
    sj	             
          U d Z ddlZddlZddlZddlZddlZddlZddlZddlZddl	Z	ddl
Z
ddlZddlZddlZddlZddlZddlZddlmZ ddlmZ ddlmZ ddlmZ ddlmZ ddlmZ dd	lmZmZmZ dd
l m!Z! ddl"m#Z$ ddl%m&Z&m'Z'm(Z(m)Z)m*Z*m+Z+m,Z,m-Z- ddl.m/Z/m0Z0m1Z1m2Z2m3Z3m4Z4m5Z5m6Z6m7Z7m8Z8m9Z9m:Z:m;Z;m<Z=m>Z>m?Z?m@Z@mAZAmBZBmCZCmDZDmEZEmFZFmGZGmHZHmIZImJZJmKZKmLZLmMZM ddlNmOZO ddlPmQZQ ddlRmSZS 	 ddlTZTn# eU$ r dZTY nw xY w ejV        eW          ZXdZYdZZde[de\de\fdZ]de\fdZ^de\fdZ_ G d de`          Za G d de`          Zb ejc        d          Zdde[de[fdZede[deffd Zgd!e&de&fd"Zhd#e(e[e&f         de*e[         fd$Zidd&e[de[fd'Zj ek            Zl emd(d)h          Znd*e[de,e[e)e[         f         fd+Zode[de,e[e)e[         f         fd,Zpd-e)e[         de)e[         fd.Zqd-e)e[         de)e[         fd/Zr e-d0          Zs e!            d1z  Ztd2Zud3ZvetZwdefd4Zxd5Zyd6Zzd7Z{e,ed8f         e|d9<   de*e         fd:Z}d;Z~deffd<Z emh d=          Zdae*ef         e|d><   d?e&deffd@ZdeffdAZdeffdBZde)e         fdCZdDedEedeffdFZdGeddfdHZdIZdJZdae*e[         e|dK<    ej                    Z e            Zee[         e|dL<    ej                    Z e            Zee[         e|dM<    ej                    ZdNe*e[         ddfdOZde*e[         fdPZdQZdNe(e[e&f         deffdRZdSe)e(e[e&f                  de)e(e[e&f                  fdTZ ejc        dU          ZdNe(e[e&f         deffdVZdSe)e(e[e&f                  de)e(e[e&f                  fdWZddYe[de[fdZZd[ej        de*e[         fd\Zd[ej        ddfd]Zd[ej        ddfd^Zd[ej        ddfd_Z	 dd`e*e         deffdaZ#de[fdbZde[fdcZ G dd deej                  Zd1d5dfd[ej        dge[dhefde[fdiZd[ej        dje[de[fdkZd5dld[ej        dge[dmefde[fdnZde[fdoZd5dpdge[dqefdrefddfdsZdge[dteddfduZd1dvd[ej        dge[ddfdwZdxZ e            Zee[         e|dy<    ej                    ZdtedeffdzZdtedeffd{Zd|Zdtee[z  dz  deffd}Zd~ZdZde[fdZdGedeffdZdZdZej        dk    Zej        dGefd            Zd[ej        ddfdZdZdZdGedefdZdGeddfdZdGeddfdZdGedeffdZdddGedefddddfdZdGeddfdZefdGede\ddfdZdGeddfdZd1dvdGedge[ddfdZdGede*e[         fdZdddGedefde(e[e&f         fdZdGedefde(e[e&f         de(e[e&f         fdZdZdZdefdZdeffdZd[ej        deffdZ G d deҦ          Z G d deҦ          Z G d deԦ          Z G d deҦ          ZddZdd5ddede\defdeffdZdede*e         fdZdGede(e[e&f         fdZdGede*e\         fdZdZdZdZdZ emh d          Zde*e[         defde*e[         de[fdZ G d deSeQeO          Z G d dæ          Zd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)deque)contextmanager)Path)sanitize_contextActivityProvenance)_sanitize_surrogates)SKILL_EXCERPT_JOINTSKILL_SCAFFOLD_SQL_LIKEdescribe_skill_invocation)get_hermes_home)is_sqlite_wal_reset_vulnerable)AnyCallableDictListOptionalSetTupleTypeVar)_BRANCH_CHILD_SQL_COMPRESSION_CHILD_SQL_FTS_CJK_TRIGGERS_FTS_TRIGGERS_LISTABLE_CHILD_SQL_PREVIEW_RAW_SELECT_RESET_END_REASONS_RESET_END_REASONS_SQL_ephemeral_child_sql_legacy_reset_child_sql_shape_preview_sql_session_last_active_sql_session_last_active_by_idescape_likeDEFERRED_INDEX_SQLFTS_CJK_STALE_KEYFTS_SQLFTS_STALE_KEYFTS_STORAGE_VERSIONFTS_TRIGRAM_SQLLEGACY_FTS_SQLLEGACY_FTS_TRIGRAM_SQLMAX_FTS5_QUERY_CHARS
SCHEMA_SQLSCHEMA_VERSION_PREVIEW_CONTENT_SQL_PREVIEW_HEAD_CHARS_PREVIEW_MAX_CHARS_PREVIEW_SCAFFOLD_WINDOW_PREVIEW_SCAFFOLDED_SQL)SessionPortabilityMixin)SessionSchemaMixin)SessionSearchMixini N  keyfallbackreturnc                     	 ddl m}  |                                d          pi }|                    |           }||S t          |          }|dk    r|n|S # t          $ r |cY S w xY w)a  Resolve a transcript safety limit from config at call time.

    Reads ``sessions.<key>`` from config.yaml lazily (avoiding a circular
    import at module load) and falls back to the module constant when the
    config subsystem is unavailable (scaffold installs, stripped test
    environments). A value of 0 disables the guard entirely. No caching:
    ``load_config_readonly`` is already mtime-cached, and resolving fresh
    keeps tests that monkeypatch config or the module constants working.
    r   load_config_readonlysessions)hermes_cli.configr=   getint	Exception)r8   r9   r=   sessions_cfgvaluelimits         0/home/agent/.hermes/hermes-agent/hermes_state.py_configured_transcript_limitrG   b   s    
::::::++--11*==C  %%=OE



uu0   s   =A  A A('A(c                  ,    t          dt                    S )z:Config-resolved resume guard limit (0 disables the guard).max_resume_messages)rG   MAX_SAFE_RESUME_MESSAGES     rF   resolved_max_resume_messagesrM   y       '7  rL   c                  ,    t          dt                    S )zDConfig-resolved in-memory export guard limit (0 disables the guard).max_export_messages)rG   MAX_SAFE_EXPORT_MESSAGESrK   rL   rF   resolved_max_export_messagesrR      rN   rL   c                   2     e Zd Zedfdededef fdZ xZS )SessionResumeTooLargeErrorzacross its lineagemessage_countrE   scopec           	      z    || _         || _        t                                          d| d| d| d           d S )Nzsession has at least z active messages z; safe resume limit is zi. Export the session instead, or set sessions.max_resume_messages: 0 in config.yaml to disable the guard.)rU   rE   super__init__)selfrU   rE   rV   	__class__s       rF   rY   z#SessionResumeTooLargeError.__init__   sq     +
SM S SE S S$)S S S	
 	
 	
 	
 	
rL   )__name__
__module____qualname__rJ   rA   strrY   __classcell__r[   s   @rF   rT   rT      sf         .)	
 

 
 	
 
 
 
 
 
 
 
 
 
rL   rT   c                   0     e Zd Zefdededef fdZ xZS )SessionExportTooLargeError
session_idrU   rE   c                     || _         || _        || _        t                                          d| d| d|            d S )Nz	session 'z' has at least z1 active messages; safe in-memory export limit is )rd   rU   rE   rX   rY   )rZ   rd   rU   rE   r[   s       rF   rY   z#SessionExportTooLargeError.__init__   sk     %*
6
 6 6= 6 6.36 6	
 	
 	
 	
 	
rL   )r\   r]   r^   rQ   r_   rA   rY   r`   ra   s   @rF   rc   rc      sc        
 .	
 

 
 	
 
 
 
 
 
 
 
 
 
rL   rc   z(?:^|:)pid=(\d+)(?::|$)system_promptc                 t    t          j        |                     d                                                    S )Nutf-8)hashlibsha256encode	hexdigest)rf   s    rF   _system_prompt_hashrm      s,    >-..w7788BBDDDrL   holderc                    t                               | pd          }|dS 	 t          |                    d                    }n# t          t
          f$ r Y dS w xY w|dk    rdS |t          j                    k    rdS t          '	 t          j	        |           S # t          $ r Y dS w xY wt          j        dk    rdS 	 t          j        |d           n*# t          $ r Y dS t          t          t           f$ r Y dS w xY wdS )a  Return True only when a structured lock holder's local PID is gone.

    Compression locks are stored in a host-local SQLite database and holder
    IDs created by ``conversation_compression`` start with ``pid=<n>``. A
    process killed during gateway shutdown cannot release its lease, so waiting
    for the full TTL makes every new turn repeatedly attempt compaction. Reclaim
    only when the kernel proves that PID no longer exists; legacy/unstructured
    holders, same-process holders, permission errors, and any probe doubt
    remain protected until normal TTL expiry (conservative: PID reuse must
    never steal a live lease, and a wrongly-kept lease self-heals via TTL).
     NF   r   ntT)_COMPRESSION_LOCK_HOLDER_PID_REsearchrA   group	TypeError
ValueErrorosgetpidpsutil
pid_existsrB   namekillProcessLookupErrorPermissionErrorOSErrorOverflowError)rn   matchpids      rF   (_compression_lock_holder_process_is_deadr      sB    ,226<R@@E}u%++a..!!z"   uu
axxu
bikk u	 (---- 	 	 	55	 
w$u
Q   ttWm4   uu5s;   "A AAB 
B)(B)?C 
C<"C<;C<rD   c                 N    t          | t                    rt          |           n| S )a>  Replace lone surrogates when *value* is text; pass anything else through.

    sqlite3 encodes bound ``str`` parameters as UTF-8 and raises
    ``UnicodeEncodeError`` on lone surrogates (U+D800..U+DFFF), so a single
    such code point anywhere in a message aborts the whole write. No-op for
    well-formed text.
    )
isinstancer_   r	   rD   s    rF   _scrub_surrogatesr      s'     +5UC*@*@K&&&eKrL   rowc                     |                      d          pd                                }|r|S |                      d          pd                                }|pdS )uv  A session's workspace grouping key: its git repo root when known, else
    its cwd.

    Branch is deliberately excluded so checking out a new branch doesn't
    fragment a workspace's session history. Returns None for cwd-less (unbound)
    sessions. Both fields are already recorded on ``sessions`` — this just picks
    the coarser identity for grouping/filtering.
    git_repo_rootrp   cwdN)r@   strip)r   rootr   s      rF   workspace_keyr      s]     GGO$$*1133D 775>>R
&
&
(
(C;$rL   model_configcolc                     d|  dS )Nzjson_extract(COALESCE(z, '{}'), '$._delegate_from')rK   )r   s    rF   _delegate_from_jsonr      s    GCGGGGrL   autocustom
cwd_prefixc                 h    |                      d          p| }t          |          }d|| d| dgfS )N/\zA(s.cwd = ? OR s.cwd LIKE ? ESCAPE '\' OR s.cwd LIKE ? ESCAPE '\')/%z\\%)rstrip_escape_like)r   prefixescs      rF   _cwd_prefix_clauser     sL    u%%3F v

CM	C]]]+ rL   c                 j    |                      d          p| }t          |          \  }}d| d|g|fS )u  Match sessions whose ``workspace_key(row)`` equals ``key``.

    Mirrors :func:`workspace_key`: a session belongs to workspace ``key``
    when its recorded ``git_repo_root`` equals ``key``, or — for rows that
    predate per-session git metadata — when its ``cwd`` is at or under
    ``key`` (so a session started in ``repo/src`` still groups with ``repo``).
    Used by ``hermes -c``/``--resume`` to continue the most recent session in
    the *current* workspace rather than the global MRU.
    r   z@(s.git_repo_root = ? OR (COALESCE(s.git_repo_root, '') = '' AND )))r   r   )r8   r   
cwd_clause
cwd_paramss       rF   _workspace_key_clauser     sO     ZZ%#F/77J
Y:YYY	* rL   
parent_idsc                    t                      }d |D             t                    t                    }|rd                    dt	          |          z            }|                     d| d| d| d| d	||z             }fd	|                                D             }                    |           |fd
D             S )ue  Delegate-subagent ids to cascade-delete with *parent_ids*.

    Only rows carrying the ``_delegate_from`` marker (set at creation, and
    backfilled by the v16 migration) — generic untagged children keep the
    orphan-don't-delete contract. Walks marker chains recursively so an
    orchestrator subagent's own delegate children go too (FK safety).
    c                     h | ]}||S rK   rK   .0sids     rF   	<setcomp>z._collect_delegate_child_ids.<locals>.<setcomp>6  s    ...S#.S...rL   ,?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 idrK   )r   r   founds     rF   
<listcomp>z/_collect_delegate_child_ids.<locals>.<listcomp>G  s+    UUU#c$iu>T>TCI>T>T>TrL   c                     g | ]}|v|	S rK   rK   )r   r   seedss     rF   r   z/_collect_delegate_child_ids.<locals>.<listcomp>J  s#    555CCu$4$4C$4$4$4rL   )r   setlistjoinlenexecutefetchallupdate)connr   dffrontierphcursorr   r   s         @@rF   _collect_delegate_child_idsr   -  s    
		B..J...E %jjEE{{H
 XXcCMM)**ER E Eb E E)+E E35E E Ex
 

 VUUU):):UUUX   655555555rL   c                    t          | |          }|rsd                    dt          |          z            }|                     d| d|           |                     d| d|           |                     d| d|           |S )Nr   r   *DELETE FROM messages WHERE session_id IN ()IUPDATE sessions SET parent_session_id = NULL WHERE parent_session_id IN ("DELETE FROM sessions WHERE id IN ()r   r   r   r   )r   r   idsr   s       rF   _delete_delegate_childrenr   M  s    
%dJ
7
7C
 	FXXcCHHn%%G"GGGMMM1+-1 1 1	
 	
 	

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

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

    Precedence:

    1. A deliberately re-pointed ``DEFAULT_DB_PATH`` (differs from the
       import-time snapshot — the established test escape hatch) wins.
    2. Otherwise resolve ``get_hermes_home()`` fresh so a runtime
       ``HERMES_HOME`` redirect takes effect regardless of import order.
    r   )DEFAULT_DB_PATH_IMPORT_DEFAULT_DB_PATHr   rK   rL   rF   _default_db_pathr   {  s'     111z))rL   FHERMES_STATE_DB_GUARD_BYPASSrK   . _STATE_DB_GUARD_EXTRA_DENY_ROOTSc                     	 t           j        dk    r|t          j                            dd                                          } | rt          |           dz  n4t          t          j                            d                    dz  dz  dz  }n/t          t          j                            d                    dz  }|	                                S # t          $ r Y d	S w xY w)
uA  Resolve the REAL platform-default Hermes root for the guard.

    Deliberately avoids ``Path.home()`` / ``hermes_constants``: tests
    routinely monkeypatch ``Path.home`` to a tempdir, and ``hermes_state``
    is often imported lazily *while* such a patch is active — resolving
    through the patched callable would misidentify the test's own hermetic
    home as "production" (false positive) or, worse, miss the real one
    (false negative).  ``os.path.expanduser`` reads the HOME environment
    variable / passwd entry, which the hermetic conftest never rewrites.
    win32LOCALAPPDATArp   hermes~AppDataLocalz.hermesN)sysplatformrx   environr@   r   r   path
expanduserresolverB   )baser   s     rF   _real_platform_state_rootr     s    <7"":>>."55;;==D TT

X%%"',,S1122Y>H8S D **3//009<D||~~   tts   CC 
CCHERMES_TEST_ISOLATIONc                      t          t          j                            d          pBt          j                            d          p#t          j                            t                              S )zBTrue when this process (or a parent test process) is a pytest run.PYTEST_CURRENT_TESTPYTEST_VERSION)boolrx   r   r@   _TEST_ISOLATION_MARKER_ENVrK   rL   rF   _running_under_pytestr     sT    

,-- 	6:>>*++	6:>>455  rL   >   py.test
pytest.exepy.test.exepytest_PYTEST_ANCESTORprocc                    	 |                                  pg }n# t          $ r Y dS w xY w|D ]}	 t          |                              d                              d          }|                    dd                              dd          d                                         }n# t          $ r Y w xY w|t          v r dS dS )	aS  True when *proc*'s command line is a pytest invocation.

    Covers both ``pytest ...`` (launcher on argv[0]) and ``python -m pytest``
    (launcher as a bare ``pytest`` token).  A process whose command line we
    cannot read is treated as "not pytest": guessing the other way would
    refuse production opens for unrelated reasons.
    F"'\/rq   T)cmdlinerB   r_   r   replacersplitlower_PYTEST_LAUNCHER_NAMES)r   r   argtokenr|   s        rF   _process_looks_like_pytestr     s    ,,..&B   uu 
 
	HHNN3''--c22E ==s++223::2>DDFFDD 	 	 	H	)))44 *5s    
''A7B''
B43B4c                      t           t           S d} t          O	 t          j                                                    D ]}t	          |          rd}  nn# t
          $ r d} Y nw xY w| a | S )uv  True when some ancestor process of this one is a pytest run.

    ``_running_under_pytest`` reads ``PYTEST_*`` env vars, which a child
    spawned with a rebuilt environment loses at the same moment it loses the
    ``HERMES_HOME`` redirect: that child aims at the production DB *and*
    disarms the guard in one step (#82770).  Ancestry is the one test-context
    signal that survives an env rebuild, so it backs the env check up.

    Fails open (``False``) when ``psutil`` is unavailable or the walk errors —
    that restores the previous env-only behaviour rather than blocking real
    user runs on a psutil hiccup.
    NFT)r   rz   Processparentsr   rB   )r   parents     rF   _has_pytest_ancestorr     s     #E	 .**2244  -f55  EE  	 	 	EEE	Ls   ;A A$#A$c                  >    t                      rdS t                      S )uq  True when this process is a test run, by environment or by ancestry.

    Order matters for cost: the env probe is two dict lookups and covers the
    common in-process case, so the ancestry walk only runs for processes the
    environment claims are ordinary user runs — and its answer is memoised,
    so a real ``hermes`` invocation pays for at most one walk.
    T)r   r   rK   rL   rF   _in_test_contextr  )  s#      t!!!rL   c                     g } t                      }||                     |           t          D ]Y}	 |                     t          |                                                                                     J# t          $ r Y Vw xY w| S N)r   appendr   r   r   r   rB   )roots	real_rootextras      rF   _production_state_rootsr	  6  s    E)++IY1  	LLe//1199;;<<<< 	 	 	H	Ls   AA99
BBresolvedr   c                     | j         |k    rdS 	 |                     |          }n# t          $ r Y dS w xY w|j        }t	          |          dk    o|d         dk    S )a  True when *resolved* is a DB file of the real Hermes home *root*.

    Matches files directly in the root (``<root>/state.db``) and profile
    homes (``<root>/profiles/<name>/state.db``).  Deliberately does NOT
    match deeper scratch paths (e.g. repo worktrees that happen to live
    under ``~/.hermes/hermes-agent/...``) so hermetic tests using unusual
    tempdirs cannot false-positive.
    TF   r   profiles)r   relative_torw   partsr   )r
  r   relr  s       rF   _is_production_state_dbr  C  sw     $t""4((   uuIEu::?5uQx:55s   % 
33db_pathc           
         t           s$t          j                            t                    rdS t                      sdS 	 t          |                                                                           }n# t          $ r Y dS w xY wt                      D ]0}t          ||          rt          d| d| dt           d          1dS )u7  Fail hard when a pytest-context process resolves a production DB.

    Raises ``RuntimeError`` before any connection, mkdir, journal-mode
    pragma, or byte probe can touch the live database.  No-op outside
    pytest and for hermetic (tmp ``HERMES_HOME``) paths.

    "pytest context" means environment *or* process ancestry — see
    :func:`_in_test_context`.  Env alone is not enough: a child spawned with
    a rebuilt environment loses ``PYTEST_*`` and ``HERMES_HOME`` together,
    which is precisely the state in which it writes to production (#82770).
    NzAlive-system guard: test attempted to open production state.db at z (under real Hermes root u  ). Tests must run against a temporary HERMES_HOME — pass an explicit tmp db_path or let the hermetic conftest redirect HERMES_HOME. If this test genuinely needs the live database, mark it with @pytest.mark.live_system_guard_bypass — or, for a spawned child process, export z=1 in its environment.)_STATE_DB_GUARD_BYPASSrx   r   r@   _STATE_DB_GUARD_BYPASS_ENVr  r   r   r   rB   r	  r  RuntimeError)r  r
  r   s      rF   _ensure_test_isolationr  V  s     0J!K!K  ==++--5577   '))  "8T22 	#'# #BF# # *D# # #
 
 
	 s   3A3 3
B B)zlocking protocolznot authorizeddisk i/o errori   _last_init_error_wal_fallback_warned_paths_wal_reset_bug_warned_pathsmsgc                 J    t           5  | addd           dS # 1 swxY w Y   dS )uo  Record (or clear) the most recent state.db init failure.

    Thread-safe via _last_init_error_lock.  Callers pass a message to
    record a failure or None to clear.  SessionDB.__init__ only calls
    this to SET on failure — it deliberately does NOT clear on success,
    because in a multi-threaded caller (e.g. gateway / web_server per-
    request SessionDB() instantiation), a concurrent successful open
    racing past a different thread's failure would erase the cause
    string that thread's /resume handler is about to format.  Explicit
    clears (e.g. test fixtures) are still supported by passing None.
    N)_last_init_error_lockr  )r  s    rF   _set_last_init_errorr    st     
                   s   c                      t           S )aK  Return the most recent state.db init failure, if any.

    Slash-command handlers (``/resume``, ``/title``, ``/history``, ``/branch``)
    call this to surface the underlying cause in their error messages when
    ``_session_db is None``.  Returns ``None`` if SessionDB initialized
    successfully (or hasn't been attempted).
    )r  rK   rL   rF   get_last_init_errorr!    s
     rL   )z:Review the conversation above and update the skill libraryz;Review the conversation above and consider saving to memoryc                 $   t          | t                    sdS |                     d          dvrdS |                     d          }t          |t                    sdS |                                t          fdt          D                       S )ay  True when ``msg`` is a persisted background-review harness prompt.

    These are user/system turns the forked skill/memory review agent wrote into
    a real session in older builds (before the ``_persist_disabled`` isolation
    fix). They instruct the agent to act as the curator under a hard tool
    restriction, so replaying them as live history hijacks the session.
    Frole>   usersystemcontentc              3   B   K   | ]}                     |          V  d S r  )
startswith)r   pheads     rF   	<genexpr>z8_is_background_review_harness_message.<locals>.<genexpr>  s/      DDatq!!DDDDDDrL   )r   dictr@   r_   lstripany_REVIEW_HARNESS_PREFIXES)r  r&  r*  s     @rF   %_is_background_review_harness_messager0    s     c4   u
wwv000uggi  Ggs## u>>DDDDD+CDDDDDDrL   messagesc                     | s| S g }d}| D ]\}t          |          rd}|r1d}t          |t                    r|                    d          dk    rG|                    |           ]|S )aS  Drop background-review harness messages and the curator-mode assistant
    reply that immediately followed each one.

    Walk the list once; when a harness user/system message is found, skip it and
    also skip the next message if it is the assistant turn that answered it.
    Everything else passes through untouched and in order.
    FTr#  	assistant)r0  r   r,  r@   r  )r1  outskip_next_assistantr  s       rF    _strip_background_review_harnessr6    s       "C 	 	055 	"& 	"'#t$$ K)G)G

3JrL   z^\[[A-Za-z_][A-Za-z0-9_.-]*\]$c                 ^   t          | t                    sdS |                     d          dk    rdS |                     d          sdS |                     d          }t          |t                    sdS t	          t
                              |                                                    S )a  True when ``msg`` is a persisted assistant turn whose content is a bare
    bracketed marker (e.g. ``[memory]``) left over from a tool-call turn.

    Before the #78148 fix in ``agent.conversation_loop``, a local tool-call
    template could emit a bare marker as assistant content alongside a real
    tool call. The loop cached that marker as a fallback and later replayed
    it as the "final response", persisting it into the session. Sessions
    written before the fix can still carry these rows.
    Fr#  r3  
tool_callsr&  )r   r,  r@   r_   r   _STALE_TOOL_CALL_MARKER_RE	fullmatchr   )r  r&  s     rF   "_is_stale_tool_call_marker_messager;    s     c4   u
wwv+%%u77<   uggi  Ggs## u*44W]]__EEFFFrL   c                     d}| D ]}t          |          r
d|d<   |dz  }|rt                              d|           | S )u  Clear bare protocol-marker content persisted before the #78148 fix.

    Replaying "[memory]" as if the model had actually answered teaches the
    model, by example, to keep emitting the same marker in later turns — the
    exact symptom the issue reported. Only the stray ``content`` field is
    blanked; the tool call and its result are left untouched so provider
    tool_call/tool_result pairing stays intact. Sessions with no affected
    rows pass through unchanged.
    r   rp   r&  rq   zMCleared %d stale tool-call marker message(s) while restoring session (#78148))r;  loggerinfo)r1  repairedr  s      rF   _strip_stale_tool_call_markersr@  !  sf     H  -c22 	C	NMH 
[	
 	
 	
 OrL   Session database not availabler   c                     t                      s|  dS d}t          fdt          D                       rd}|  d | dS )u~  Format a user-facing 'session DB unavailable' message with cause.

    When ``SessionDB()`` init fails, callers set ``_session_db = None`` and
    several slash commands (/resume, /title, /history, /branch) previously
    responded with a bare ``"Session database not available."`` — no
    indication of WHY.  This helper includes the captured cause (typically
    ``"locking protocol"`` from NFS/SMB) and points users at the known
    culprit so they can fix it themselves.

    Example output:
        Session database not available: locking protocol (state.db may be
        on NFS/SMB — see https://www.sqlite.org/wal.html).
    .rp   c              3   D   K   | ]}|                                 v V  d S r  )r   )r   markercauses     rF   r+  z0format_session_db_unavailable.<locals>.<genexpr>L  s0      
G
Gv6U[[]]"
G
G
G
G
G
GrL   uN    (state.db may be on NFS/SMB/FUSE/ZFS — see https://www.sqlite.org/wal.html): )r!  r.  _WAL_INCOMPAT_MARKERS)r   hintrF  s     @rF   format_session_db_unavailablerJ  :  sq      !!E |||D

G
G
G
G1F
G
G
GGG `_&&&t&&&&rL   r   c                 z   d}t          d          D ]
}	 |                     d                                          }n\# t          j        $ rJ}|}dt          |                                          vrY d}~ dS t          j        d           Y d}~d}~ww xY w| dS |d         }t          |t                    r)	 |                    d          }n# t          $ r Y  dS w xY w|3t          |                                                                          ndc S |t                              d|           dS )	u  Read the journal mode from the SQLite DB header on disk.

    Returns the mode string (e.g. ``"wal"``, ``"delete"``), or ``None``
    if the value cannot be determined (new DB, or PRAGMA read failed).

    A PRAGMA read can fail transiently with ``disk i/o error`` on
    virtualized block devices (XFS on cloud hosts).  Treating that as
    "mode unknown" pushes callers onto their fail-closed unknown-mode
    branch even though the on-disk mode is perfectly readable a few
    milliseconds later.  Retry the read a few times before giving up:
    transient EIO clears, deterministic unsupported-filesystem errors do
    not.  ``None`` is still returned on final failure so the caller's
    existing "unknown → refuse to downgrade" logic applies.
    N   PRAGMA journal_moder  皙?r   asciiz:_on_disk_journal_mode: retries exhausted on disk read (%s))ranger   fetchonesqlite3OperationalErrorr_   r   timesleepr   bytesdecodeUnicodeDecodeErrorr   r=  debug)r   last_exc_r   excmodes         rF   _on_disk_journal_moder^  Q  sp    %)H1XX G G	,,455>>@@CC' 	 	 	Hs3xx~~'7'777ttttttJtHHHH	 ;441vdE"" 	{{7++%   ttt,0,<s4yy  &&((($FFFH(	
 	
 	
 4s-   '>B%B9BB=C
C"!C"c                     	 |                      dt                      dS # t          j        $ r&}t                              d|           Y d}~dS d}~ww xY w)u  Bound the WAL so it returns space to the OS after big transactions.

    SQLite's default ``journal_size_limit`` is -1 (unlimited): after a
    checkpoint the WAL file is *reused in place* and never truncated, so
    ``state.db-wal`` permanently retains the high-water mark of the largest
    transaction ever run against it.

    A single bulk operation is enough to strand gigabytes. Observed on a
    3.0 GB ``state.db``: ``hermes sessions optimize`` (FTS merge + VACUUM)
    rewrites every page through the WAL, leaving a **3.07 GB**
    ``state.db-wal`` sitting next to the database indefinitely — the host
    went from 6.9 GB free to 772 MB (100% full) and stayed there, because
    nothing shrinks the WAL back down. An explicit
    ``PRAGMA wal_checkpoint(TRUNCATE)`` reclaimed the full 3.07 GB, which
    confirms the space was pure slack rather than live data.

    That also makes the maintenance command self-defeating on exactly the
    databases that need it most: the larger the DB, the larger the WAL it
    strands, so ``optimize`` can consume more disk than it frees.

    ``journal_size_limit`` makes SQLite truncate the WAL back to the limit
    at each checkpoint. 64 MiB is comfortably above normal transaction
    sizes (so steady-state commits never pay a truncate) while capping the
    stranded slack at a bounded, predictable figure.

    ``hermes_cli/kanban_db.py`` already bounds its WAL growth with
    ``wal_autocheckpoint=100``; the session store — by far the larger
    database — had no equivalent.

    Best-effort: never raises. A failure here only costs disk slack, and
    must not prevent the database from opening.
    zPRAGMA journal_size_limit=z"journal_size_limit not applied: %sN)r   _WAL_SIZE_LIMIT_BYTESrR  rS  r=  rY  )r   r\  s     rF   _apply_wal_size_limitra  z  sx    B@I2GIIJJJJJ# @ @ @93?????????@s   ! AAAc                     t           j        dk    rdS 	 |                     d           dS # t          j        $ r Y dS w xY w)u  Enable ``PRAGMA checkpoint_fullfsync`` on macOS (no-op elsewhere).

    On Darwin, ``synchronous=FULL`` (the WAL default) issues a plain
    ``fsync()``, which Apple documents does *not* guarantee that data
    has reached stable storage or that writes are not reordered — see
    the ``fsync(2)`` man page.  SQLite's WAL corruption-safety guarantee
    assumes the OS honors the fsync write barrier; macOS does not unless
    the app uses ``F_FULLFSYNC``.

    During a launchd *system* shutdown/reboot the OS page cache is
    dropped (effectively a power-loss event for in-flight pages), so a
    WAL checkpoint whose ``fsync()`` "reported" durable may never have
    hit the platter — corrupting ``state.db`` with a malformed image.
    This is the trigger in issue #30636 ("SIGTERM during launchd
    shutdown under high load"), distinct from a plain in-session kill
    (which the page cache survives and SQLite recovers from).

    ``checkpoint_fullfsync=1`` forces an ``F_FULLFSYNC`` barrier only at
    checkpoint boundaries — where WAL frames land in the main DB — so the
    cost amortizes to roughly +0.1 ms/commit (vs ~+4 ms for the broader
    ``fullfsync=1`` that flushes on every commit's WAL sync).  Guarded by
    ``sys.platform == "darwin"`` because ``F_FULLFSYNC`` is macOS-only;
    on other platforms the PRAGMA is a no-op, so we skip it entirely.

    Best-effort: never raises.
    darwinNzPRAGMA checkpoint_fullfsync=1r   r   r   rR  rS  r   s    rF   _apply_macos_checkpoint_barrierrf    sY    6 |x455555#      + >>c                     t           j        dk    rdS 	 |                     d           dS # t          j        $ r Y dS w xY w)uS  Enforce ``PRAGMA synchronous=FULL`` on macOS to prevent btree corruption.

    On Darwin, the default ``synchronous=NORMAL`` only calls ``fsync()``,
    which Apple's fsync(2) man page explicitly states does *not* guarantee
    data-on-platter or write-ordering. During a WAL checkpoint race with
    process termination (e.g., launchd shutdown), this can leave the main
    DB with half-written btree pages → ``btreeInitPage error 11``.

    WAL mode's durability guarantee assumes the OS honors fsync barriers;
    macOS does not unless we explicitly set ``synchronous=FULL``, which issues
    a real ``fsync()`` on every transaction commit.  The ``F_FULLFSYNC``
    barrier at checkpoint boundaries is handled separately by
    :func:`_apply_macos_checkpoint_barrier`.

    This function is called after any successful WAL activation (either
    from ``apply_wal_with_fallback()`` setting a fresh WAL or when probing
    an existing WAL mode). It ensures macOS connections always use FULL
    synchronous mode, even if a prior connection set ``synchronous=NORMAL``.

    Best-effort: never raises.
    rc  NzPRAGMA synchronous=FULLrd  re  s    rF   _enforce_macos_synchronous_fullri    sY    , |x./////#   rg  version_infoc                 @    | | nt           j        }t          |          S )a:  Return True when the linked SQLite library has the WAL-reset bug.

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

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

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

    Covers both shapes of WAL refusal on network filesystems (NFS / SMB / FUSE
    / the AgentFS NFS overlay): SQLite *raising* ``SQLITE_PROTOCOL`` ("locking
    protocol"), and the quieter macOS-NFS case where ``PRAGMA journal_mode=WAL``
    silently returns the still-effective mode without raising.  Subclasses
    ``sqlite3.OperationalError`` so existing ``except sqlite3.OperationalError``
    DB-init handling still catches it, while callers that specifically mandate
    WAL can catch this narrower type.
    Nr\   r]   r^   __doc__rK   rL   rF   r}  r}    s        
 
 
 
rL   r}  )db_labelrequire_walr  r  c                   t                      }t                      rt          | ||dk              S t          |           }|dk    r/t	          |            t          |            t          |            dS |dk    rH|t          j        d          t          | d          }|dk    rt          j        d|pd d	          |S 	 | 
                    d
                                          }|rA|d         9t          |d                                                                                   nd}|dk    r/t	          |            t          |            t          |            dS t          d|d	          }|r|t!          ||           |pdS # t          j        $ r}	t#          |	t                    r t          |	                                          t%          fdt&          D                       s dv rt)          d          D ] }
t+          j        d           	 | 
                    d
                                          }nB# t          j        $ r0}dt          |                                          vr |}	Y d}~zd}~ww xY w|rA|d         9t          |d                                                                                   nd}|dk    r4t	          |            t          |            t          |             Y d}	~	dS  t          |           }|dk    s| |rt          t          |	                    |	t!          ||	           t          | d           Y d}	~	dS d}	~	ww xY w)u
  Set ``journal_mode=WAL`` on ``conn``, falling back to DELETE on failure.

    Returns the journal mode actually set (``"wal"`` or ``"delete"``).

    On WAL-incompatible filesystems (NFS, SMB, some FUSE, ZFS), SQLite either
    raises ``OperationalError("locking protocol")`` /
    ``OperationalError("disk I/O error")`` or — on macOS NFS / SMB /
    the AgentFS NFS overlay — silently refuses the switch and leaves the DB in
    DELETE.  Either way the degradation is logged at ERROR level (it is a real
    loss of concurrency — a write blocks concurrent readers — not a cosmetic
    warning) and, by default, the function falls back to DELETE (the pre-WAL
    default, which works on NFS and ZFS) so the feature keeps working.

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

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

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

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

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

    Never downgrades to DELETE if the on-disk DB header reports WAL — see
    _on_disk_journal_mode.  That holds for both the NFS path and the
    WAL-reset vulnerability path.
    rx  )r  require_deleterv  N   could not verify journal mode before applying configured journal_mode=delete (database is locked — possible concurrent openers); refusing to downgrade a database this process does not exclusively ownDELETE2could not set configured journal_mode=delete (got 	no resultr   zPRAGMA journal_mode=WALr   rp   z0journal_mode=WAL refused without raising (still c              3       K   | ]}|v V  	d S r  rK   )r   rE  r  s     rF   r+  z*apply_wal_with_fallback.<locals>.<genexpr>  s'      EEV6S=EEEEEErL   r     rN  )r{  r   _apply_delete_for_wal_reset_bugr^  ra  rf  ri  rR  rS  _set_journal_mode_no_waitr   rQ  r_   r   r   r}  _log_wal_fallback_oncer   r.  rH  rP  rT  rU  )r   r  r  
configuredcurrent_modeactualr   r]  
silent_excr\  r[  	retry_excexistingr  s                @rF   apply_wal_with_fallbackr  (  s   l &''J &'' 
.%1
 
 
 	
 )..Lud###'---'---u X *8   +4::X*]VEZ{]]]   N ll455>>@@.1Pc!f6Hs3q6{{  ""((***b5==!$'''+D111+D1115 )HtHHH
 

  	x444x# 4 4 4 c.// 	#hhnnEEEE/DEEEEE 	 s""1XX  
4   ,,'@AAJJLLCC/   's9~~/C/C/E/EEE#CHHHH	 "1v1 AKK%%''--/// 
 5==)$///3D9993D999 555555 )..u 0 	9%c#hh//S8x---!$111xxxxxi4sR   BF (*F M3#BM.''IM.J&J	M.	JA<M.AM..M3r]  c                    d}	 |                      d                                          }|r|d         t          |d                   }n$# t          j        t
          t          f$ r d}Y nw xY w|                      d           	 |                      d|                                           }|rA|d         9t          |d                                                   	                                nd	 	 |                      d|            S # t          j        $ r Y S w xY w# 	 |                      d|            w # t          j        $ r Y w w xY wxY w)u  Execute ``PRAGMA journal_mode=<mode>`` without waiting on other openers.

    This is the ONLY place a journal-mode switch pragma may be issued for a
    non-WAL target.  It temporarily forces ``busy_timeout=0`` so SQLite's own
    exclusivity requirement becomes a concurrent-opener detector: leaving WAL
    mode requires exclusive access to the database, so if ANY other connection
    (this process or another) holds the DB open, the pragma fails immediately
    with ``database is locked`` instead of waiting out a busy timeout and
    sneaking the flip in between a concurrent writer's transactions — which is
    exactly how committed-but-uncheckpointed WAL transactions get destroyed.

    Callers must treat a raised ``OperationalError`` as "not exclusively
    owned: leave the journal mode alone", never as a retryable condition.

    Returns the resulting journal mode as reported by SQLite (lowercase), or
    ``""`` when SQLite returned no row.
    r   zPRAGMA busy_timeoutNzPRAGMA busy_timeout=0zPRAGMA journal_mode=rp   zPRAGMA busy_timeout=)
r   rQ  rA   rR  rS  rv   rw   r_   r   r   )r   r]  previous_timeoutr   s       rF   r  r    s   $ ll011::<< 	+3q6%"3q6{{$i<   LL()))ll8$8899BBDD.1Pc!f6Hs3q6{{  ""((***bP	LLB0@BBCCCC' 	 	 	D		LLB0@BBCCCC' 	 	 	D	sU   AA A,+A,A.D# 5DD D #E%D>=E>EEEE)r  r  c                \   t          |           }|dk    r@t          |d           t          |            t          |            t	          |            dS |*|rt          j        d          t          |dd           dS d}	 t          | d          }nc# t
          j        $ rQ}|r t          |          	                                }d	|v sd
|v rt          |dd           |pdcY d}~S Y d}~nd}~ww xY w|r |dk    rt          j        d|pd d          t          |d           dS )u  Avoid enabling WAL when the linked SQLite has the WAL-reset bug.

    - Already-WAL on disk: leave WAL alone (no live downgrade) and warn.
    - Mode unreadable (probe blocked by a concurrent opener's locks):
      ownership is not provably exclusive — leave the journal mode alone
      and warn.  Never treat "could not read the mode" as "not WAL": that
      exact confusion let a vulnerable-SQLite process flip a live WAL
      state.db to DELETE under a concurrent WAL writer, destroying its
      committed-but-uncheckpointed transactions.
    - Otherwise: set DELETE (refusing to wait out concurrent openers) and
      warn.
    - For an explicit operator request, verify SQLite accepted DELETE.
    rv  T)kept_walNr  )r  indeterminaterp   r  lockedbusyrx  r  r  r   F)
r^  _log_wal_reset_bug_oncera  rf  ri  rR  rS  r  r_   r   )r   r  r  currentr  r\  lowereds          rF   r  r     s   & $D))G% 	 48888d###'---'---u  	*8   	 4tLLLLuF'*4::# 	' 	' 	' 	c((..""w&G"3"3 $Ht4PPPP&h&&&&&& #4"3"3"3"3		'  
&H,,&-)k- - -
 
 	
 Hu55558s   B C6%AC1&C61C6c                      	 ddl m} m}m}  |  |                      } ||          }|dv rd| dS |dk    rd| dS |S # t          $ r Y nw xY w	 dS )	zReturn a context-appropriate hint for repairing the SQLite runtime.

    Uses the codebase's install-type detection so the hint matches what
    ``hermes update`` can actually do for this install (#75153).
    r   )detect_install_method%recommended_update_command_for_methodget_project_root>   gitunknownz>Hermes-managed installs can repair the embedded runtime with ``dockerz!update the container image with `zdinstall a Python build bundled with SQLite 3.51.3+ (or backports 3.50.7 / 3.44.6) and restart Hermes)r?   r  r  r  rB   )r  r  r  methodcmds        rF   _wal_reset_repair_hintr  D  s    	
 	
 	
 	
 	
 	
 	
 	
 	
 	

 '&'7'7'9'9::33F;;'''ZTWZZZZX=s====
   	< s   1A A  A 
AA)r  r  r  c                *   t           5  | t          v r	 ddd           dS t                              |            ddd           n# 1 swxY w Y   |rd}n|rd}nd}t                      }t                              d| t          j        ||           dS )zHLog once per (process, db_label) about the WAL-reset vulnerability path.Nu   journal mode could not be verified or exclusively switched (database is locked — possible concurrent openers); leaving the journal mode untouched (no live downgrade under concurrent openers)u\   is already in WAL mode — leaving WAL in place (no live downgrade under concurrent openers)z1using journal_mode=DELETE instead of enabling WALu   %s: linked SQLite %s is vulnerable to the WAL-reset corruption bug (https://sqlite.org/wal.html#walresetbug) — %s. Upgrade to SQLite 3.51.3+ (or backports 3.50.7 / 3.44.6); %s. See `hermes doctor`. This warning fires once per process per database.)_wal_reset_bug_warned_lockr  addr  r=  warningrR  sqlite_version)r  r  r  actionrepair_hints        rF   r  r  `  s%    
$ 2 22222 2 2 2 2 2 2 2 	$''1112 2 2 2 2 2 2 2 2 2 2 2 2 2 2  E 	 
 E2 	
 E )**K
NN	 
 	
 
 
 
 
   AAA
A
r\  c                     t           5  | t          v r	 ddd           dS t                              |            ddd           n# 1 swxY w Y   t                              d| |           dS )u  Log a single ERROR per (process, db_label) about WAL fallback.

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

    Without this dedup, NFS users running kanban (which opens a fresh
    connection on every operation — see hermes_cli/kanban_db.py) would
    fill errors.log with hundreds of identical errors per hour.
    Nu  %s: WAL journal_mode unsupported on this filesystem (%s) — falling back to journal_mode=DELETE (slower rollback-journal mode; reduces concurrency but works on NFS/SMB/FUSE/ZFS). See https://www.sqlite.org/wal.html for details. This message fires once per process per database.)_wal_fallback_warned_lockr  r  r=  error)r  r\  s     rF   r  r    s     
# 1 11111 1 1 1 1 1 1 1 	#&&x0001 1 1 1 1 1 1 1 1 1 1 1 1 1 1 LL	/
 	    r  r  c                   	 ddl m}m}  |            }n# t          $ r Y dS w xY wdD ]} ||d|d          }|	 t	          t          |                                                    }n4# t          t          f$ r  t          
                    d|||           Y tw xY w	 |                     d| d	|            # t          j        $ r Y w xY wdS )
u+  Apply optional performance and WAL-sizing PRAGMAs from ``config.yaml``.

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

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

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

    Best-effort: config load or pragma failures are ignored so DB init
    never breaks on a malformed ``database:`` section.
    r   )cfg_getr=   N)
cache_size	mmap_size
temp_storewal_autocheckpointjournal_size_limitru  )defaultz'%s: ignoring non-integer database.%s=%rzPRAGMA =)r?   r  r=   rB   rA   r_   r   rv   rw   r=  r  r   rR  rS  )r   r  r  r=   cfgpragma_name	raw_valuerD   s           rF   apply_database_pragmasr    sL   4CCCCCCCC""$$   
   GC[$GGG			I,,..//EE:& 	 	 	NN9	   H		LL8;88889999' 	 	 	D	- s/    
##.A,,.BB!B==CC)zmalformed database schemaz database disk image is malformed_repair_attempted_pathsc                 |     t           t          j                  sdS t           fdt          D                       S )zTrue if *exc* is a SQLite 'malformed schema / disk image' error.

    These are the corruption classes where the schema fails to parse, so
    targeted ``sqlite_master`` surgery (not an ordinary FTS rebuild) is the
    only recovery path.
    Fc              3   ^   K   | ]'}|t                                                    v V  (d S r  r_   r   )r   rE  r\  s     rF   r+  z(is_malformed_db_error.<locals>.<genexpr>  s8      RRfvS)))RRRRRRrL   )r   rR  DatabaseErrorr.  _MALFORMED_SCHEMA_MARKERSr\  s   `rF   is_malformed_db_errorr    sC     c7011 uRRRR8QRRRRRRrL   c                     t          | t          j                  sdS dt          |                                           v S )u  True if *exc* is SQLite's 'file is not a database' error.

    Raised when a connection's backing file is not a SQLite database — the
    runtime connection-corruption class: a sibling process (forked curator
    agent, external repair pass) replaced/truncated the file out from under
    the live connection.  The file on disk may be perfectly healthy; the
    CONNECTION is broken.  Distinct from the malformed-schema class: the fix
    is a reconnect, not schema surgery.
    Ffile is not a database)r   rR  r  r_   r   r  s    rF   _is_not_a_database_errorr    s9     c7011 u#s3xx~~'7'777rL   )zno space left on deviceznot enough spacezdatabase or disk is fullz	disk fullz	full diskenospcc                 ,   | dS t          | t                    r!t          | dd          t          j        k    rdS t          | t
                    r| nt          |           }|                                t          fdt          D                       S )a  True when *exc* (or a stringified error) is a disk-full / ENOSPC failure.

    Covers:
      * ``OSError`` with ``errno.ENOSPC``
      * SQLite ``OperationalError: database or disk is full`` (SQLITE_FULL)
      * Plain English / errno strings that survive RPC wrapping
    NFerrnoTc              3       K   | ]}|v V  	d S r  rK   )r   rE  r  s     rF   r+  z%is_disk_full_error.<locals>.<genexpr>=  s(      BBVv BBBBBBrL   )	r   r   getattrr  ENOSPCr_   r   r.  _DISK_FULL_MARKERS)r\  textr  s     @rF   is_disk_full_errorr  /  s     {u#w GC$$?$?5<$O$OtS#&&433CHHDjjllGBBBB/ABBBBBBrL   )r  compressioncompression_closed
turn_leasecorruptdiskr  )	malformedr  znot a databasezdatabase corruptionc                    | dS t          | t                    rdS t          | t                    rdS t          | t                    rdS t	          |                                           dv rdS dv rdS dv sd	v rdS t          fd
t          D                       rdS dv sdv rdS t          |           sdv sdv sdv rdS dS )u!  Classify a session-persistence failure into a coarse cause bucket.

    Fast-failing a turn on a SessionDB write error is deliberate (the
    transcript would otherwise be lost on restart), but the *guidance* the
    user gets must match the cause: sustained SQLite write-lock contention
    ("database is locked" on a shared state.db) needs "storage was busy,
    send it again", while a full disk or read-only database needs the
    disk-space/permissions advice. Returns one of PERSISTENCE_ERROR_CAUSES:

    * ``"locked"``  — SQLite lock/busy contention (another process holds the
      database write lock); transient, retry-later guidance applies.
    * ``"compression"`` — a live compression lease refused the transcript
      write; the database itself is healthy and unlocked.
    * ``"compression_closed"`` — the write targeted a session already
      rotated (closed) by compression and no live continuation was adopted;
      the store is healthy — the client must refresh/adopt the new session
      id, so disk-space advice would be a misdiagnosis.
    * ``"turn_lease"`` — a presented session-turn-lease holder no longer
      owns the conversation (expired, released, or reclaimed); fail-fast
      fencing, not a storage fault.
    * ``"corrupt"`` — the database file itself is structurally damaged
      (``database disk image is malformed`` / SQLITE_NOTADB).  Distinct from
      ``"disk"``: freeing space cannot help, the user needs the repair path
      (``hermes doctor`` / automatic schema surgery).
    * ``"disk"``    — disk full / read-only / permission-shaped failures
      (delegates the disk-full patterns to :func:`is_disk_full_error` so the
      two classifiers can never drift apart — e.g. ENOSPC).
    * ``"unknown"`` — anything else (or no visible exception at all).
    Nr  r  r  r  z
turn leasezclosed by compressionzbeing compressedzcompression leasec              3       K   | ]}|v V  	d S r  rK   )r   rE  r  s     rF   r+  z-classify_persistence_error.<locals>.<genexpr>  s'      
?
?f6T>
?
?
?
?
?
?rL   r  r  r  r  readonlyz	read-only)	r   SessionTurnLeaseLostErrorCompressionSessionClosedErrorCompressionSessionBusyErrorr_   r   r.  _DB_CORRUPTION_MARKERSr  )
exc_or_strr  s    @rF   classify_persistence_errorr  ^  s-   < y *788 |*;<< $##*9:: }z??  ""Dt|$&&##T!!%8D%@%@}
 
?
?
?
?(>
?
?
??? yDT>>x:&&T>>$v9rL   c                     t          |           }t          5  |t          v r	 ddd           dS t                              |           	 ddd           dS # 1 swxY w Y   dS )a  Claim the one-shot repair attempt for *db_path* in this process.

    Returns True for the first caller, False afterwards. Keeps a malformed
    DB from triggering an unbounded repair/reopen loop and stops concurrent
    callers from racing surgery on the same file.
    NFT)r_   _repair_attempt_lockr  r  )r  r8   s     rF   _claim_repair_attemptr    s     g,,C	  )))        	 ##C(((	                 s   AAAAg      ^@皙?r   c              #   `  K   |                      | j        dz             }	 |j                            dd           |                    d          }n8# t
          $ r+}t                              d||           dV  Y d}~dS d}~ww xY wd}	 t          j	                    t          z   }	 	 t          rHddl}|                    d           |                    |                                |j        d	           n9ddl}|                    |                                |j        |j        z             d}nJ# t*          t
          f$ r5 t          j	                    |k    rY n t          j        t.                     Y nw xY w|s!t                              d
|t                     |V  	 |rt          rHddl}|                    d           |                    |                                |j        d	           n1ddl}|                    |                                |j                   n# t
          $ r Y nw xY w|                                 dS # |                                 w xY w# 	 |rt          rHddl}|                    d           |                    |                                |j        d	           n1ddl}|                    |                                |j                   n# t
          $ r Y nw xY w|                                 w # |                                 w xY wxY w)u{  Serialize state.db schema surgery across processes.

    Yields True when this process holds the repair lock for *db_path*, False
    when the bounded acquire timed out.  Unlike the kanban init lock — whose
    critical section is idempotent, so proceeding without the lock is merely
    redundant work — proceeding here would be exactly the unsafe interleaving
    we are trying to prevent, so a caller that gets False must NOT do surgery.

    ``flock`` is the right primitive for this: the kernel drops the lock when
    the holding process dies, so a crashed repairer cannot leave a stale lock
    that wedges every future repair (a pidfile would).  The acquire is still
    bounded because a *live* repairer can legitimately sit in ``VACUUM`` for
    minutes on a large DB, and an unbounded wait would hang the caller's open
    with no traceback (the failure shape of #36644).
    z.repair.lockTr   exist_oka+bu^   Could not open state.db repair lock %s (%s) — proceeding with in-process serialisation only.NFr   rq   u   state.db repair lock %s held by another process for more than %.0fs — skipping schema surgery in this process to avoid racing the repairer.)	with_namer|   r   mkdiropenr   r=  r  rT  	monotonic_REPAIR_LOCK_TIMEOUT_SECONDS_IS_WINDOWSmsvcrtseeklockingfilenoLK_NBLCKfcntlflockLOCK_EXLOCK_NBBlockingIOErrorrU  _REPAIR_LOCK_POLL_SECONDSLK_UNLCKLOCK_UNrq  )r  	lock_pathhandler\  acquireddeadliner  r  s           rF   _cross_process_repair_lockr    s^     " !!',"?@@Itd;;;&& 	 	 	 	-.7	
 	
 	
 


	 H*>##&BB	66 P!MMMKKNNNNN6==??FOQGGGG LLLKK1NOOO#W- 6 6 6>##x//E
4555556	6"  	NN- 7	   	 	@ @!MMMKKNNNNN6==??FOQGGGG LLLKK??? 	 	 	D	 LLNNNNNFLLNNNN	 	@ @!MMMKKNNNNN6==??FOQGGGG LLLKK??? 	 	 	D	 LLNNNNFLLNNNNs   1A 
B BBI* +B
D6 5I* 6(E<I*  E<9I* ;E<<+I* (BH+ *I +
H85I 7H88I I'*L-,BK/.L/
K<9L;K<<L?L-L**L-c                 B   	 |                      d                                          d         }|                      dt          |          dz   dz              dS # t          j        t
          t          f$ r&}t                              d|           Y d}~dS d}~ww xY w)uX  Increment the schema cookie after direct ``sqlite_master`` surgery.

    Ordinary DDL bumps this counter for free, and every other connection
    compares it before running a prepared statement — that is how they learn
    to discard a cached schema.  Editing ``sqlite_master`` under
    ``PRAGMA writable_schema=ON`` does NOT bump it, so live connections in
    other processes keep compiling statements against the schema we just
    deleted objects from — e.g. writing ``messages`` rows through triggers
    into ``messages_fts*`` shadow tables that no longer exist.  SQLite's
    writable_schema documentation calls out incrementing ``schema_version``
    as the required companion to such an edit.

    Best-effort and never raises: a failed bump leaves exactly the
    pre-existing behaviour, and the repair itself is still worth completing.
    zPRAGMA schema_versionr   zPRAGMA schema_version=rq   iz)Could not bump state.db schema cookie: %sN)	r   rQ  rA   rR  r  rv   
IndexErrorr=  r  )r   r  r\  s      rF   _bump_schema_cookier    s     I,,677@@BB1E 	Os7||a/?:.MOOPPPPP!9j9 I I IBCHHHHHHHHHIs   AA B8BBr  c                 <    |                      | j        dz             S )Nz.repair-attempts.json)r  r|   )r  s    rF   _repair_ledger_pathr	  A  s    W\,CCDDDrL   zOptional[str]c                 p    	 |                                  }|j         d|j         S # t          $ r Y dS w xY w)aa  Cheap identity for a damaged DB file: size + mtime_ns.

    Hashing a multi-GB corrupt file on every open is exactly the kind of
    repeated cost this ledger exists to avoid; size+mtime is stable for a
    file nothing can successfully write to, and any successful repair,
    truncation or manual restore changes it (resetting the attempt count).
    :N)statst_sizest_mtime_nsr   )r  sts     rF   _db_fingerprintr  E  sN    \\^^*//r~///   tts   $' 
55zDict[str, Any]c                     	 t          j        t          |                               d                    }t	          |t
                    r|S n# t          t          f$ r Y nw xY wi S )Nrh   encoding)jsonloadsr	  	read_textr   r,  r   rw   )r  rz  s     rF   _read_repair_ledgerr  T  sv    j,W55???QQRRc4   	J	Z    Is   AA A#"A#c                     t          |           }|dS t          |           }|                    d          |k    o+t          |                    dd                    t          k    S )ap  Whether *db_path* has already burned its cross-restart repair budget.

    True only when the ledger records ``_MAX_PERSISTENT_REPAIR_ATTEMPTS``
    failed attempts against the CURRENT file fingerprint. Never raises; a
    missing/corrupt ledger or unstatable DB reads as "not exhausted" (the
    in-process claim and cross-process lock still bound a single run).
    NFfingerprintfailed_attemptsr   )r  r  r@   rA   _MAX_PERSISTENT_REPAIR_ATTEMPTS)r  fpledgers      rF   %_persistent_repair_attempts_exhaustedr  ^  sg     
	!	!B	zu ))F

=!!R' 	U

,a00115TTrL   )r  r?  r  c          	      F   t          |           }	 |r|                    d           dS ||nt          |           }|dS t          |           }|                    d          |k    r&t          |                    dd                    dz   nd}ddl}|                    t          j	        |||j        
                                                    d	          d
          d           dS # t          $ r&}t                              d|           Y d}~dS d}~ww xY w)u   Update the persistent attempt ledger after a repair pass. Never raises.

    Defaults to the post-attempt fingerprint — the file state the NEXT
    attempt's exhaustion probe will observe.
    T
missing_okNr  r  r   rq   seconds)timespec)r  r  last_attemptrh   r  z+Could not update state.db repair ledger: %s)r	  unlinkr  r  r@   rA   datetime
write_textr  dumpsnow	isoformatrB   r=  r  )	r  r?  r  ledger_pathr  r  attemptsr&  r\  s	            rF   _record_repair_outcomer-  p  sz    &g..KK 	$///F'3[[9Q9Q:F$W-- zz-((B.. 

,a0011A55 	
 	J#%'/$,$5$9$9$;$;$E$E!* %F % %    	 	
 	
 	
 	
 	
  K K KDcJJJJJJJJJKs#   C0 C0 B,C0 0
D :DD z
List[Path]c                     | j          d	 fd| j                                        D             }n# t          $ r g cY S w xY wt	          |d d          S )z8Timestamped forensic backups of *db_path*, newest first..malformed-backup-c                 |    g | ]8}|j                                       |j                             d           6|9S )-wal-shm)r|   r(  endswith)r   r)  r   s     rF   r   z/_existing_malformed_backups.<locals>.<listcomp>  sX     
 
 
v  ((
 FOO$455	

 
 
rL   c                     | j         S r  )r|   )r)  s    rF   <lambda>z-_existing_malformed_backups.<locals>.<lambda>  s    qv rL   T)r8   reverse)r|   r   iterdirr   sorted)r  r   r   s     @rF   _existing_malformed_backupsr:    s    000F
 
 
 
^++--
 
 
    			%--t<<<<s   %3 AAkeepc                 J   t          |           |d         D ]}||                    |j        dz             |                    |j        dz             fD ]L}	 |                    d           # t          $ r&}t
                              d||           Y d}~Ed}~ww xY wdS )zADelete all but the *keep* newest forensic backups (and sidecars).Nr2  r3  Tr   z&Could not prune stale DB backup %s: %s)r:  r  r|   r%  r   r=  r  )r  r;  stalevictimr\  s        rF   _prune_malformed_backupsr?    s    ,W55dee< 	V 	VOOEJ/00OOEJ/00
 	V 	VF
V.... V V VGQTUUUUUUUUV	V	V 	Vs   A//
B9BBz$Tuple[Optional[Path], Optional[str]]c                    ddl }ddl}	 ddlm} n# t          $ r d}Y nw xY w|1 ||           r&d|  d}t
                              d| |           d|fS |j                                                             d          }| 	                    | j
         d|           }d	}|                                r;| 	                    | j
         d| d
|           }|d	z  }|                                ;	 	 |                                 }t          |           dd	         D ]W}	|	                                }
|
j        |j        k    r1|
j        |j        k    r!t
                              d|	           |	dfc S Xn# t"          $ r Y nw xY w|                    | |           dD ]d}| 	                    | j
        |z             }|                                r1|                    ||	                    |j
        |z                        et'          |            |dfS # t(          $ r-}t
                              d| |           dd| fcY d}~S d}~ww xY w)u  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 ``(backup_path, None)`` on success or
    ``(None, reason)`` on failure — callers on the repair path treat a
    refused backup as a HARD STOP (see #69603: proceeding without the
    pre-repair backup leaves the writable_schema surgery, FTS deletion and
    VACUUM strategies mutating the only remaining copy of the damaged DB).

    Refuses when a connection to this database is still live in the process:
    reading the file would ``close()`` a descriptor for it and cancel that
    connection's POSIX advisory locks (see ``hermes_cli.sqlite_safe_read``).
    The repair path can be entered by one SessionDB while the gateway holds
    others, so this is a real possibility rather than a theoretical one.
    r   N)has_live_connectionza connection to z is still open in this process; raw-copying it would cancel that connection's POSIX advisory locks. Close all SessionDB handles first.z&Refusing to raw-copy %s for backup: %s%Y%m%d_%H%M%Sr/  rq   r[  zBReusing existing forensic backup %s (identical to the damaged DB).r1  z%Could not back up malformed DB %s: %szbackup copy failed: )r&  shutilhermes_cli.sqlite_safe_readrA  ImportErrorr=  r  r)  strftimer  r|   existsr  r:  r  r  r>  r   copy2r?  rB   r  )r  r&  rC  rA  reasonstampbackup_pathseqsrc_statr  estsuffixsidecarr\  s                 rF   _backup_db_filerQ    s
     OOOMMM#CCCCCCC # # #"# &+>+>w+G+G&8w 8 8 8 	
 	=wOOOV|!!##,,_==E##w|$N$Nu$N$NOOK C




 ''|<<u<<s<<
 
 	q	 



 
2
	||~~H7@@!D 
* 
*mmooK8#3338+???KK'(0   $T>)))
*  	 	 	D	Wk***& 	X 	XF''v(=>>G~~ XWk&;&;K<Lv<U&V&VWWW )))D   2 2 2>MMM1C1111111112sP      4BE8 6E8 7H 8
FH FBH 
I""I
I
Ic                   t          |           }|dk    s|                    d          rdS 	 t          t                                                                n# t
          $ r dY nw xY wdt          dt          ffddddt          d	t          ddffd
}| j        }|                                r ||d           dD ]B}|r| 	                    | j
        |z             n| }|                                r ||           CdS )u5  Refuse-or-repair read-only DB files BEFORE the first connection opens.

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

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

    ``:memory:`` and ``file:`` URI paths are skipped (no plain on-disk files
    to check). Shared by :class:`SessionDB` and ``hermes_cli.kanban_db``.
    ro  file:Nr)  r:   c                     dS 	 |                                                                S # t          t          f$ r Y dS w xY wNF)r   is_relative_tor   rw   )r)  homes    rF   _in_repair_scopez2preflight_db_writability.<locals>._in_repair_scope&  sU    <5	99;;--d333$ 	 	 	55	s   &. AAF)is_dirrY  c                n   dd l }t          j        | t          j        t          j        z            rd S  |           r	 |j        |j        z  |r|j        ndz  }t          j        | |                                  j	        |z             n# t          $ r Y nw xY wt          j        | t          j        t          j        z            r#t                              d| |rdnd           d S |rdnd}| j                            d          rdnd}t          j         d	| d
|  d|rdnd d|  d|           )Nr   z2%s preflight: repaired read-only %s (chmod u+rw%s)xrp   	directoryfiler2  uv    Do NOT delete the -wal file — it contains committed data that will be merged into the database once it is writable.z is not writable:  zf is read-only for this user. Hermes needs read-write access to open the database. Fix with: chmod u+rwz 'z4' (files owned by another user may need sudo/chown).)r  rx   accessR_OKW_OKS_IRUSRS_IWUSRS_IXUSRchmodst_moder   r=  r>  r|   r4  rR  rS  )r)  rY  _statr  kindwal_noterX  r  s         rF   _ensure_writablez2preflight_db_writability.<locals>._ensure_writable.  s   9Q"')** 	FA 	mem37Uu}}TUVAFFHH,s23333   yBGbg-.. H!)CCr	   $0{{& vv&& D D 	 	 & M M4 M M! M M*0#833bM M<=M M CKM M
 
 	
s    A
B 
BBT)rp   r2  r3  )r_   r(  r   r   r   rB   r   r   rY  r  r|   is_file)	r  r  rz  rj  r   rO  r)  rX  rW  s	    `     @@rF   preflight_db_writabilityrl    s   8 g,,C
jCNN733#O$5$566>>@@   D T       5: 
 
 
D 
T 
d 
 
 
 
 
 
 
B ^F}} . 	----&    8>KGglV3444G99;; 	 Q   s   -A A.-A.c                 T   t          j        t          |           d          }	 t          |           |                    d                                           |                    d                                          }d |D             }|r1d                    |dd                   |                                 S |                    d                                           d	D ]}	 |                    d
| d| d                                           2# t           j	        $ ru}t                              |          rY d}~`t          |                                          }d|v sd|v rY d}~d| d| cY d}~c |                                 S d}~wt           j        $ r(}d| d| cY d}~c |                                 S d}~ww xY wdt          j                     }	 |                    d           |                    d|dt          j                    f           |                    d|ddt          j                    f           |                    d           n# t           j	        $ r}	 |                    d           n# t           j        $ r Y nw xY wt          |                                          }d|v sd|v rY d}~|                                 dS d|v rY d}~|                                 dS t          |          cY d}~|                                 S d}~ww xY w	 |                                 dS # t           j        $ r-}t          |          cY d}~|                                 S d}~ww xY w# |                                 w xY w)u/  Probe a DB on a fresh connection. Returns None if healthy, else a reason.

    Runs the same first-statement (``PRAGMA journal_mode``) that trips the
    malformed-schema parse, then ``PRAGMA integrity_check`` and a canonical
    ``sessions`` read, and finally a rolled-back ``messages`` write so that
    FTS5 index corruption — which leaves base-table reads and
    ``integrity_check`` passing while every ``INSERT INTO messages`` fails
    through the FTS triggers — is reported as unhealthy rather than slipping
    past as a false "ok" (#50502).
    Nisolation_levelrM  zPRAGMA integrity_checkc                     g | ]D}|t          |d                                                    dk    /t          |d                    ES )r   okr  r   rs     rF   r   z%_db_opens_cleanly.<locals>.<listcomp>q  sE    OOO!qOS1YY__5F5F$5N5NC!II5N5N5NrL   z; r  SELECT COUNT(*) FROM sessionsmessages_ftsmessages_fts_trigrammessages_fts_cjkzSELECT 1 FROM  WHERE z MATCH '""' LIMIT 1zno such tablezno such columnzfts5 read probe failed on rG  _hermes_fts_health_probe_BEGIN IMMEDIATEz>INSERT INTO sessions (id, source, started_at) VALUES (?, ?, ?)_health_probezOINSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)r$  _fts_health_probeROLLBACK no such tokenizer: cjk_unicode61)rR  rp  r_   load_fts5_cjk_extensionr   rQ  r   r   rq  rS  	SessionDB_is_fts5_unavailable_errorr   r  rT  time_nsrr  )r  r   rowsproblems	fts_tabler\  r  probe_session_ids           rF   _db_opens_cleanlyr  [  sG    ?3w<<>>>Do 	 %%%*++44666||455>>@@OOtOOO 	+99Xbqb\**F 	

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

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

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

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

 s88OOOOO
 	

)	   	

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

 	

s   BM +M -.DM G+FM 
)F3M 8F?G M G-G4G5M GM /A?I/ .M /L8?JL3J'$L3&J'',L3M -L31M L3L8M 3L88M N!N	/N0N 	NN N'T)backupr  c          	      n   ddddd}t          |           } |                                 s
|  d|d<   |S t          |           rIdt           d|  dt	          |           j         d	|d<   t                              d
|d                    |S t          |           5 }|s:t          |           d|d<   d|d<   |cddd           S d|d<   |cddd           S t          | ||          }t          | t          |                    d                               |cddd           S # 1 swxY w Y   dS )a  Repair a state.db whose ``sqlite_master`` schema is malformed or whose
    FTS indexes reject writes.

    Handles two corruption classes: the "duplicate object definition" /
    malformed-schema class where even ``PRAGMA`` statements fail, and the FTS
    write-corruption class (#50502) where base tables read fine and
    ``integrity_check`` passes but writes fail through the ``messages_fts*``
    triggers. Tries least-destructive recovery first and escalates:

      1. **Rebuild FTS indexes in place** via the FTS5 ``'rebuild'`` command,
         which rewrites the internal b-tree segments from the canonical
         ``messages`` rows without dropping or recreating anything. Fixes the
         FTS write-corruption class while preserving the schema intact.
      2. **De-duplicate** ``sqlite_master`` (keep the lowest rowid per
         ``type``/``name``). Fixes the canonical "table X already exists"
         case and PRESERVES the existing FTS index intact.
      3. **Drop the FTS schema** (every ``messages_fts*`` object) + ``VACUUM``.
         The next ``SessionDB()`` open rebuilds the FTS indexes from the
         canonical ``messages`` table.

    Canonical ``sessions`` / ``messages`` rows are never modified. A
    timestamped raw backup is taken first unless ``backup=False``.

    The surgery below is serialised across processes (see
    :func:`_cross_process_repair_lock`): the gateway service, the Desktop
    app's backend and interactive CLI sessions all open the same file, and
    two of them running ``writable_schema`` surgery concurrently is itself a
    corruption source.

    Returns a report dict: ``{repaired: bool, strategy: str|None,
    backup_path: str|None, error: str|None}``.
    FN)r?  strategyrK  r  z does not existr  z$automatic repair has already failed u    times on this exact file — the corruption is beyond the schema/FTS repair strategies (likely b-tree page damage). Manual recovery required: restore a backup, or salvage with `sqlite3 z ".recover"`. Delete z$ to force another automatic attempt.zstate.db repair skipped: %sTr?  repaired_by_other_processr  zYanother process holds the state.db repair lock; skipped schema surgery to avoid racing it)r  report)r?  )r   rG  r  r  r	  r|   r=  r  r  r  _repair_state_db_schema_lockedr-  r   r@   )r  r  r  holding_lockresults        rF   repair_state_db_schemar    s   D 	 F 7mmG>> $555w -W55 !.! ! 3:	! !
 *'227! ! ! 	w 	2F7ODDD	#G	,	,  	 !))1%)z"%@z"       4 7O         0vVVV 	wfjj6L6L1M1MNNNN/                 s   D*D*AD**D.1D.r  c                B
   t          |           d|d<   d|d<   |S |rUt          |           \  }}|rt          |          nd|d<   |+d| |d<   t                              d	|d                    |S 	 t          j        t          |           d
          }	 t          |           dD ]4}	 |                    d| d| d            # t
          j	        $ r Y 1w xY w	 |
                                 n# |
                                 w xY wt          |           'd|d<   d|d<   t                              d|            |S n7# t
          j        $ r%}t                              d|           Y d}~nd}~ww xY w	 t          j        t          |           d
          }	 |                    d           |                                 |
                                 n# |
                                 w xY wt          |           'd|d<   d|d<   t                              d|            |S n7# t
          j        $ r%}t                              d|           Y d}~nd}~ww xY w	 t          j        t          |           d
          }	 |                    d           |                    d                                          }|D ] \  }	}
}}|                    d|	|
|f           !|rt          |           |                    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           t          |           |                    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 )"z}Repair strategies for :func:`repair_state_db_schema`.

    Caller must hold the cross-process repair lock for *db_path*.
    NTr?  already_healthyr  rK  zepre-repair backup refused; aborting schema repair to avoid mutating the only copy of the damaged DB: r  zstate.db repair aborted: %srn  ru  zINSERT INTO (z) VALUES('rebuild')rebuild_ftsz<state.db FTS indexes rebuilt in place (schema preserved): %sz-state.db FTS in-place rebuild pass failed: %sREINDEXreindex_btreez/state.db B-tree indexes rebuilt via REINDEX: %sz state.db REINDEX pass failed: %szPRAGMA writable_schema=ONzhSELECT type, name, COUNT(*) AS c, MIN(rowid) AS keep FROM sqlite_master GROUP BY type, name HAVING c > 1zFDELETE FROM sqlite_master WHERE type IS ? AND name IS ? AND rowid <> ?zPRAGMA writable_schema=OFFdedup_schemazRstate.db schema repaired by de-duplicating sqlite_master (FTS index preserved): %sz%state.db dedup repair pass failed: %sz9DELETE FROM sqlite_master WHERE name LIKE 'messages_fts%'VACUUMdrop_fts_rebuildzdstate.db schema repaired by dropping FTS schema; indexes will rebuild from messages on next open: %szsstate.db schema repair could not recover %s automatically (backup: %s); manual restore from backup may be required.)r  rQ  r_   r=  r  rR  rp  r  r   rS  rq  r  r  commitr   r  )r  r  r  bpathbackup_errorr   
table_namer\  dupestype_r|   _countr;  rI  s                 rF   r  r  3	  s	    !!)!z.z -g66|.3 =E


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

 Ms   4#E C4 ,C	C4 	CC4 CC4 E 4D

8E E8E33E8<#H.  )G 	H. G448H. .I"=II"&#N 
BL8 #N 8M8N N<N77N< #S $A7Q0 S 0R:S S S2S--S2a\  
CREATE VIEW IF NOT EXISTS messages_fts_cjk_src AS
    SELECT id, role, content, tool_name, tool_calls
    FROM messages
    WHERE role <> 'tool';

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

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

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

    Returns False (never raises) when the .so is absent, the feature is
    disabled via ``sessions.cjk_fts``, or this Python build has extension
    loading compiled out — every caller treats False as "behave exactly as
    before the cjk index existed".
    FTz#fts5_cjk extension load failed (%s)exc_info)	r  r  rG  enable_load_extensionload_extensionr_   rB   r=  r  )r   r   s     rF   r  r  ;
  s     #$$ uD;;== u	""4(((	.D		***&&u----D&&u----t   <dTRRRuus)   B  "B .B  BB   'C
Cc                   (     e Zd ZdZdef fdZ xZS )r  z@A durable write targeted a parent already closed by compression.rd   c                 `    || _         t                                          d|d           d S )NzSession zP is closed by compression; adopt its live continuation before appending messages)rd   rX   rY   )rZ   rd   r[   s     rF   rY   z&CompressionSessionClosedError.__init__W
  sJ    $Dz D D D	
 	
 	
 	
 	
rL   )r\   r]   r^   r  r_   rY   r`   ra   s   @rF   r  r  T
  sJ        JJ
3 
 
 
 
 
 
 
 
 
 
rL   r  c                       e Zd ZdZdS )r  z>A non-owner tried to write while compression owns the session.Nr~  rK   rL   rF   r  r  _
  s        HHHHrL   r  c                       e Zd ZdZdS )!SessionCompressionInProgressErrora  A concurrent writer collided with a *live* compression lock.

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

    Subclassing keeps every existing ``except CompressionSessionBusyError``
    handler working unchanged.
    Nr~  rK   rL   rF   r  r  c
  s           rL   r  c                       e Zd ZdZdS )r  ak  A transcript write presented a turn-lease holder that no longer owns it.

    Fail-fast fencing: do not retry inside ``_execute_write``. The caller
    either still thinks it owns the conversation after expiry/reclaim, or
    the lease row is gone. A later writer may already be persisting a
    newer turn; landing this write would interleave a stale reply.
    Nr~  rK   rL   rF   r  r  s
  s           rL   r  c                     	 ddl m} nJ# t          $ r= t                              d|            t          j        t          |           fi |cY S w xY w || f|t
          j        d|S )a  ``sqlite3.connect`` that registers the open fd for lock-safety.

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

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

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

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

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

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

_ 7E _ (F5_ 7F_ F!_ 1BI I2 II2 II2 2J/_ <BM M= M$!M= #M$$M= =N*_ BQ R Q*'R )Q**R RR2 1_ 2S>_  SB_ U6 4_ 6ZY<_ "BX6 5Y# 6Y
Y# 	Y

Y# #Y9<Z:_ <2[/._ /
[<9_ ;[<<_ B^_^*'_)^**__b6Ba10b1bbbbb6b33b6c                    dddddddddddddd}	 t          t          |           dz             }|                                r|                                j        nd|d<   n# t
          $ r Y nw xY wd	 t          dt          |            dt          |           dd	
          n5# t          $ r(}t          	                    d| |           |cY d}~S d}~ww xY wdt          dt          ffd}	  |d          } |d          }|t          |          nd|d<   |t          |          nd|d<   |d         |d         |d         |d         z  |d<    |d          }|t          |          nd|d<    |d          }|t          |          nd|d<    |d          }	|	t          |	          nd|d<    |d          }
|
t          |
          nd|d<   	 d                     dd                                          D             fddD             |d <   n# t          $ r Y nw xY wd!t          dt          t                   ffd"} |d#          |d#<    |d$          } |d%          }||d$<   ||d%<   |d&|d'<   n|pd|k     |d'<   	                                  n:# t          $ r Y n.w xY w# 	                                  w # t          $ r Y w w xY wxY w|S )(u  Best-effort, strictly read-only stats snapshot of a state.db file.

    Opens the database with ``mode=ro`` (URI) and a short timeout so it can
    run against a *live* database held by a gateway without ever taking a
    write lock or mutating the file. Every field is collected independently:
    a failed pragma/SELECT yields ``None`` for that field, and the helper
    itself never raises.

    Deliberately does NOT instantiate :class:`SessionDB` — its constructor
    runs schema DDL (migrations, FTS table creation), which is exactly the
    kind of write a diagnostics probe must never perform.

    Returned keys (all present, any may be None on failure):

    - ``page_count``, ``page_size``, ``freelist_count`` — PRAGMA values
    - ``logical_size_bytes`` — page_count * page_size (post-checkpoint size)
    - ``wal_size_bytes`` — stat() of ``<db>-wal`` (0 when absent)
    - ``journal_mode`` — PRAGMA journal_mode string
    - ``messages`` / ``sessions`` — row counts
    - ``fts_tables`` — dict of {table_name: bool} presence for
      messages_fts / messages_fts_trigram / messages_fts_cjk
    - ``fts_storage_version`` — int from state_meta, None when the marker is
      absent (legacy pre-v23 inline layout)
    - ``fts_rebuild_pending`` — True when the deferred v23 backfill has not
      finished (high_water present and progress < high_water)
    - ``fts_rebuild_high_water`` / ``fts_rebuild_progress`` — raw ints
    N)
page_count	page_sizefreelist_countlogical_size_byteswal_size_bytesrw  r1  r>   
fts_tablesfts_storage_versionfts_rebuild_pendingfts_rebuild_high_waterfts_rebuild_progressr2  r   r  rS  ?mode=roT       @)r  uritimeoutz4collect_state_db_stats: cannot open %s read-only: %ssqlr:   c                     	                      |                                           }|r|d         nd S # t          $ r Y d S w xY w)Nr   )r   rQ  rB   )r  r   r   s     rF   _scalarz'collect_state_db_stats.<locals>._scalar  sZ    	,,s##,,..C *3q66d* 	 	 	44	s   26 
AAPRAGMA page_countPRAGMA page_sizer  r  r  zPRAGMA freelist_countr  rM  rw  SELECT COUNT(*) FROM messagesr1  rt  r>   c                     h | ]
}|d          S r   rK   r   r   s     rF   r   z)collect_state_db_stats.<locals>.<setcomp>  s,        A  rL   zISELECT name FROM sqlite_master WHERE type = 'table' AND name IN (?, ?, ?)ru  c                     i | ]}||v S rK   rK   )r   tnamess     rF   
<dictcomp>z*collect_state_db_stats.<locals>.<dictcomp>  s/     # # # AJ# # #rL   r  r8   c                     	                      d| f                                          }|r|d         t          |d                   nd S # t          $ r Y d S w xY w)N*SELECT value FROM state_meta WHERE key = ?r   )r   rQ  rA   rB   )r8   r   r   s     rF   	_meta_intz)collect_state_db_stats.<locals>._meta_int  st    ll@3& (**  '*Jc!f.@s3q6{{{dJ   tts   A	A 
AAr  r  r  Fr  )r   r_   rG  r  r  r   r  rB   r=  rY  r   rA   r   r   r   rq  )r  statswal_pathr\  r  pcpsfljmmsgssessr  
high_waterprogressr   r  s                 @@rF   collect_state_db_statsr  <  s;   : "##"& $ E"Gv-..=E__=N=N"U(--//"9"9TU    D #+DMM+++w--	
 
 
    Kc	# 	# 	#
S S      <W())W'(()+c"gggTl(*SWWWDk*u[/A/M*/*=k@R*RE&'W,---/^#b'''W*+++->Btnw677)-)9CIIItjw677)-)9CIIItj	 <<,P  (**  E# # # #U# # #E,  	 	 	D		3 	8C= 	 	 	 	 	 	 (1y1F'G'G#$Y788
9344*4&'(0$%+0E'((,4MZ+GE'(	JJLLLL 	 	 	D		JJLLLL 	 	 	D	 Ls   AA& &
A32A391B+ +
C5CCC5C*J:  AH$ #J: $
H1.J: 0H11A#J: J* *
J76J7:K!<KK!
KK!KK!c                    	 t           j                            d          sdS t          j                            t          |                     }d}t          j        d          D ]}}|                                sd| d}	 t          j        |          }n# t          $ r Y ?w xY w|D ]7}	 t          j
        | d|           |k    r|dz  } n(# t          $ r Y 4w xY w~|S # t          $ r Y dS w xY w)	a  Best-effort count of processes holding ``db_path`` open (Linux only).

    Scans ``/proc/*/fd`` symlinks for the resolved database path. Returns
    the number of distinct PIDs with the file open, or ``None`` on any
    error or on non-Linux platforms. Never raises; no lsof dependency.
    Unreadable per-process fd dirs (other users' processes without root)
    are silently skipped, so the count is a lower bound.
    linuxNr   z/procz/proc/z/fdr   rq   )r   r   r(  rx   r   realpathr_   listdirisdigitr   readlinkrB   )r  targetholdersr   fd_dirfdsfds          rF   count_db_holdersr    sJ   |&&w// 	4!!#g,,//:g&& 	 	C;;== &c&&&Fj((     {f#3#3r#3#344>>1 ?    H   tts_   C% AC% BC% 
B%"C% $B%%C% -"CC% 
CC% CC% %
C32C3completeinterruptedr  empty>   r  agent_errorcontent_filterr#  has_tool_callsfinish_reasonc                    |pd                                                                 t          v rt          S | pd                                                                 }|dk    r|rt          nt
          S |dv rt          S t
          S )u:  Classify a session's lifecycle from the shape of its final message.

    - assistant with a normal finish → ``complete``
    - assistant that still has pending tool_calls (no tool result row ever
      followed, or it would be the last row instead) → ``interrupted``
    - user or tool as the last row → ``interrupted`` (the agent never got to
      answer / never consumed the tool result)
    - an error finish_reason on the last row → ``error``
    - anything unrecognized → ``complete`` (benign default; pickers must not
      alarm on unknown shapes)
    rp   r3  >   toolr$  )r   r   _ERROR_FINISH_REASONSSESSION_STATUS_ERRORSESSION_STATUS_INTERRUPTEDSESSION_STATUS_COMPLETE)r#  r  r  rs  s       rF   classify_session_statusr     s      	""$$**,,0EEE##	""$$AK .<X))AXX))""rL   c            6       )   e Zd ZU dZdZdZdZdZdZdZ	dZ
d	Zd
ZdZdZdZdZdZdZdZdZdZdZedee         dee         fd            Zedd            Zedej        deeef         fd            Z edeej!                 ddfd            Z"dde#de$fd Z%deej!                 fd!Z&dd"Z'dd#Z(deej!                 fd$Z)e*d%             Z+ed&ej,        de$fd'            Z-ed&ej,        de$fd(            Z.ed)ej/        de$fd*            Z0d&ej,        ddfd+Z1d&ej,        ddfd,Z2dd-Z3ed)ej/        ddfd.            Z4d)ej/        d/ed0ede$fd1Z5	 dd2e6ej!        ge7f         d3ee8         de7fd4Z9d5e8d3e8de$fd6Z:de$fd7Z;ed&ej<        de$fd8            Z=d&ej<        de$fd9Z>d&ej<        de$fd:Z?dd;Z@dd<ZAde$fd=ZBd> ZCdd?ZDdZEd@ZFdAZGdBZHde$fdCZI	 	 	 	 	 	 	 	 	 	 	 	 	 	 ddDedEedFedGeeef         dedHedIee         dJedKedLedMedNedOedPedQedReddf"dSZJdDedEedefdTZKdddddddddUdDedEedHedIedJedKedLedRedQedVe$ddfdWZLd	dDedYe$ddfdZZMd[d\dIed]ed^eddfd_ZNd[d\d`eeef         d^eddfdaZOd[d\d^edeeef         fdbZPd[d\dceQe         d^eddfddZRdee8deQeeef                  fdfZSdgeTe         deUfdhZVddidee8djee#         deWeUeUf         fdkZXddXdldmee         dne$deQeeef                  fdoZYdddpdmedJedLee         dHee         dee         f
dqZZddddddrdEedHee         dIee         dJee         dKee         dLee         deeeef                  fdsZ[dtZ\ddudvee8         deQeeef                  fdwZ]dxedyede$fdzZ^d{Z_dMedeeeef                  fd|Z`dDede$fd}ZadddddddXddd~	dMededEedeQeeef                  dFedGeeef         dedNedOedede$deeU         deeU         ddfdZbdDededdfdZcdDeddfdZd	 d
dDedede$fdZe	 	 	 ddDedNedee         dPee         de$deeU         fdZf	 	 ddDedNedeUdee         dPee         de$fdZgdeeef         ddfdZh	 ddDede8dee         ddfdZidDedeeeef                  fdZjdDedeeef         fdZkdDedeeef         ddfdZldDeddfdZmdDedeUfdZndDedeUddfdZodIedeUfdZpdIeddfdZqdDedeUfdZrdDedeUddfdZs	 ddDedede8de$fdZt	 ddDedede8de$fdZudDededdfdZvdDedefdZwdDedefdZxddddDedede8d3ee8         de$f
dZyddd
ddddddDedede8de8de8de8de8de$fdZzdddDedede8de$fdZ{dDededdfdZ|dDedee         fdZ}	 dddddDedee8         dee         dee~         ddf
dZdDeddfdZdDedeeeef                  fdZ	 ddDededFee         ddfdZdDedee         ddfdZ	 ddDedFedee         ddfdĄZddƜdDedeeef         defdɄZdDedeeef         ddfdʄZ	 ddDedededefd̈́ZddddddΜdDedFee         dee         deeeef                  dee         de$ddfd҄ZdDede$ddfdԄZedeeeef                  de$fdք            Zedeeeef                  deeef         fdׄ            Zdd؜dDedededee         ddf
dۄZdZdZdZdDeddfd߄Zdde8de$fdZddZdeQeWeeeef         f                  ddfdZdeQeWeeeef         f                  deQeWeeeef         f                  fdZdde8ddfdZddZ	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 ddDedeUdeUdFedeUdeUdeUdee8         dee8         dee         dee         dee         dee         dee         dee         deUde$ddf$dZd[ddDedFee         dee         dee         dee         deUdeUdeUdeUdeUdee8         dee8         dee         dee         deUdeddf"dZ	 	 ddDedEedFedefdZddddddddddd 
dDededFee         dee         dee         deUdeUdeUdeUdeUdee8         deUddfdZddjddeUfdZdeUfdZdDedeeeef                  fdZdDedeeeef                  fdZdedee         fdZd	Zd
ZdZdZededediZedEee         deUfd            Zedee         dee         fd            Zdedede$fdZdDededEede$fdZdDedede$fdZdDededEede$fdZdDedede$fdZdDedee         fdZdDedee         fdZdDedEede$fdZdDede$de$fdZdDede$de$fdZdDede$de$fd Zd	dDed!e$de$fd"Zed#eeef         de$fd$            Zdedeeeef                  fd%Zdedee         fd&Zd'edefd(ZdDedee         fd)Z eh d*          ZdZee         ed+<   ddd,d-eUd.e$deee8f         fd/Z	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 ddEed1eQe         d2eQe         d3ed4eUd5eUd6e$d-eUd7e$d8e$d.e$d9e$d:ed;ed<e$d=e$dIed>e$deQeeef                  f&d?ZdgeQe         deeef         fd@ZdAZedBedefdC            ZedBedefdD            ZedEedee         fdF            Z	 	 ddDedee         dGee         dHe8ddf
dIZedJedeeeef                  fdK            ZedLedee         fdM            Z	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 ddDedNedBedOedPedQedReUdSedTedUedVedWedXedYedZe$d[ee         d\ed]ee         d^ee         dEeeeef                  dee         dGee         dHe8deUf0d_Z	 	 	 	 ddDedeQeeef                  dee         dGee         d`eeU         dHe8deUfdaZddbdDedNedBed^edEeeeef                  de$fdcZɐddZʐddedDedfeUdgee         dhedeeQeeef                           f
diZdDedfeUdeQeeef                  fdjZ̐ddedDedhedeQeeef                  fdkZ͐dddXdldDedNed5eUdme$deeU         f
dnZdDedeeU         fdoZdDedpeUdee         fdqZdDedeQeeef                  deeUeUf         fdrZ	 	 ddDedeQeeef                  dne$dse$ddf
dtZdDede$fduZdDedeUfdvZ	 	 	 ddDedweQeeef                  dxeeeef                  deeU         dyee         deUfdzZdeQe         fd{ZdDedBed]edeUfd|Z	 	 	 	 	 	 ddDed}e$d~e$d4eeU         d5eUde$deeU         deQeeef                  fdZdgeQe         deQeeef                  fdZ	 ddDedeUdeUdeeef         fdZdDedefdZ	 	 	 	 ddDede$d}e$de$de$deQeeef                  fdZݐdZdddDede$de$de$deQeeef                  f
dZdDedeWeQeeef                  eQeeef                  f         fdZdDedeUfdZ	 ddDedeeU         deUfdZ	 ddDedeeU         deUfdZdDedeQeeef                  fdZdDede$fdZdDedefdZdDedeQe         fdZedeQeeef                  deeef         de$fd            ZdDedeUdeeef         fdZdDedeUdeUfdZ	 	 	 	 ddEed4eUd5eUdedeQeeef                  f
dZ	 	 	 	 	 	 	 	 ddEed1eQe         d3ed-eUd.e$d9e$de$d2eQe         deUfdZddeUde$fdZddddd.e$d9e$de$deeeUf         fdZddDedeUfdZdDedYede$fdZdeeef         de$fdZdeeef         de$fdZdDedeQe         fdZdDeddfdZedjee#         dDeddfd            ZdDedeQe         fdZ	 	 ddDedjee#         deeQe                  de$fdZ	 ddDedjee#         de$fdZ	 ddgeQe         djee#         deUfdZdeUfdZ	 ddjee#         deUfdZeddddddddddddddddddddddddddee8         dee8         dee8         dee8         dEee         dee         dee         d3ee         deeU         deeU         dee$         dee         dee         dHee         dJee         dKee         dee         deeU         deeU         dee8         dee8         deeU         deeU         d=e$deWeef         f2dń            Zedeee8         deeef         ddfdǄ            Z	 	 ddeee8         dEedeQeeef                  fdȄZ	 	 ddeee8         dEedeUfdɄZ 	 	 ddeee8         dEedeUfdʄZ	 	 ddeee8         dEedeUfd˄ZdXd̜de8de$deUfdτZ	 	 	 ddeee8         dEedjee#         deUfdфZddXdҜde$de$deeef         fdՄZdedee         fdքZddלdedLed)eej/                 ddfd؄ZdedeUfdڄZdedeQeWeef                  fd܄Z	dd݄Z
dddޜdJedHedee$         dee$         ddf
dZdXddJede$ddfdZdJedHede$fdZdJedLedeeeef                  fdZdJedeQeeef                  fdZdDedeeeef                  fdZdJedLedeUfdZdddJedLedHedIedDededdfdZdDede$fdZdddJedHed4eUdeQeeef                  fdZdZdeeU         fdZdeUfdZ	 	 	 	 	 ddeUdeUde$djee#         deUdeeef         fdZ	 	 	 d de8deUde$deeef         fdZdDedmede$fdZdDedeeeef                  fdZdeQeeef                  fd ZdDede$fdZdDeddfdZdDededdfdZdS (!  r  z
    SQLite-backed session storage with FTS5 search.

    Thread-safe for the common gateway pattern (multiple reader threads,
    single writer via WAL mode). Each method opens its own cursor.
    g      4@r   g      ?r  r  g333333?r  g      ?      ?2   i    rL  i'  iP  i  P i  g      >@rf   r:   c                 \    |d S t          |          }|                     d||f           |S )NzAINSERT OR IGNORE INTO system_prompts (hash, prompt) VALUES (?, ?))rm   r   )r   rf   prompt_hashs      rF   _store_system_promptzSessionDB._store_system_promptn  sC     4)-88O-(	
 	
 	
 rL   Nc                 0    |                      d           d S )Nz|DELETE FROM system_prompts WHERE NOT EXISTS (SELECT 1 FROM sessions WHERE sessions.system_prompt_hash = system_prompts.hash)r   re  s    rF   #_delete_unreferenced_system_promptsz-SessionDB._delete_unreferenced_system_promptsy  s'    	
 	
 	
 	
 	
rL   r   c                 h    t          |           }d|v r|                    d          }d|v r||d<   |S )N_system_prompt_resolvedrf   )r,  pop)r   datar
  s      rF   _session_row_dictzSessionDB._session_row_dict  sD    Cyy$,,xx 9::H$&&(0_%rL   r   c                     | dS 	 |                                   dS # t          $ r  t                              dd           Y dS w xY w)zCClose a partially initialized connection without masking its error.Nz&Could not close a SessionDB connectionTr  )rq  rB   r=  rY  re  s    rF   _close_connection_quietlyz#SessionDB._close_connection_quietly  sd     <F	RJJLLLLL 	R 	R 	RLLADLQQQQQQ	Rs    &AAFr  	read_onlyc                 V
    |pt                       _        t           j                   | _        t	          j                     _        t          j        t                     _
        t	          j        t                     _        d _        t	          j                     _        d _        d _        d _        d _        d _        d _        d _        d _        d _        d _        d _        d _        d _        d  _        t;                       _        t	          j        t	          j                               _         d  _!        d _"        d _#        d  _$        d}	 |r!tK          d j         d j        dddd 	           _        tL          j'         j        _(        	 tS           j        d
            j        *                                } +                    |d          du  _         j        r +                    |d          du  _        nD# tX          $ r7  j        d c} _        	 |-                                 n# t\          $ r Y nw xY w w xY wd}	 |s& j        d c} _         /                    |           d S d S  j        j0        1                    dd           |ste           j        d
           |s j        3                                rti           j                  r	  j        5                                j6        }n# tn          $ r d}Y nw xY wtq           j                  } j        j0        dz  }d| d|pd d| d}	tr          :                    |	           tw          |	           |A j        3                                r(ti           j                  rtM          j<        |	           fd fd}
	  |
             n# tL          j<        $ r}t{          |          rt}           j                  s tr          :                    d|           	  j         j        -                                 n# t\          $ r Y nw xY wt           j                  }|@                    d          s  |
             Y d }~nd }~ww xY wd}n9# t\          $ r,}tw          t          |          jB         d|             d }~ww xY w	 |s& j        d c} _         /                    |           d S d S # |s% j        d c} _         /                    |           w w xY w)N)maxsizer   F        rS  r  Tr"  r  r  check_same_threadr  ro  r   r  rv  rw  r  r   zstate-snapshotszstate.db looks ZEROED (z( bytes, no SQLite header). Preserved at u*   (quarantine failed — file left in place)z. Restore from z via `hermes snapshot list` / `hermes snapshot restore <id>` if available. Opening a fresh empty database so the agent can start.c                     t          t           j                  ddd            _        t          j         j        _        t           j        d          dk     _        t           j        d            j        
                    d           t           j                   _                                          d S )NFr"  )r7  r  ro  r   r  rv  PRAGMA foreign_keys=ON)r  r_   r  _connrR  Rowrow_factoryr  _wal_activer  r   r  _fts_cjk_loaded_init_schemarZ   s   rF   _connect_and_initz-SessionDB.__init__.<locals>._connect_and_initT  s    0%%&+   %)  
 *1
&+DJLLLPUU   'tzJGGGG
""#;<<<'>tz'J'J$!!#####rL   c            
         t          j                    j        z   } 	 	               d S # t          j        $ r}t          |                                          }d|vrd|vr 	 j        j                                         n# t          $ r Y nw xY wt          j                    }|| k    r t          j
        t          t          j        j        j                  t!          | |z
  d                               Y d }~nd }~ww xY w)NTr  r  MbP?)rT  r  _WRITE_PATIENCE_SrR  rS  r_   r   r:  rq  rB   rU  minrandomuniform_WRITE_RETRY_SLOW_MIN_S_WRITE_RETRY_SLOW_MAX_Sr  )r  r\  errr)  rA  rZ   s       rF   $_connect_and_init_with_lock_patiencez@SessionDB.__init__.<locals>._connect_and_init_with_lock_patiencej  sO     >++d.DD))+++"3   !#hhnn..#3..63D3D!!#z5 $
 0 0 2 2 2( ! ! ! D!"n..(??!
 &$($@$($@!" !" !$HsNE : :        	s9   
+ D
*D% BD
BDBA-DD
u`   state.db schema is malformed (%s) — attempting automatic repair (a backup copy is made first).r?  rG  )Cr   r  r  r2  	threadingLock_lockqueue	LifoQueue_READ_POOL_MAX
_read_poolBoundedSemaphore_read_permits_read_permit_exhausted_read_conns_lock_read_conns_closed_read_open_failed_atr=  _write_count_fts_runtime_rebuild_attempted_notadb_reconnect_attempted_fts_usermerge_floor_applied_fts_enabled
_fts_stale_trigram_availabler>  _fts_cjk_available_fts_unavailable_warnedr:  r   _token_queue	Condition_token_queue_cond_token_writer_thread_token_writer_stop_token_writer_busy_token_atexit_hookr  rR  r;  r<  r  r   _fts_table_probeBaseExceptionrq  rB   r1  r   r  rl  rG  r  r  r  r   r  r=  r  r  r  r  r  r  r@   typer\   )rZ   r  r2  initialization_completer   r   zsizeqpathsnapsr  rK  r\  r  rA  s   `            @rF   rY   zSessionDB.__init__  sb   4"2"4"4 	t|,,,"^%%
  BG"B
 B
 B
 '7GG '(# ) 0 0
 #( %(!  /4+ ,1( -2)!"'
  %"'',$
 $)77!*!4Y^5E5E!F!F@D!"'"'@D"'K	5 2 12DL222"&,&+$(  
 *1
&*4:
KKKK!Z..00F--fnEEM % (  11 & 6   $	$ / %   '+z4$D$*

$    +/'l + 5#':t dj..t444445 5i L%%dT%BBB  L(
KKKK 5L''))5 't|445
 L--//7EE   EEE24<@@+.??Me M M$)$Y-YM M$)M M M  S!!!$S))) =T\%8%8%:%:=?QRVR^?_?_=!/444$ $ $ $ $,$ $ $ $ $ $L7446666( 7 7 7 -S11 9Nt|9\9\ <=@  z-
((***    D/==zz*-- 4466666666-7> '+## 	 	 	 !DII$6!?!?#!?!?@@@	 $" + 5#':t dj..t444445 5* 5#':t dj..t44445s   %AR 'A0H R I2II
IIIIR 	A(R 2L R L R L  B+R 
O R R&?R& QR
QRQ7RR RR S? 
S%'SSS? ?)T(c                 L   | j         r| j        rdS | j        5  | j        r	 ddd           dS | j        r2t          j                    | j        z
  t          k     r	 ddd           dS ddd           n# 1 swxY w Y   | j        	                    d          sW| j        5  | xj
        dz  c_
        ddd           n# 1 swxY w Y   t                              dt          | j                   dS d}	 t          d| j         d| j        ddd	d
          }t           j        |_        t'          |d           | j        rt+          |           n# t           j        $ r |                     |           | j        5  t          j                    | _        ddd           n# 1 swxY w Y   t                              d| j        d           | j                                         Y dS t2          $ r0 |                     |           | j                                          w xY w|S )u  Open a fresh read-only connection, or None when unavailable.

        Callers must return the connection to self._read_pool (see
        _read_ctx); this opens, it does not track.

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

        Fresh read transactions begin per statement (autocommit), so each
        query observes everything committed so far — read-your-writes holds
        for the flush-then-search patterns in a turn.
        NF)blockingrq   zVread pool at capacity (%d) for %s; serving this read from the locked writer connectionrS  r  Tr  r6  r   r  z'read-only connection open failed for %sr  )r=  r2  rV  rW  rX  rT  r  _READ_OPEN_RETRY_SECONDSrT  acquirerU  r=  rY  rQ  r  r  rR  r;  r<  r  r>  r  rr  _discard_partial_read_connreleaserj  rZ   r   s     rF   _get_read_connzSessionDB._get_read_conn  se      	4> 	4" 	 	& 	 	 	 	 	 	 	 	 )N$$t'@@*+ + 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 !))5)99 		& 1 1++q0++1 1 1 1 1 1 1 1 1 1 1 1 1 1 1LL+	   4 .	&...."l #( $  D  '{D"4*====
 # .'---} 	 	 	 ++D111 & = =,0N,<,<)= = = = = = = = = = = = = = =LLBDL[_L```&&(((44 	 	 	 ++D111&&(((	 s_   	A3,A33A7:A7 B==CC3AE +H!:FH!F#	#H!&F#	'>H!(9H!c                     |dS 	 |                                  dS # t          $ r,}t                              d| j        |           Y d}~dS d}~ww xY w)zClose a connection that failed between open and hand-off.

        Separate from _close_read_conn because that one releases a permit and
        this runs on paths that release their own.
        Nz2partially-opened read conn close failed for %s: %s)rq  rB   r=  r  r  rZ   r   r\  s      rF   rt  z$SessionDB._discard_partial_read_conn%  s     <F	JJLLLLL 	 	 	NNDdlTW        	s    
A!AAc                    	 |                                  n8# t          $ r+}t                              d| j        |           Y d}~nd}~ww xY w| j                                         dS # | j                                         w xY w)u  Close a pooled read connection and release its descriptor permit.

        This was a bare ``except Exception: pass``, which silently swallowed
        the sqlite3.ProgrammingError raised when close() ran on a thread
        other than the one that opened the connection — the exact signature
        of the fd leak this pool fixes. A close that fails leaks a tracked
        fd, so it must not be invisible.

        The permit is released even when close() raises: the descriptor is
        already lost at that point, and withholding the permit too would turn
        one leaked fd into a permanently narrower read path — failing twice for
        one fault. The warning is the signal that matters.

        Pairs with _get_read_conn(). Calling this on a connection that did not
        come from there over-releases the BoundedSemaphore, which raises
        ValueError rather than silently widening the ceiling.
        z!read-conn close failed for %s: %sN)rq  rB   r=  r  r  rT  ru  ry  s      rF   _close_read_connzSessionDB._close_read_conn4  s    $	)JJLLLL 	S 	S 	SNN>cRRRRRRRR	S &&(((((D&&((((s+    A* 
A!AA* AA* *Bc                     | j         r| j        rdS 	 | j                                        S # t          j        $ r |                                 cY S w xY w)u  Borrow a read connection from the pool, opening one on a miss.

        The single acquisition seam for the read path: the WAL/read_only gate,
        the pool checkout and the open-on-miss all live here, so there is
        exactly one place to exercise (and one place for a caller to bypass by
        accident). Returns None when the read path is unavailable and the
        caller must fall back to the locked writer connection.

        A pool hit costs no permit — the connection it hands back is already
        holding one. Only the miss path can open, and only _get_read_conn() can
        take a permit, so peak live connections is bounded by _READ_POOL_MAX no
        matter how many threads miss simultaneously.
        N)r=  r2  rR  
get_nowaitrO  Emptyrw  r@  s    rF   _checkout_read_connzSessionDB._checkout_read_connM  sk      	4> 	4	)?--///{ 	) 	) 	)&&(((((	)s   + #AAc              #   l  K   |                                  }|	 |V  d}| j        5  | j        s3	 | j                            |           d}n# t
          j        $ r Y nw xY wddd           n# 1 swxY w Y   |s|                     |           nx# d}| j        5  | j        s3	 | j                            |           d}n# t
          j        $ r Y nw xY wddd           n# 1 swxY w Y   |s|                     |           w w xY wdS | j        5  | j	        V  ddd           dS # 1 swxY w Y   dS )u  Yield a connection for read-only statements.

        WAL: a read-only connection borrowed from a bounded pool with NO
        lock — recall queries never convoy behind writer flushes (the
        gateway shares one SessionDB across every agent, so this lock was a
        global choke point). The connection is checked out for the duration
        of the block, so no two threads ever touch it concurrently.
        Non-WAL, read-conn failure, or _READ_POOL_MAX already reached: the
        shared writer connection under self._lock, byte-for-byte the legacy
        behavior.

        That last case is the deliberate degradation. Past the ceiling readers
        convoy on the writer lock instead of opening descriptors — measurably
        slower under a burst, and the alternative is EMFILE, which takes the
        whole process down in a way a restart-on-exit supervisor cannot see.
        NFT)
r  rV  rW  rR  
put_nowaitrO  Fullr{  rN  r:  )rZ   r   returneds      rF   	_read_ctxzSessionDB._read_ctxb  sa     $ ''))0


 * ! !2 !! O66t<<<'+HH$z ! ! ! D!! ! ! ! ! ! ! ! ! ! ! ! ! ! !   
0 ))$///% !* ! !2 !! O66t<<<'+HH$z ! ! ! D!! ! ! ! ! ! ! ! ! ! ! ! ! ! !   
0 ))$////
0 FZ 	 	*	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	s   B A.AA.AA.AA..A25A2
DC"$C C"C	C"C	C"D"C&&D)C&*D
D))D-0D-r\  c                 t    t          |                                           }d|v rd|v rdS d|v rdS d|v rdS dS )Nzno such modulefts5Tno such tokenizer: trigramr  Fr  r\  rJ  s     rF   r  z$SessionDB._is_fts5_unavailable_error  sX    #hhnns""v}}4 (3..4 .444urL   c                 T    t          |                                           }d|v pd|v S )u  True when only an optional tokenizer is missing (FTS5 itself works).

        Covers the built-in trigram tokenizer (needs SQLite >= 3.34) and the
        loadable cjk_unicode61 tokenizer — both mean "this one index can't be
        served here", never "disable FTS".
        r  r  r  r  s     rF   _is_trigram_unavailable_errorz'SessionDB._is_trigram_unavailable_error  s3     #hhnn(C/ 91S8	
rL   r   c                     |                      d                                          }|dS t          |t          j                  s|d         n|d         pd}d|vS )u  True when messages_fts exists in ANY pre-v23 shape.

        v23's messages_fts is external-content over THREE real columns
        (content, tool_name, tool_calls). Every pre-v23 shape lacks the
        tool_name/tool_calls columns — whether the old inline single-column
        form (v11..v22) or the even older external-content single-column form
        (v10-era, pre-#16751). We therefore detect "needs optimize" as "the
        stored CREATE lacks the tool_name column", which is the precise v23
        marker and correctly catches BOTH legacy variants.

        Returns False when messages_fts doesn't exist yet (fresh DB mid-init):
        the post-migration FTS setup block will create it in the v23 shape.
        zLSELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'messages_fts'NFr   r  rp   	tool_name)r   rQ  r   rR  r;  )r   r   r  s      rF   _db_has_legacy_inline_ftsz#SessionDB._db_has_legacy_inline_fts  sj     nn=
 
 (** 	 ;5'W[99Is1vvs5zPb #%%rL   c                     t          | dd          rdS d| _        t                              d| j        t
          j        |           dS )zHLog once that the trigram tokenizer is missing; base FTS5 stays enabled._trigram_unavailable_warnedFNTzSQLite trigram tokenizer unavailable for %s (requires SQLite >= 3.34, this build is %s); CJK/substring search will fall back to LIKE: %s)r  r  r=  r>  r  rR  r  rZ   r\  s     rF   _warn_trigram_unavailablez#SessionDB._warn_trigram_unavailable  sY    46>> 	F+/(> L"	
 	
 	
 	
 	
rL   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]  ra  r=  r  r  r  s     rF   _warn_fts5_unavailablez SessionDB._warn_fts5_unavailable  sQ    !' 	F'+$% L	
 	
 	
 	
 	
rL   c                    t          |                    d                                                    }| j        s|rd |                    dd                    d t
          D                        dt
                                                    D             }|r`t                              dt                                 |                    dt          f           |D ]}|                    d	|            d
| _        dS 	 |                    t                     |s|                    dt          f           |                    d                                          d         }|dk    r^|                    d                                          d         }dt          |          fdfD ]\  }}|                    d||f           |                    dt          f                                          }	|	r	d
| _        dS |                    t                     |                    d                                          }
|
 | _        dS # t           j        $ r' t                              dd           d
| _        Y dS w xY w)u  Create / repair / self-heal the CJK-bigram index surface.

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

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

        *fn* receives the connection and should perform INSERT/UPDATE/DELETE
        statements.  The caller must NOT call ``commit()`` — that's handled
        here after *fn* returns.

        BEGIN IMMEDIATE acquires the WAL write lock at transaction start
        (not at commit time), so lock contention surfaces immediately.
        On ``database is locked``, we release the Python lock, sleep a
        random jitter, and retry — breaking the convoy pattern that
        SQLite's built-in deterministic backoff creates.

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

        Returns whatever *fn* returns.
        Nr\  r:   c                 H    dt          |                                           v S )Nzno more rows availabler  r  s    rF   _is_no_more_rowsz2SessionDB._execute_write.<locals>._is_no_more_rows  s    +s3xx~~/?/???rL   Tr{  rq   r   r  r  zQdatabase is locked (another Hermes process held the state.db write lock for over .0fu   s — likely a long maintenance operation such as VACUUM, a large WAL checkpoint, or an older pre-update process; the database itself is healthy))rD  rT  r  rR  rr  r   rN  r:  r   r  rj  rollbackrB   rY  _CHECKPOINT_EVERY_N_WRITES_try_wal_checkpoint_FTS_MERGE_EVERY_N_WRITES_try_incremental_merge_ftsr  rE  _COMPRESSION_BUSY_WAIT_S_sleep_before_write_retryrS  r_   r   r  r  _reconnect_after_notadb_try_runtime_fts_rebuild_enter_fts_fail_open)	rZ   r  r  r  compression_deadliner  r  r\  err_msgs	            rF   _execute_writezSessionDB._execute_write  s7   : /J>##j0 15	@'- 	@D 	@ 	@ 	@ 	@`	_Z 
 
J&&'8999!#DJ
))++++(   ! J//1111( ! ! ! D! ,	
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 !!Q&!!$t'FF!KK,,...$t'EEJJ335554    (/+.((4+HH(, ,( 11($*G   H+   c((..**w&&&G*;*;55h
KK !  "2C8BNC C C   $#C(( T-K-KHV`-a-a HHHH(   ##C(( T-K-KHV`-a-a HHHH ,C00 7799 HHHH 0055 HHHH,,S11 HHHH=    $#C(( T-K-KHV`-a-a HHHHs   D: C)BC
B?B-,B?-
B:7B?9B::B??CD: CD: CA"D: :AK<K<?H#!;H#"H##K<5!J=$J=J=!J=<J==K<!K76K77K<r  c           	      F   t          j                    }||k    rdS |||z
  z
  }|| j        k    r t          j        | j        | j                  }nt          j        | j        | j                  }t          j	        t          |t          ||z
  d                               dS )a  Sleep one jitter interval if the patience budget still allows it.

        Returns True when the caller should retry, False when *deadline* has
        passed and the error should propagate. Jitter stays small for the
        first ``_WRITE_RETRY_SLOW_AFTER_S`` (fast reclaim on millisecond
        contention) and backs off after that, and never overshoots the
        deadline by a full slow-jitter.
        FrC  T)rT  r  _WRITE_RETRY_SLOW_AFTER_SrF  rG  rH  rI  _WRITE_RETRY_MIN_S_WRITE_RETRY_MAX_SrU  rE  r  )rZ   r  r  r)  elapsedjitters         rF   r  z#SessionDB._sleep_before_write_retry  s     n(??5J./d444^,, FF
 ^'' F 	
3vs8c>599::;;;trL   c                    | j         rdS d| _         t                              d           	 | j        5  | j        2	 | j                                         n# t          $ r Y nw xY wd| _        t          t          | j	                  | j	        ddd          }t          j        |_        || _        t          |d          d	k    | _        t          |d           |                    d
           t#          |          | _        |                                  ddd           n# 1 swxY w Y   n3# t          $ r&}t                              d|           Y d}~dS d}~ww xY wt                              d           dS )u$  Close the corrupted write connection and reopen state.db.

        Returns True when the connection was successfully replaced and the
        failed write should be retried.  Mirrors the constructor's
        ``_connect_and_init`` so WAL/schema reconciliation runs on the fresh
        connection.  Never raises — logs and returns False on failure so the
        original error propagates.

        One-shot per instance: a genuinely unrecoverable database must not
        put writers into a reconnect loop that pins CPU on every write.
        FTuw   state.db connection reported 'file is not a database' — closing and reopening the connection to self-heal (one-shot).Nr"  )r  r7  r  ro  r   r  rv  r9  zrstate.db reconnect after 'file is not a database' failed (%s); the database may need the full offline repair path.zEstate.db connection reopened successfully; retrying the failed write.)r[  r=  r  rN  r:  rq  rB   r  r_   r  rR  r;  r<  r  r=  r  r   r  r>  r?  r  )rZ   new_connr\  s      rF   r  z!SessionDB._reconnect_after_notadb+  s    + 	5+/(D	
 	
 	
!	 $ $:)
((****$   !%DJ.%%"&,&+$(   (/{$ &
+HzJJJ   'x*EEEE  !9:::'>x'H'H$!!###3$ $ $ $ $ $ $ $ $ $ $ $ $ $ $4  	 	 	LLF  
 55555	 	S	
 	
 	
 ts^   D0 D$AD$
A# D$"A##B5D$D0 $D((D0 +D(,D0 0
E :EE c                 v    t          |           rdS t          |                                           }d|v od|v S )a  True for the error class a corrupt FTS index raises on writes.

        The message varies by SQLite version: older builds raise the generic
        ``database disk image is malformed`` (covered by
        ``is_malformed_db_error``); newer builds (e.g. ubuntu-latest CI)
        raise the FTS5-specific ``fts5: corrupt structure record for table
        "messages_fts"``. Both mean the same thing for the write path: the
        canonical rows are fine, the FTS shadow tables are not.
        Tr  r  )r  r_   r   )r\  r  s     rF   _is_fts_write_corruption_errorz(SessionDB._is_fts_write_corruption_errore  sB     !%% 	4#hhnn}1c!11rL   c                    | j         rdS | j        sdS |                     |          sdS d| _         t                              d|           	 |                                 }n3# t          $ r&}t                              d|           Y d}~dS d}~ww xY w|st                              d           dS t                              d|           dS )ub  One-shot in-place FTS rebuild after a corrupt-index write failure.

        Returns True when a rebuild was performed and the failed write should
        be retried; False when the error isn't the FTS-corruption class, FTS
        is disabled, or a rebuild was already attempted for this instance.

        Delegates to :meth:`rebuild_fts` (the FTS5 ``'rebuild'`` command —
        index rewritten from the canonical messages table, zero message-row
        mutation). Safe to call from ``_execute_write``'s except path: the
        failed transaction was rolled back and ``self._lock`` released before
        the exception propagated, and ``rebuild_fts`` re-acquires it.
        E2E-verified: a corrupted ``messages_fts_data`` shadow table rejects
        every append; after the in-place rebuild the same append succeeds and
        search works again.
        FTu   state.db write failed with an FTS-corruption error (%s) — attempting one-shot in-place FTS rebuild; canonical message rows are preserved.zkIn-place FTS rebuild failed (%s); the database needs the full offline repair path (repair_state_db_schema).NzpIn-place FTS rebuild made no progress; the database needs the full offline repair path (repair_state_db_schema).zFstate.db FTS indexes rebuilt in place (%d); retrying the failed write.)rZ  r]  r  r=  r  r  rB   r  )rZ   r\  rebuiltrebuild_excs       rF   r  z"SessionDB._try_runtime_fts_rebuildu  s     . 	5  	522377 	5.2+"#&	
 	
 	

	&&((GG 	 	 	LLE  
 55555	  	LLI   5T	
 	
 	
 ts   A" "
B,BBc                    | j         r|                     |          sdS 	 | j        5  | j                            d           	 | j                            dt
          f           | j                            dd                    d t          D                        dt                                                    }|r!| j                            dt          f           | 
                    | j                                                   | j                                         n(# t          $ r | j                                          w xY w	 ddd           n# 1 swxY w Y   n8# t          j        $ r&}t"                              d	|           Y d}~dS d}~ww xY wd
| _        d| _         d| _        d| _        t"                              d|           d
S )an  Detach corrupt FTS indexes so canonical writes can continue.

        The stale breadcrumb and trigger removal commit atomically. Its
        ordering is load-bearing: after triggers are absent, new canonical
        rows create an index gap of unknown extent, so another process must
        never reinstall the triggers without first rebuilding every row.
        Fr{  ziINSERT INTO state_meta (key, value) VALUES (?, '1') ON CONFLICT(key) DO UPDATE SET value = excluded.valuez@SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name IN (r   c              3      K   | ]}d V  dS r  rK   r  s     rF   r+  z1SessionDB._enter_fts_fail_open.<locals>.<genexpr>  r  rL   z	) LIMIT 1NzNCould not detach corrupt FTS indexes; canonical write still cannot proceed: %sTzstate.db FTS indexes remain corrupt (%s); disabled FTS sync and retrying the canonical write. Search temporarily uses LIKE until a later SessionDB open rebuilds the indexes.)r]  r  rN  r:  r   r(   r   r   rQ  r&   _drop_all_fts_triggersr   r  rj  r  rR  rr  r=  r  r^  r_  r`  )rZ   r\  cjk_triggers_present
detach_excs       rF   r  zSessionDB._enter_fts_fail_open  sg      	(K(KC(P(P 	5 	  
""#4555J&&P&(  
 ,0:+=+="(+0P0P>O0P0P0P(P(P" " " *	, ,
 hjj ) , 
**T.0  
 //
0A0A0C0CDDDJ%%''''$   J''))) (+              2 } 	 	 	LL%  
 55555	 !"'"'; 		
 	
 	
 tsS   E& EC!D%$E%%E

EE& EE& !E"E& &F5FFc                 r   	 | j         5  | j                            d                                          }|r4|d         dk    r(t                              d|d         |d                    ddd           dS # 1 swxY w Y   dS # t          $ r&}t                              d|           Y d}~dS d}~ww xY w)uq  Best-effort PASSIVE WAL checkpoint.  Never raises.

        Flushes committed WAL frames back into the main DB file without
        requiring an exclusive lock.  PASSIVE is safe for frequent
        periodic use because it does not block concurrent writers and
        cannot corrupt B-tree pages under I/O pressure.

        PASSIVE does not truncate the WAL file — it stays at its
        high-water mark. Explicit checkpoints on the shared ``state.db`` no
        longer truncate the WAL; it is bounded by ``journal_size_limit`` and
        the writer's natural post-checkpoint reset rather than by a TRUNCATE
        at every close or maintenance command.

        Previous TRUNCATE strategy caused B-tree corruption on large
        databases (65K+ pages) due to the exclusive-lock I/O pressure
        from checkpointing thousands of frames at once (issue #45383).
        PRAGMA wal_checkpoint(PASSIVE)rq   r   z(WAL checkpoint: %d/%d pages checkpointedr  Nz#WAL checkpoint (PASSIVE) failed: %s)rN  r:  r   rQ  r=  rY  rB   r  )rZ   r  r\  s      rF   r  zSessionDB._try_wal_checkpoint  s"   $	G  ++4 (**   fQi!mmLLBq	6!9                     	G 	G 	GNN@#FFFFFFFFF	Gs;   B A#A9,B 9A==B  A=B 
B6B11B6c                     | S )u  Enter a scope that closes this handle on the way out.

        Ownership of a SessionDB should be released explicitly.
        Historically an instance with a started token writer pinned ITSELF
        (bound-method writer target plus a strong ``atexit`` drain hook), so
        ``__del__`` never ran for exactly the instances that leaked
        descriptors (#88033).  The writer now retires after an idle window
        and the atexit hook holds only a weak reference, so abandoned
        handles are eventually collectible — but "eventually, after the
        idle window and a GC cycle" is not a release policy.  Call sites
        owning a handle are still expected to close it deterministically
        (see the ownership comments in ``run_agent.py`` and
        ``tui_gateway/methods_session.py``).

        This makes the correct usage the easy one, so an owning scope can be
        exception-safe by construction rather than by remembering a
        ``try/finally``:

            with SessionDB(path) as db:
                db.append_message(...)

        Purely additive: it changes nothing for callers that already call
        ``close()`` directly, and ``close()`` stays idempotent, so a scope
        that closes early still exits cleanly.
        rK   r@  s    rF   	__enter__zSessionDB.__enter__  s	    4 rL   c                 .    |                                   dS )zClose the handle, then let any exception propagate.

        Returns False (never suppressing), so ``with`` here only manages the
        descriptor lifetime and never swallows a caller's error.
        F)rq  )rZ   exc_typer\  tbs       rF   __exit__zSessionDB.__exit__  s     	

urL   c                    |                                   | j        dc}| _        |t          j        |           | j        5  d| _        ddd           n# 1 swxY w Y   	 	 | j                                        }n# t          j	        $ r Y nw xY w| 
                    |           F| j        5  | j        ry| j        sN	 | j                            d           n2# t          $ r%}t                               d|           Y d}~nd}~ww xY w| j        dc}| _        |                     |           ddd           dS # 1 swxY w Y   dS )u  Close the database connection.

        Drains queued token deltas first (the background writer needs the
        connection). Writable connections then attempt a PASSIVE WAL
        checkpoint (NOT TRUNCATE: transient per-cron-run connections close
        many times an hour, and a TRUNCATE fires a full WAL reset that
        races the gateway's live writer and tears B-tree pages — issue
        #45383). Read-only connections never request a checkpoint.
        NTr  z,WAL checkpoint (PASSIVE) at close failed: %s)_stop_token_writerrh  atexit
unregisterrV  rW  rR  r}  rO  r~  r{  rN  r:  r2  r   rB   r=  rY  r1  )rZ   hookr   r\  s       rF   rq  zSessionDB.close#  s    	!!!(,(?%d%d###
 " 	+ 	+&*D#	+ 	+ 	+ 	+ 	+ 	+ 	+ 	+ 	+ 	+ 	+ 	+ 	+ 	+ 	+	(1133;   !!$'''	( Z 	5 	5z 5~ 
**+KLLLL$   J       
 $(:t dj..t444%	5 	5 	5 	5 	5 	5 	5 	5 	5 	5 	5 	5 	5 	5 	5 	5 	5 	5s`   AAA"A< <BB.D<>CD<
D#D>D<D'D<<E E c                     | j                             d          dS 	 |                                  dS # t          $ r Y dS w xY w)u  Safety net: close the connection if the caller forgot.

        The async accounting worker retires when idle and its atexit hook
        holds only a weak reference, so neither can pin an otherwise orphaned
        instance. During interpreter teardown the order of module cleanup is
        undefined, so every attribute access remains guarded.

        Delegates to ``close()`` so the read pool, token writer, and atexit
        hook are all cleaned up — not just the writer connection.
        r:  N)__dict__r@   rq  rB   r@  s    rF   __del__zSessionDB.__del__Q  sW     =W%%-F	JJLLLLL 	 	 	DD	s   4 
AAg      @g?fts_v22_trash_c           	          t          |                    d| j                            dd          dz   f                                                    S )zTrue when demoted v22 shadow tables are still awaiting teardown.
        Caller must hold ``self._lock`` (or pass a migration-time cursor).zSSELECT 1 FROM sqlite_master WHERE type = 'table' AND name LIKE ? ESCAPE '\' LIMIT 1r[  \_%)r   r   _FTS_TRASH_PREFIXr   rQ  rv  s     rF   _has_fts_trashzSessionDB._has_fts_trash  sT     DLL2#++C77#=?
 
 (**	  	rL   rd   sourcemodelr   user_idsession_keychat_id	chat_type	thread_idparent_session_idr   profile_namer   origin_jsondisplay_namec                     	
 	 
fd}                      | j                   dS )u  Insert a session row, enriching NULL metadata on conflict.

        The gateway's ``get_or_create_session`` creates a bare row (source +
        user_id) *before* the agent exists; the agent's later
        ``create_session`` then carries the real ``model`` / ``model_config`` /
        ``system_prompt``. A plain ``INSERT OR IGNORE`` silently dropped that
        enrichment, leaving gateway sessions with NULL model/billing metadata.
        The ``ON CONFLICT`` upsert backfills those fields via ``COALESCE`` —
        only filling columns that are still NULL, never overwriting values an
        earlier writer already set (so a later bare call with source="unknown"
        can't clobber a real source/model).

        ``chat_id``/``thread_id`` record the messaging origin (the chat/room and
        thread the session was started in) so that gateway ``/resume`` can prove
        a persisted, now-inactive row belongs to the caller's chat/thread before
        switching to it (IDOR scoping — without them the ``sessions`` table has
        no chat/thread to compare).

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

        ``display_name`` / ``origin_json`` carry the gateway's presentation
        and full origin metadata (#9006) so consumers (mcp_serve, mirror,
        channel directory) can read routing data from state.db instead of
        sessions.json.  They are COALESCE'd only in the sense that ``None``
        leaves the existing value untouched.

        ``include_compression_ancestors`` keeps a logical compression lineage
        on one routing peer when an explicit gateway resume moves its tip to a
        different lane. Normal per-turn metadata refreshes update only the
        supplied row.

        Self-healing (#82616): when the target row does not exist yet — the
        gateway's ``create_session`` write failed and was deferred, or a
        crash landed between routing publication and row creation — this
        recorder INSERTs the row with the full identity instead of silently
        no-opping. Every per-turn peer refresh is therefore a repair
        opportunity: a gateway session row can no longer be first-created by
        an identity-less lazy writer (``update_token_counts`` /
        ``record_auxiliary_usage``) and stay unroutable forever.
        Nc                   
 d}d}g }rd}d}|                     
           |                    	f           s|                     
           |                     | d| |           s^|                     d
f          }|                                5|                     d
	t	          j                    f
           d S d S d S )Nrp   zWHERE id = ?a  
                    WITH RECURSIVE compression_lineage(id) AS (
                        SELECT ?
                        UNION
                        SELECT parent.id
                        FROM compression_lineage lineage
                        JOIN sessions child ON child.id = lineage.id
                        JOIN sessions parent ON parent.id = child.parent_session_id
                        WHERE parent.end_reason = 'compression'
                          AND json_extract(
                              COALESCE(child.model_config, '{}'),
                              '$._branched_from'
                          ) IS NULL
                          AND json_extract(
                              COALESCE(child.model_config, '{}'),
                              '$._delegate_from'
                          ) IS NULL
                          AND COALESCE(child.source, '') != 'tool'
                    )
                z0WHERE id IN (SELECT id FROM compression_lineage)a9  
                   UPDATE sessions
                   SET session_key = ?, source = ?, user_id = ?, chat_id = ?,
                       chat_type = ?, thread_id = ?,
                       display_name = COALESCE(?, display_name),
                       origin_json = COALESCE(?, origin_json)
                   +SELECT 1 FROM sessions WHERE id = ? LIMIT 1a  INSERT INTO sessions (
                               id, source, user_id, session_key, chat_id,
                               chat_type, thread_id, display_name, origin_json,
                               started_at
                           )
                           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                           ON CONFLICT(id) DO UPDATE SET
                               session_key = COALESCE(sessions.session_key, excluded.session_key),
                               chat_id = COALESCE(sessions.chat_id, excluded.chat_id),
                               chat_type = COALESCE(sessions.chat_type, excluded.chat_type),
                               thread_id = COALESCE(sessions.thread_id, excluded.thread_id),
                               display_name = COALESCE(sessions.display_name, excluded.display_name),
                               origin_json = COALESCE(sessions.origin_json, excluded.origin_json))r  extendr   rQ  rT  )r   lineage_ctetarget_clausequery_paramscurr  r  r  r  r  rd   r  r  r  r  s        rF   r  z2SessionDB.record_gateway_session_peer.<locals>._do  s^   K*ML, 0( !S##J/// 	   1 0##J///LL  & & "& & 	 	 	 1 llAJ=  <<>>)LLe '"#'#%%(' IKK      *)rL   r  )rZ   rd   r  r  r  r  r  r  r  r  r  r  s    `````````` rF   record_gateway_session_peerz%SessionDB.record_gateway_session_peero  s    H  	 	FW	 W	 W	 W	 W	 W	 W	 W	 W	 W	 W	 W	 W	 W	r 	C     rL   T	finalizedc                 H    sdS fd}|                      |           dS )zMark a gateway session's expiry-finalization flag in state.db.

        Mirrors ``SessionEntry.expiry_finalized`` (sessions.json) so the flag
        survives even if the JSON index is pruned or lost (#9006).
        Nc                 @    |                      drdndf           d S )Nz5UPDATE sessions SET expiry_finalized = ? WHERE id = ?rq   r   r)  )r   r  rd   s    rF   r  z+SessionDB.set_expiry_finalized.<locals>._do  s7    LLG&Q
3    rL   r
  )rZ   rd   r  r  s    `` rF   set_expiry_finalizedzSessionDB.set_expiry_finalized  sM      	F	 	 	 	 	 	 	C     rL   rp   )rV   
entry_jsonrV   c                P    rsdS fd}|                      |           dS )u  Upsert one gateway routing entry (session_key -> SessionEntry JSON).

        The gateway_routing table is the durable replacement for
        sessions.json: one row per routing key, holding the full serialized
        ``SessionEntry`` so the gateway can rehydrate exactly what it wrote.

        ``scope`` namespaces the index the way separate sessions.json files
        did (one per sessions_dir) — callers pass their sessions_dir path so
        two stores with different directories never share routing state.
        Nc                 ^    |                      dt          j                    f           d S )Na!  INSERT INTO gateway_routing (scope, session_key, entry_json, updated_at)
                   VALUES (?, ?, ?, ?)
                   ON CONFLICT(scope, session_key) DO UPDATE SET
                       entry_json = excluded.entry_json,
                       updated_at = excluded.updated_atr   rT  )r   r  rV   r  s    rF   r  z1SessionDB.save_gateway_routing_entry.<locals>._do  s<    LL;
 Z=    rL   r
  )rZ   r  r  rV   r  s    ``` rF   save_gateway_routing_entryz$SessionDB.save_gateway_routing_entry  sZ      	* 	F	 	 	 	 	 	 	 	C     rL   entriesc                j    t          j                     fd}|                     |           dS )a7  Atomically replace the routing index for *scope* with *entries*.

        Mirrors the sessions.json full-rewrite semantics: keys absent from
        *entries* are removed (pruned/reset sessions disappear from the
        index).  Runs as a single write transaction.  Other scopes are
        untouched.
        c                     |                      df           r7|                     dfd                                D                        d S d S )Nz+DELETE FROM gateway_routing WHERE scope = ?z\INSERT INTO gateway_routing (scope, session_key, entry_json, updated_at) VALUES (?, ?, ?, ?)c                 *    g | ]\  }}||	||fS rK   rK   )r   r  r  r)  rV   s      rF   r   zJSessionDB.replace_gateway_routing_entries.<locals>._do.<locals>.<listcomp>2  s0    OOODAqqOQOeQ3'OOOrL   )r   executemanyitems)r   r  r)  rV   s    rF   r  z6SessionDB.replace_gateway_routing_entries.<locals>._do,  su    LLFQQQ   *OOOOOGMMOOOOO     rL   NrT  r  )rZ   r  rV   r  r)  s    `` @rF   replace_gateway_routing_entriesz)SessionDB.replace_gateway_routing_entries   sQ     ikk	 	 	 	 	 	 	 	C     rL   c                    | j         5  | j                            d|f                                          }ddd           n# 1 swxY w Y   d |D             S )z>Load routing entries for *scope* as {session_key: entry_json}.zCSELECT session_key, entry_json FROM gateway_routing WHERE scope = ?Nc                 ,    i | ]}|d          |d         S )r  r  rK   rr  s     rF   r  z:SessionDB.load_gateway_routing_entries.<locals>.<dictcomp>>  s#    @@@a- !L/@@@rL   )rN  r:  r   r   )rZ   rV   r  s      rF   load_gateway_routing_entriesz&SessionDB.load_gateway_routing_entries7  s    Z 	 	:%%U  hjj 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	
 A@4@@@@   /AA
Asession_keysc                H    sdS fd}|                      |           dS )z=Remove routing entries for the given session keys in *scope*.Nc                 L    |                      dfdD                        d S )N?DELETE FROM gateway_routing WHERE scope = ? AND session_key = ?c                     g | ]}|fS rK   rK   )r   r  rV   s     rF   r   zISessionDB.delete_gateway_routing_entries.<locals>._do.<locals>.<listcomp>J  s    222%222rL   r  )r   rV   r!  s    rF   r  z5SessionDB.delete_gateway_routing_entries.<locals>._doG  s?    Q2222\222    rL   r
  )rZ   r!  rV   r  s    `` rF   delete_gateway_routing_entriesz(SessionDB.delete_gateway_routing_entries@  sM      	F	 	 	 	 	 	 	C     rL   older_than_daysc                    t          j                     t          |          dz  z
  }| j        5  | j                            d|f                                          }ddd           n# 1 swxY w Y   d |D             S )u<  Keyed gateway rows that were opened and then never used at all.

        Selects rows that are keyed (``session_key IS NOT NULL``), still open
        (``ended_at IS NULL``) and carry no evidence of a single turn: no
        messages, no tokens, no tool or API calls, no recorded activity, no
        title.  Such a row is indistinguishable from "never happened".

        That is exactly the shape of a leaked test fixture (#82770) — and
        also of a chat that was routed but never answered.  Both are safe to
        drop: there is no transcript to lose, and the gateway mints a fresh
        session on the next inbound message either way.

        ``bulk prune``/``archive`` cannot reach these rows: their shared
        selector is pinned to ``ended_at IS NOT NULL`` so that a live session
        is never picked, which permanently excludes every never-closed row.
        Hence a separate, narrower selector rather than another filter flag.

        ``pinned`` and ``archived`` rows are excluded — both are explicit
        user intent to keep the row around.
             @a  
                SELECT s.id, s.session_key, s.source, s.chat_id,
                       s.chat_type, s.user_id, s.started_at
                  FROM sessions s
                 WHERE s.session_key IS NOT NULL
                   AND s.ended_at IS NULL
                   AND s.title IS NULL
                   AND s.last_activity_at IS NULL
                   AND COALESCE(s.message_count, 0) = 0
                   AND COALESCE(s.tool_call_count, 0) = 0
                   AND COALESCE(s.api_call_count, 0) = 0
                   AND COALESCE(s.input_tokens, 0) = 0
                   AND COALESCE(s.output_tokens, 0) = 0
                   AND COALESCE(s.pinned, 0) = 0
                   AND COALESCE(s.archived, 0) = 0
                   AND s.started_at IS NOT NULL
                   AND s.started_at < ?
                   AND NOT EXISTS (
                           SELECT 1 FROM messages m WHERE m.session_id = s.id
                       )
                 ORDER BY s.started_at
                Nc                 ,    g | ]}t          |          S rK   r,  rr  s     rF   r   z>SessionDB.list_never_active_keyed_sessions.<locals>.<listcomp>  s    &&&AQ&&&rL   )rT  floatrN  r:  r   r   )rZ   r(  cutoffr  s       rF    list_never_active_keyed_sessionsz*SessionDB.list_never_active_keyed_sessionsO  s    . o 6 6 @AZ 	 	:%%, 	/ 0 hjj1 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	4 '&&&&&s   /A))A-0A-session_idsc                    |sdS | j         5  | j                            d                                          }ddd           n# 1 swxY w Y   g |D ]}	 t	          j        |d         pd          }n# t          $ r Y ,w xY wt          |t                    r:|	                    d          |v r#
                    |d         |d         f           sdS fd	}|                     |           t                    S )
u5  Drop ``gateway_routing`` rows pointing at any of *session_ids*.

        Routing entries are keyed by ``(scope, session_key)`` and record their
        target session inside ``entry_json``, so there is no way to reach them
        by session id in SQL — the match is done in Python over all scopes.
        r   z:SELECT scope, session_key, entry_json FROM gateway_routingNr  z{}rd   rV   r  c                 4    |                      d           d S )Nr$  r&  )r   doomeds    rF   r  z;SessionDB._delete_routing_entries_for_sessions.<locals>._do  s+    Q    rL   )rN  r:  r   r   r  r  rB   r   r,  r@   r  r  r   )rZ   r0  r  r   entryr  r3  s         @rF   $_delete_routing_entries_for_sessionsz.SessionDB._delete_routing_entries_for_sessions  s     	1Z 	 	:%%L hjj 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 )+ 	B 	BC
3|#4#<==   %&& B599\+B+Bk+Q+Qs7|S-?@AAA 	1	 	 	 	 	 	C   6{{s#   -AA
A
A55
BBsessions_dirr7  c                    |                      |          }|sdS d |D             }|                     |          }d}|D ]}|                     ||          r|dz  }||fS )u}  Delete never-active keyed rows and the routing entries naming them.

        Returns ``(sessions_deleted, routing_entries_deleted)``.

        The routing entries go first: a stale entry that outlived its target
        would leave the gateway resuming a session id that no longer exists.
        Deleting the pair is what leaving them both would have amounted to
        anyway — the target had no transcript to resume.

        Deletion goes through :meth:`delete_session` rather than a bulk
        ``DELETE`` so the delegate cascade, FTS bookkeeping and on-disk
        transcript cleanup stay owned by one implementation.
        )r(  )r   r   c                 8    h | ]}t          |d                    S r   )r_   r  s     rF   r   z>SessionDB.prune_never_active_keyed_sessions.<locals>.<setcomp>  s"    444#s3t9~~444rL   r   r6  rq   )r/  r5  delete_session)rZ   r(  r7  
candidatesr   routing_deleteddeletedrd   s           rF   !prune_never_active_keyed_sessionsz+SessionDB.prune_never_active_keyed_sessions  s    & ::+ ; 
 

  	644444CCCHH 	 	J"":L"II 1))rL   )r   active_onlyr   r?  c                Z                                       dt          d           d}g }|r|dz  }|                    |           |r|dz  }|dz  } j        5   j                            ||                                          }ddd           n# 1 swxY w Y    fd|D             S )	uF  List gateway sessions (rows with a session_key) from state.db.

        Returns the newest row per session_key — the same shape consumers got
        from sessions.json: one live mapping per routing key.  ``platform``
        filters on ``source``; ``active_only`` restricts to sessions that
        have not ended.
        z
            SELECT sessions.*,
                   COALESCE(sp.prompt, sessions.system_prompt)
                       AS _system_prompt_resolved,
                   r>   ag   AS last_active
            FROM sessions
            LEFT JOIN system_prompts sp
              ON sp.hash = sessions.system_prompt_hash
            WHERE session_key IS NOT NULL
              AND started_at = (
                  SELECT MAX(s2.started_at) FROM sessions s2
                  WHERE s2.session_key = sessions.session_key
              )
        z AND LOWER(source) = LOWER(?)z AND ended_at IS NULLz ORDER BY last_active DESCNc                 :    g | ]}                     |          S rK   r/  r   rs  rZ   s     rF   r   z3SessionDB.list_gateway_sessions.<locals>.<listcomp>  s'    888a&&q))888rL   )flush_token_countsr"   r  rN  r:  r   r   )rZ   r   r?  queryparamsr  s   `     rF   list_gateway_sessionszSessionDB.list_gateway_sessions  s/    	!!! -Z88	    	$44EMM(### 	-,,E--Z 	@ 	@:%%eV44==??D	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@888848888s   .BBB)r  r  c                n   |r|dv rdS d}|t          |          g}|'|dz  }|                    t          |                     |dz  }| j        5  d | j                            ||                                          D             }ddd           n# 1 swxY w Y   |sdS rAfd|D             }|rt          |d         d	                   S t          |          d
k    rdS n4t          |          d
k    r!d |D             }	t          |	          d
k    rdS t          |d         d	                   S )a  Find the most recent live session_id for a platform + chat origin.

        Equivalent of gateway/mirror's sessions.json scan: matches on
        source + chat_id (+ thread_id when provided).  When ``user_id`` is
        provided, exact sender matches are preferred; if multiple distinct
        users share the chat and none matches, returns None rather than
        contaminating another participant's session.
        )Nrp   Nz
            SELECT id, user_id, started_at FROM sessions
            WHERE LOWER(source) = LOWER(?)
              AND session_key IS NOT NULL
              AND chat_id = ?
              AND ended_at IS NULL
        z  AND COALESCE(thread_id, '') = ?z ORDER BY started_at DESCc                 ,    g | ]}t          |          S rK   r,  rr  s     rF   r   z4SessionDB.find_session_by_origin.<locals>.<listcomp>  s    RRRDGGRRRrL   c                 ~    g | ]9}t          |                    d           pd          t                    k    7|:S r  rp   )r_   r@   )r   rs  r  s     rF   r   z4SessionDB.find_session_by_origin.<locals>.<listcomp>  sB    TTT1AEE),<,<,B(C(Cs7||(S(SQ(S(S(SrL   r   r   rq   c                     h | ]n}t          |                    d           pd                                          8t          |                    d           pd                                          oS rK  )r_   r@   r   rr  s     rF   r   z3SessionDB.find_session_by_origin.<locals>.<setcomp>  sx       quuY''-2..4466AEE)$$*++1133  rL   )r_   r  rN  r:  r   r   r   )
rZ   r   r  r  r  rE  rF  r  exactdistinct_userss
       `     rF   find_session_by_originz SessionDB.find_session_by_origin  s      	7j004 !#g,,/ 77EMM#i..))),,Z 	S 	SRRTZ%7%7v%F%F%O%O%Q%QRRRD	S 	S 	S 	S 	S 	S 	S 	S 	S 	S 	S 	S 	S 	S 	S 	4 	TTTTTTTE +58D>***4yy1}}t YY]]   N
 >""Q&&t474=!!!s   8BBB)r  r  r  r  r  c          	         |sdS | j         5  | j                            dt           d||f                                          }|!|                     |          cddd           S ||	 ddd           dS | j                            dt           d|||||f                                          }ddd           n# 1 swxY w Y   |r|                     |          ndS )uV  Find the latest recoverable gateway session for a routing peer.

        ``sessions.json`` is the fast routing index, but it can be missing or
        pruned after process-level restart bugs.  New gateway sessions persist
        the deterministic ``session_key`` on the durable session row so the
        mapping can be rebuilt exactly.  Rows ended only by older gateway
        cleanup's ``agent_close`` bug or a mistaken TUI ``ws_orphan_reap``
        (dashboard viewer disconnect before #60609) are treated as recoverable;
        explicit conversation boundaries such as /new, /resume switches, and
        compression splits are not.

        Ordering and emptiness (#82616): candidates are ranked by actual
        conversation recency (``last_activity_at``, falling back to
        ``started_at``) — ``started_at`` alone resurrected days-old zombie
        rows over the live conversation. Rows with messages are preferred,
        but an empty keyed row is still returned rather than ``None``:
        returning ``None`` mints a brand-new session id, which is a worse
        outcome than resuming an empty-but-correctly-keyed row (and "empty"
        may just mean the transcript lives under a compression child).

        Reset boundaries fence recovery (#68539): an intentional boundary
        such as ``session_reset`` (or any explicit non-recoverable
        end_reason) must block fallback to an *older* row for the same
        peer. Without the fence, the has-messages ranking above could reach
        behind a /new reset and silently restore the exact context the user
        reset. Each candidate is therefore rejected when a boundary row for
        the peer ended *after* the candidate's last activity — if the
        conversation's most recent event is an intentional reset, recovery
        returns nothing rather than reaching behind it.
        Na  
                SELECT s.*,
                       COALESCE(sp.prompt, s.system_prompt)
                           AS _system_prompt_resolved,
                       (COALESCE(s.message_count, 0) > 0 OR EXISTS (
                           SELECT 1 FROM messages WHERE messages.session_id = s.id LIMIT 1
                       )) AS _has_messages
                FROM sessions s
                LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash
                WHERE s.session_key = ?
                  AND s.source = ?
                  AND (s.ended_at IS NULL OR s.end_reason IN ('agent_close', 'ws_orphan_reap'))
                  AND NOT EXISTS (
                      SELECT 1 FROM sessions b
                      WHERE b.session_key = s.session_key
                        AND b.source = s.source
                        AND b.ended_at IS NOT NULL
                        AND b.end_reason IN (a$  )
                        AND b.ended_at
                            > COALESCE(s.last_activity_at, s.started_at)
                  )
                ORDER BY _has_messages DESC,
                         COALESCE(s.last_activity_at, s.started_at) DESC
                LIMIT 1
                a!  
                SELECT s.*,
                       COALESCE(sp.prompt, s.system_prompt)
                           AS _system_prompt_resolved,
                       (COALESCE(s.message_count, 0) > 0 OR EXISTS (
                           SELECT 1 FROM messages WHERE messages.session_id = s.id LIMIT 1
                       )) AS _has_messages
                FROM sessions s
                LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash
                WHERE s.source = ?
                  AND COALESCE(s.user_id, '') = COALESCE(?, '')
                  AND COALESCE(s.chat_id, '') = COALESCE(?, '')
                  AND COALESCE(s.chat_type, '') = COALESCE(?, '')
                  AND COALESCE(s.thread_id, '') = COALESCE(?, '')
                  AND (s.ended_at IS NULL OR s.end_reason IN ('agent_close', 'ws_orphan_reap'))
                  AND (COALESCE(s.message_count, 0) > 0 OR EXISTS (
                      SELECT 1 FROM messages WHERE messages.session_id = s.id LIMIT 1
                  ))
                  AND NOT EXISTS (
                      SELECT 1 FROM sessions b
                      WHERE b.source = s.source
                        AND COALESCE(b.user_id, '') = COALESCE(s.user_id, '')
                        AND COALESCE(b.chat_id, '') = COALESCE(s.chat_id, '')
                        AND COALESCE(b.chat_type, '') = COALESCE(s.chat_type, '')
                        AND COALESCE(b.thread_id, '') = COALESCE(s.thread_id, '')
                        AND b.ended_at IS NOT NULL
                        AND b.end_reason IN (z)
                        AND b.ended_at
                            > COALESCE(s.last_activity_at, s.started_at)
                  )
                ORDER BY COALESCE(s.last_activity_at, s.started_at) DESC
                LIMIT 1
                )rN  r:  r   r   rQ  r/  )rZ   r  r  r  r  r  r  r   s           rF   $find_latest_gateway_session_for_peerz.SessionDB.find_latest_gateway_session_for_peer  s   P  	4Z H	 H	*$$" /E#  2 f%5 6 hjj7 8 --c22=H	 H	 H	 H	 H	 H	 H	 H	F )"3IH	 H	 H	 H	 H	 H	 H	 H	J *$$ 4 /E5     B '9i@E# #F hjjG KH	 H	 H	 H	 H	 H	 H	 H	 H	 H	 H	 H	 H	 H	 H	R /2;t%%c***t;s   AC(C:;CCCg      @)	max_gap_srR  c                   || j         nt          |          }t          d          }t          d          }d}g | j        5  | j                            d| d                                          }|D ]N}d}d}	d}
|d         rFd	}	| j                            d
| d|d         |d         f                                          }|d}
nd}	| j                            d
| d| d| d| d	|d         |d         |d         |d         |d         pd|z
  |d         pd|z   |d         f                                          }|sd|dd}
nt          |          dk    rd}
n|d         }	                    |d         |d         |d         |d         |d         |r|d         nd|r|d         nd|r|	nd|du|
d
           P	 ddd           n# 1 swxY w Y   fd D             }D ]}|d!         |v r
d"|d#<   d$|d%<   S )&u  Report message-bearing session rows that lost their routing identity.

        A row is a candidate orphan when it has messages but no
        ``session_key``. It is only *adoptable* when exactly one keyed
        predecessor can be named as the conversation it continues:

        * ``lineage`` — ``parent_session_id`` points at a keyed row of the
          same source. That is a recorded fact, so no time window applies.
        * ``contiguity`` — exactly one keyed row of the same source (and
          compatible ``user_id``) fell quiet within *max_gap_s* of the
          orphan's start, and is older than the orphan's own last activity.

        Anything ambiguous is reported with ``adoptable=False`` and a reason
        rather than guessed at: mis-adopting would splice one person's
        conversation into another person's chat. Branch/delegate/tool rows
        are excluded outright — they are unkeyed by design, not by damage.
        Nodzpd.id, d.session_key, d.chat_id, d.chat_type, d.thread_id, d.user_id, d.origin_json, d.display_name, d.end_reasonz
                SELECT o.id, o.source, o.user_id, o.started_at,
                       o.parent_session_id,
                       a   AS last_active,
                       (SELECT COUNT(*) FROM messages m
                         WHERE m.session_id = o.id) AS message_count
                FROM sessions o
                WHERE o.session_key IS NULL
                  AND EXISTS (SELECT 1 FROM messages m
                               WHERE m.session_id = o.id)
                  AND COALESCE(o.source, '') != 'tool'
                  AND json_extract(COALESCE(o.model_config, '{}'),
                                   '$._branched_from') IS NULL
                  AND json_extract(COALESCE(o.model_config, '{}'),
                                   '$._delegate_from') IS NULL
                ORDER BY o.started_at ASC
                rp   r  lineagez 
                        SELECT z
                        FROM sessions d
                        WHERE d.id = ?
                          AND d.session_key IS NOT NULL
                          AND COALESCE(d.source, '') = COALESCE(?, '')
                        r  z9parent session carries no gateway identity of this source
contiguity, a   AS last_active
                        FROM sessions d
                        WHERE d.session_key IS NOT NULL
                          AND d.id != ?
                          AND COALESCE(d.source, '') = COALESCE(?, '')
                          AND (COALESCE(d.user_id, '') = ''
                               OR COALESCE(?, '') = ''
                               OR d.user_id = ?)
                          AND z/ BETWEEN ? AND ?
                          AND zo < ?
                        ORDER BY last_active DESC
                        LIMIT 2
                        r   r  
started_atr   last_activez'no keyed predecessor fell quiet within r  zs of this session's startrq   z>ambiguous: more than one keyed predecessor matches this windowrU   r  )
	orphan_idr  rU   rY  rZ  donor_idr  evidence	adoptablerI  c                 p    h | ]1d          r't          fdD                       dk    )d         2S )r^  c              3   @   K   | ]}|d          d          k    dV  dS )r\  rq   NrK   )r   r[  rs  s     rF   r+  zESessionDB.find_orphaned_gateway_sessions.<locals>.<setcomp>.<genexpr>,  s5      II!!J-1Z=*H*HA*H*H*H*HIIrL   rq   r\  )sum)r   rs  recordss    @rF   r   z;SessionDB.find_orphaned_gateway_sessions.<locals>.<setcomp>(  sa     
 
 
~
 IIIIwIIIIIAMM jM NMMrL   r\  Fr^  z@ambiguous: more than one unkeyed session claims this predecessorrI  )
_ORPHAN_ADOPTION_MAX_GAP_Sr-  r"   rN  r:  r   r   rQ  r   r  )rZ   rR  gaporphan_activedonor_activedonor_columnsorphansorphandonorr]  rI  r;  	contestedrecordrb  s                 @rF   find_orphaned_gateway_sessionsz(SessionDB.find_orphaned_gateway_sessions  s   ,   ++y!! 	
 155/44E 	 )+Z `	 `	j(( &   $ hjj% ( " K K-. 7.(H J.. -     34fX6FG	 	 hjj  }* 
  ,H!%!3!3 - 1=   ,   ,   #4L"8,"9-"9-#L16Q#=#L16Q#="=1" "0 hjj1 2 & .6cQ 6 6 6  Z1,,2 
 !+1%+D\"("2)/)@&,\&:'-m'<38$BE$KKd?D'Nu]';';$05$=HH2%*$%6"(    }K+`	 `	 `	 `	 `	 `	 `	 `	 `	 `	 `	 `	 `	 `	 `	H
 
 
 

 
 
	  	 	Fj!Y..&+{#" x  s   FGGGr[  r\  c                 T    rrk    rdS fd}|                      |          S )a{  Stamp *orphan_id* with *donor_id*'s routing identity, retire *donor_id*.

        Re-verifies the pair inside the write transaction, so a concurrent
        gateway that healed either row in the meantime turns this into a
        no-op instead of a conflicting write. Existing non-NULL columns on
        the orphan are preserved. Returns True when the adoption applied.
        Fc                    |                      df                                          }|                      df                                          }||dS |d         r|d         rdS |d         pd|d         pdk    rdS |                      d|d         |d         |d	         |d
         |d         |d         |d         f	           |                      dt          j                    f           dS )NzxSELECT session_key, chat_id, chat_type, thread_id, user_id, origin_json, display_name, source FROM sessions WHERE id = ?z5SELECT session_key, source FROM sessions WHERE id = ?Fr  r  rp   a6  UPDATE sessions
                      SET session_key = ?,
                          chat_id = COALESCE(chat_id, ?),
                          chat_type = COALESCE(chat_type, ?),
                          thread_id = COALESCE(thread_id, ?),
                          user_id = COALESCE(user_id, ?),
                          origin_json = COALESCE(origin_json, ?),
                          display_name = COALESCE(display_name, ?),
                          parent_session_id = COALESCE(parent_session_id, ?)
                    WHERE id = ? AND session_key IS NULLr  r  r  r  r  r  zfUPDATE sessions SET ended_at = COALESCE(ended_at, ?), end_reason = 'superseded_by_repair' WHERE id = ?T)r   rQ  rT  )r   rj  ri  r\  r[  s      rF   r  z5SessionDB.adopt_orphaned_gateway_session.<locals>._doD  s>   LLO  hjj	 
 \\G  hjj  }u' 6-+@ uh%26(+;+ArBBuLL	< -()$+&+&)$-(.)
  6 LLCh'  
 4rL   r
  )rZ   r[  r\  r  s    `` rF   adopt_orphaned_gateway_sessionz(SessionDB.adopt_orphaned_gateway_session7  sY      	 	I,A,A51	 1	 1	 1	 1	 1	f ""3'''rL   z  AND COALESCE(json_extract(COALESCE({alias}model_config, '{{}}'), '$._branched_from'), '') != ?
  AND COALESCE(json_extract(COALESCE({alias}model_config, '{{}}'), '$._delegate_from'), '') != ?
  AND COALESCE({alias}source, '') != 'tool'
c                    |sdS | j         5  | j                            d|f                                          }||d         |d         dk    r	 ddd           dS | j                            d| j                            d          z   d	z   |||f                                          }ddd           n# 1 swxY w Y   t          |          d
k    r|                     |d                   ndS )a  Return the unique live direct child of a compression-ended session.

        A stale agent may observe that another compression path already rotated
        its parent. Recovery is safe only when the durable lineage identifies
        exactly one live direct continuation. Multiple children are treated as
        ambiguous and fail closed rather than guessing which transcript owns
        subsequent messages.
        N6SELECT ended_at, end_reason FROM sessions WHERE id = ?ended_at
end_reasonr  ad  
                SELECT s.*,
                       COALESCE(sp.prompt, s.system_prompt)
                           AS _system_prompt_resolved
                FROM sessions s
                LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash
                WHERE s.parent_session_id = ?
                  AND s.ended_at IS NULL
                zs.aliaszS
                ORDER BY s.started_at ASC
                LIMIT 2
                rq   r   )	rN  r:  r   rQ  "_NON_CONTINUATION_CHILD_FILTER_SQLformatr   r   r/  )rZ   r  r   r  s       rF   find_live_compression_childz%SessionDB.find_live_compression_child  sy    ! 	4Z 	 	Z''H"$  hjj 
 *%-,'=88	 	 	 	 	 	 	 	 :%% 9@@t@LL	M #$57HI   hjj! 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	8 36d))q..t%%d1g...dJs   AB:AB::B>B>c                 ^     sdS  fd}t                               |                    S )a  Reopen a compression parent only when no continuation was published.

        Compression publication is atomic in current builds, but older builds
        could leave a closed parent behind after an interrupted handoff.  This
        recovery is deliberately conservative: an active compression lease or
        any canonical child means the lineage is still owned by another path,
        so the caller must fail closed instead of reopening the parent.
        Fc                 x   |                      d	f                                          }||d         |d         dk    rdS |                      dj                            d          z   d	z   			f                                          }|dS t	          j                    }|                      d
	f                                          }|K|d         }|t          |          |k    rdS |                      d	|d         |f          }|j        dk    rdS |                      d	f          }|j        dk    S )Nrr  rs  rt  r  Fzt
                SELECT 1
                FROM sessions
                WHERE parent_session_id = ?
                rp   ru  z)
                LIMIT 1
                ESELECT holder, expires_at FROM compression_locks WHERE session_id = ?
expires_atzTDELETE FROM compression_locks WHERE session_id = ? AND holder = ? AND expires_at = ?rn   rq   z{UPDATE sessions SET ended_at = NULL, end_reason = NULL WHERE id = ? AND ended_at IS NOT NULL AND end_reason = 'compression')r   rQ  rw  rx  rT  r-  rowcount)
r   r   childr)  lock_rowr}  r=  updatedrZ   rd   s
           rF   r  z:SessionDB.reopen_orphaned_compression_session.<locals>._do  s   \\H  hjj 
 *%-,'=88u
 LL
 9@@r@JJK Z4  hjj   u )++C||'  hjj	 
 #%l3
%z):):c)A)A 5,,M(!3Z@ 
 #q(( 5ll1 	 G #q((rL   r   r  rZ   rd   r  s   `` rF   #reopen_orphaned_compression_sessionz-SessionDB.reopen_orphaned_compression_session  sV      	5@	) @	) @	) @	) @	) @	)D D'',,---rL   )	r  r   rf   r   r  compression_lock_holderrequire_compression_lease	watermarkwatermark_ceilingchild_session_idr1  r  r  r  r  c                p    	
 
	 fd}                      |           dS )uA  Atomically close a parent and publish its durable compression child.

        The parent closure, child row, and compacted handoff become visible in
        one transaction. Readers can therefore observe either the live parent or
        a complete child, never an ended parent with a missing/empty child.

        Concurrent-append safety (#75316): when *watermark* is provided (the
        parent's :meth:`get_active_message_watermark` captured at compression
        start), parent rows that arrived during the slow summary call
        (``id > watermark``) are cloned into the child AFTER the handoff —
        same pure-SQL column clone as :meth:`archive_and_compact`, with the
        session id rewritten — so a mid-compression append survives rotation
        instead of stranding in the closed parent.

        *watermark_ceiling* bounds the clone from above: the rotation path
        flushes its OWN un-persisted input transcript to the parent right
        before publishing (#47202), and those rows are already represented in
        the compacted handoff — cloning them would duplicate the transcript.
        The caller captures ``MAX(id)`` immediately BEFORE that flush; only
        rows in ``(watermark, watermark_ceiling]`` are foreign concurrent
        tail. ``None`` = unbounded (no internal flush happened).
        c                    |                      df                                          }rL|8r6|d         k    s*t          |d                   t          j                    k    rt	          d           |                      df                                          }|t          d           |d         t          d           st          d	                              |           }|                      d
rt          j                  nd |p|d         |d         |d         p|d         |d         |d         |d         |d         |d         |d         |d         t          j                    f           	                    |           \  }}xd}t                    g}$d}|                    t                               |                      d| d|                                          }|rd |D             }	d                    d |	D                       }
d                     |           D             }d                    |          }|                      d| d | d!|
 d"g|	           |t          |	          z  }|D ]y}|d#         }|rm	 t!          |t"                    rt          j        |          n|}|t!          |t&                    rt          |          nd$z  }b# t(          t*          f$ r Y uw xY wz|                      d%||f           |                      d&t          j                    f          }|j        d'k    rt          d(           d S ))Nr|  rn   r}  z+Compression lease lost before publication: zSELECT ended_at, cwd, git_branch, git_repo_root,
                          user_id, session_key, chat_id, chat_type,
                          thread_id, display_name, origin_json, profile_name
                   FROM sessions WHERE id = ?zCompression parent not found: rs  z"Compression parent already ended: z+Compression child handoff must not be emptya  INSERT INTO sessions (
                   id, source, model, model_config, system_prompt,
                   system_prompt_hash,
                   parent_session_id, cwd, git_branch, git_repo_root,
                   profile_name, user_id, session_key, chat_id, chat_type,
                   thread_id, display_name, origin_json, started_at
                ) VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)r   
git_branchr   r  r  r  r  r  r  r  r  rp   z AND id <= ?zRSELECT id, tool_calls FROM messages WHERE session_id = ? AND active = 1 AND id > ? ORDER BY idc                 8    g | ]}t          |d                    S r   )rA   rr  s     rF   r   zDSessionDB.publish_compression_child.<locals>._do.<locals>.<listcomp>  s"    @@@AdG@@@rL   r   c              3      K   | ]}d V  dS r  rK   r  s     rF   r+  zCSessionDB.publish_compression_child.<locals>._do.<locals>.<genexpr>  s"      +B+BAC+B+B+B+B+B+BrL   c                     g | ]}|d v|	S ))r   rd   active	compactedrK   r   cs     rF   r   zDSessionDB.publish_compression_child.<locals>._do.<locals>.<listcomp>  s.     " " "$OOO OOOrL   rX  INSERT INTO messages (z(, session_id, active, compacted) SELECT z%, ?, 1, 0 FROM messages WHERE id IN () ORDER BY idr8  r   GUPDATE sessions SET message_count = ?, tool_call_count = ? WHERE id = ?z^UPDATE sessions SET ended_at = ?, end_reason = 'compression' WHERE id = ? AND ended_at IS NULLrq   z/Compression parent changed during publication: )r   rQ  r-  rT  r  r  r'  r  r(  _insert_message_rowsrA   r  r   r   _message_column_namesr   r   r_   r  r   rv   rw   r~  )r   r  r   r  total_messagestotal_tool_calls_ceiling_clause_params	tail_rowstail_idsplaceholders
clone_colscol_listrs  rz  parsedr  r  r  r   r1  r  r   r  r  r  rZ   r  rf   r  r  s                    rF   r  z0SessionDB.publish_compression_child.<locals>._do-  s   ||W"$  hjj  )  . !H%)@@@,/00DIKK??1UBSUU   \\1 #$  hjj  ~"#WDU#W#WXXXj!-"#[HY#[#[\\\ R"#PQQQ!%!:!:4!O!OLLY %0<FDJ|,,,$&%(6%=<(?+ !:F>$:9%=)9%;';'>*=)IKK/! ! !D 04/H/H&0 0,N, $
 #%!2C	NN C$0&4ONN3'8#9#9::: LL5&5 5 5 	 
 (**   %@@i@@@H#&88+B+B+B+B+B#B#BL" "#'#=#=d#C#C" " "J  $yy44HLLD D D"*D D(4D D D *5H5	   #c(mm3N& % %o %%<FsC<P<P)YCVY 0:fVZC[C[4bCKKKab b 0 0$-z#: % % % $%	% LLY!13CD   ll4/0 G
 1$$"YFWYY   %$s   4AL		LLNr
  )rZ   r  r  r  r1  r  r   rf   r   r  r  r  r  r  r  s   `````````````` rF   publish_compression_childz#SessionDB.publish_compression_child  s    Nu	 u	 u	 u	 u	 u	 u	 u	 u	 u	 u	 u	 u	 u	 u	 u	 u	 u	n 	C     rL   rt  c                 @    fd}|                      |           dS )a  Mark a session as ended.

        No-ops when the session is already ended. The first end_reason wins:
        compression-split sessions must keep their ``end_reason = 'compression'``
        record even if a later stale ``end_session()`` call (e.g. from a
        desynced CLI session_id after ``/resume`` or ``/branch``) targets them
        with a different reason. Use ``reopen_session()`` first if you
        intentionally need to re-end a closed session with a new reason.
        c                 \    |                      dt          j                    f           d S )NzRUPDATE sessions SET ended_at = ?, end_reason = ? WHERE id = ? AND ended_at IS NULLr  )r   rt  rd   s    rF   r  z"SessionDB.end_session.<locals>._do  s8    LL4j*5    rL   Nr
  )rZ   rd   rt  r  s    `` rF   end_sessionzSessionDB.end_session  s>    	 	 	 	 	 	 	C     rL   c                 <    fd}|                      |           dS )zClear ended_at/end_reason so a session can be resumed.

        Before clearing a reset boundary, stabilize markerless legacy reset
        children that still depend on the parent's mutable end_reason.
        c                     d                     d t          D                       }|                     dt          d|           gt          R            |                     df           d S )Nr   c              3      K   | ]}d V  dS r  rK   r  s     rF   r+  z8SessionDB.reopen_session.<locals>._do.<locals>.<genexpr>  s"      #D#DAC#D#D#D#D#D#DrL   a  UPDATE sessions AS child SET model_config = json_set(COALESCE(child.model_config, '{}'), '$._reset_from', child.parent_session_id) WHERE child.parent_session_id = ? AND json_extract(COALESCE(child.model_config, '{}'),                  '$._reset_from') IS NULL AND r  zCUPDATE sessions SET ended_at = NULL, end_reason = NULL WHERE id = ?)r   r   r   r    )r   r  rd   s     rF   r  z%SessionDB.reopen_session.<locals>._do  s    88#D#D1C#D#D#DDDL LLH /wEEH H 1011	 	 	 LLU    rL   Nr
  r  s    ` rF   reopen_sessionzSessionDB.reopen_session  s8    	 	 	 	 	& 	C     rL   session_resetrI  c                     sdS t          j                     fd}	 |                     |          }t          |          S # t          $ r Y dS w xY w)u  Durably mark a session as ended by an intentional reset boundary.

        Promotes *only* live rows (``ended_at IS NULL``) or rows carrying an
        accidental end_reason that the recovery query
        (``find_latest_gateway_session_for_peer``) treats as recoverable:
        ``agent_close`` (older gateway cleanup bug) and ``ws_orphan_reap``
        (mistaken TUI reaper).  Explicit conversation boundaries such as
        ``compression``, ``session_reset``, ``session_switch``, etc. are
        preserved — the first writer wins for those, and a later expiry
        finalization must not silently overwrite them.

        Plain ``end_session()`` is NOT sufficient for reset boundaries: it
        no-ops on an already-ended row, so a row that agent cleanup already
        closed as ``agent_close`` would stay recoverable and stale-route
        recovery would resurrect the reset session with its full history
        (#61220, #61993, #63539).

        Keep this promotion set in sync with the recoverable set in
        ``find_latest_gateway_session_for_peer`` — any reason recovery would
        reopen must be promotable here.

        ``reason`` lets reset paths keep their auditable specific reasons
        (``idle``, ``daily``, ``suspended``, ``resume_pending_expired``).

        Returns ``True`` when the row was promoted, ``False`` when skipped
        (already has a different explicit end_reason, or row not found).
        Fc                 D    |                      df          }|j        S )NzUPDATE sessions SET ended_at = ?, end_reason = ? WHERE id = ? AND (ended_at IS NULL OR end_reason IN ('agent_close', 'ws_orphan_reap'))r   r~  )r   r   r)  rI  rd   s     rF   r  z/SessionDB.promote_to_session_reset.<locals>._do  s2    \\F fj)	 F ?"rL   )rT  r  r   rB   )rZ   rd   rI  r  r  r)  s    ``  @rF   promote_to_session_resetz"SessionDB.promote_to_session_reset  s    <  	5ikk	# 	# 	# 	# 	# 	# 	#	&&s++D:: 	 	 	55	s   #A 
AAr  replace_git_metac                     rsdS |pd                                 |pd                                 fd}|                     |          S )u  Persist the authoritative cwd and claim a Git metadata generation.

        ``git_branch`` records the git branch checked out in ``cwd`` at the time
        the session started/resumed. The sidebar groups main-checkout sessions
        by this so feature-branch work doesn't pile under a single "main" row
        (the main checkout's *current* branch is transient and would
        misattribute past sessions).

        ``git_repo_root`` records the git repo this cwd belongs to — the
        authoritative project key. Resolving it here, at the lowest level, means
        every surface reads the same membership instead of re-probing git in the
        GUI over a partial page. Each field is only written when non-empty so a
        probe failure never clobbers a previously-captured value.

        ``replace_git_meta`` inverts that non-empty rule: a deliberate workspace
        MOVE (re-homing a session into another project) must overwrite the old
        repo identity even when the new cwd resolves to none — keeping the stale
        root would leave the session grouped under the project it just left.

        Every call increments ``git_metadata_generation`` in the same write
        transaction. Async Git probes must publish through
        :meth:`publish_session_git_metadata` with the returned generation, so
        an older worker cannot overwrite a newer cwd claim even after an
        A -> B -> A transition or from another process sharing this database.
        Metadata from a different cwd is cleared atomically with the move.
        Nrp   c                 B   |                      df                                          }|d S t          |t          j                  r|d         n|d         }ddg}g}|k    s	r1|                    d           |                    pd 
pd f           n,r*|                    d           |                               
r2|k    r,	s*|                    d           |                    
           |                               |                      d	d
                    |           d|           |                      df                                          }|d S t          |t          j                  r|d         n|d         }t          |          S )Nz%SELECT cwd FROM sessions WHERE id = ?r   r   zcwd = ?zBgit_metadata_generation = COALESCE(git_metadata_generation, 0) + 1)git_branch = ?git_repo_root = ?r  r  UPDATE sessions SET rX  z WHERE id = ?z9SELECT git_metadata_generation FROM sessions WHERE id = ?git_metadata_generation)	r   rQ  r   rR  r;  r  r  r   rA   )r   r  current_cwdsetsrF  r   rD   branchr   r  	repo_rootrd   s          rF   r  z)SessionDB.update_session_cwd.<locals>._do,  s   ll7* hjj  t,6w,L,L\'%..RYZ[R\KTD "%Fc!!%5!CDDDv~y/@DABBBB &,---f%%% )[C//8H//000i(((MM*%%%LLEtyyEEEv   ,,K  hjj  {t6@gk6R6R^C122X[\]X^Eu::rL   )r   r  )	rZ   rd   r   r  r   r  r  r  r  s	    ``  ` @@rF   update_session_cwdzSessionDB.update_session_cwd  s    D  	 	4"))++"(b//11	!	 !	 !	 !	 !	 !	 !	 !	 !	F ""3'''rL   
generationc                   	
 |r2|r0t          |t                    st          |t                    r|dk     rdS |pd                                }|pd                                }|s|sdS g 
g 	|r*
                    d           	                    |           |r*
                    d           	                    |           	                    |||f           	
fd}t          |                     |                    S )zAPublish async Git enrichment only while its cwd claim is current.rq   Frp   r  r  c                 t    |                      dd                               d          }|j        dk    S )Nr  rX  z9 WHERE id = ? AND cwd = ? AND git_metadata_generation = ?rq   )r   r   r~  )r   r   rF  r  s     rF   r  z3SessionDB.publish_session_git_metadata.<locals>._dor  sJ    \\2tyy 2 2 2 	 F ?a''rL   )r   r   rA   r   r  r  r  )rZ   rd   r   r  r  r   r  r  r  rF  r  s            @@rF   publish_session_git_metadataz&SessionDB.publish_session_git_metadataQ  sG    		 *d++	 j#..		
 A~~5"))++"(b//11	 	i 	5 	"KK()))MM&!!! 	%KK+,,,MM)$$$z3
3444	( 	( 	( 	( 	( 	( D'',,---rL   cwd_to_rootc                     d |                                 D             sdS fd}|                     |           dS )a$  Persist resolved git repo roots for cwds that don't have one yet.

        Backfills history so projects light up for sessions created before the
        column existed, without clobbering an already-recorded root. Only
        non-empty roots are written (a non-git cwd stays NULL).
        c                 $    g | ]\  }}||	||fS rK   rK   )r   r   r   s      rF   r   z1SessionDB.backfill_repo_roots.<locals>.<listcomp>  s*    RRRdTRcR$RRRrL   Nc                 H    D ]\  }}|                      d||f           d S )NzXUPDATE sessions SET git_repo_root = ? WHERE cwd = ? AND COALESCE(git_repo_root, '') = ''r)  )r   r   r   pairss      rF   r  z*SessionDB.backfill_repo_roots.<locals>._do  sH    "  	cI3K    rL   )r  r  )rZ   r  r  r  s      @rF   backfill_repo_rootszSessionDB.backfill_repo_roots}  sg     SRk.?.?.A.ARRR 	F	 	 	 	 	 	C     rL   cooldown_untilr  c                     sdS fd}	 |                      |           dS # t          j        $ r'}t                              d|           Y d}~dS d}~ww xY w)z>Persist the active compression-failure cooldown for a session.Nc                 :    |                      df           d S )NfUPDATE sessions SET compression_failure_cooldown_until = ?, compression_failure_error = ? WHERE id = ?r)  )r   r  r  rd   s    rF   r  z:SessionDB.record_compression_failure_cooldown.<locals>._do  s2    LL=
3    rL   z2record_compression_failure_cooldown(%s) failed: %sr  rR  rr  r=  r  )rZ   rd   r  r  r  r\  s    ```  rF   #record_compression_failure_cooldownz-SessionDB.record_compression_failure_cooldown  s      	F	 	 	 	 	 	 		$$$$$} 	 	 	NNDC        	s   ' AAAc                    |sdS t          j                     }| j        5  | j                            d|f                                          }ddd           n# 1 swxY w Y   |dS t          |t          j                  r|d         n|d         }|dS t          |          }||k    rdS t          |t          j                  r|d         n|d         }|||z
  |dS )zBReturn the active compression-failure cooldown for ``session_id``.N_SELECT compression_failure_cooldown_until, compression_failure_error FROM sessions WHERE id = ?"compression_failure_cooldown_untilr   compression_failure_errorrq   )r  remaining_secondsr  )	rT  rN  r:  r   rQ  r   rR  r;  r-  )rZ   rd   r)  r   r  r  s         rF    get_compression_failure_cooldownz*SessionDB.get_compression_failure_cooldown  sW   
  	4ikkZ 	 	*$$-  hjj	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 ;4 #w{++C455Q 	
 !4~..S  4 #w{++C+,,Q 	 -!/#!5
 
 	
s   /AA!Ac                    |sddddS | j         5  | j                            d|f                                          }ddd           n# 1 swxY w Y   |ddddS t	          |t
          j                  r|d         n|d         }t	          |t
          j                  r|d         n|d         }d	|t          |          nd|dS )
aG  Return the exact stored cooldown columns without expiry filtering.

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

        Unlike the ordinary record/clear helpers, this transactional rollback
        API deliberately propagates write and verification failures. A caller
        must not report cancellation as mutation-free when compensation failed.
        r  FzBcannot restore absent compression cooldown row: session now existsNr  r  c                 t    |                      df          }|j        dk    rt          d           d S )Nr  rq   z/compression cooldown rollback session missing: )r   r~  r  )r   r   r  r  rd   s     rF   r  z?SessionDB.restore_compression_failure_cooldown_row.<locals>._do  sY    \\=5*- F
 !##"RjRR   $#rL   Tr  z<compression cooldown rollback verification failed: expected=z	, actual=)r   r@   r  r  r  r-  )	rZ   rd   r  expected_existsr  r  expectedr  r  s	    `     @@rF   (restore_compression_failure_cooldown_rowz2SessionDB.restore_compression_failure_cooldown_row  sJ    x||,<eDDEE 	>>zJJFzz*E22 "X   F<< 011W%%		 		 		 		 		 		 		 	C   :::FF"191EeHooo4
 

 X<$< <17< <   rL   c                     sdS fd}	 |                      |           dS # t          j        $ r'}t                              d|           Y d}~dS d}~ww xY w)z?Clear any persisted compression-failure cooldown for a session.Nc                 6    |                      df           d S )NzlUPDATE sessions SET compression_failure_cooldown_until = NULL, compression_failure_error = NULL WHERE id = ?r)  r   rd   s    rF   r  z9SessionDB.clear_compression_failure_cooldown.<locals>._do+  s.    LL@    rL   z1clear_compression_failure_cooldown(%s) failed: %sr  )rZ   rd   r  r\  s    `  rF   "clear_compression_failure_cooldownz,SessionDB.clear_compression_failure_cooldown&  s     	F	 	 	 	 		$$$$$} 	 	 	NNCC        	s   # AAAc                    |sdS | j         5  | j        }|	 ddd           dS |                    d|f                                          }ddd           n# 1 swxY w Y   |dS t	          |t
          j                  r|d         n|d         }	 t          dt          |pd                    S # t          t          f$ r Y dS w xY w)z3Return the persisted deterministic-fallback streak.r   Nz=SELECT compression_fallback_streak FROM sessions WHERE id = ?compression_fallback_streakrN  r:  r   rQ  r   rR  r;  r  rA   rv   rw   rZ   rd   r   r   rD   s        rF   get_compression_fallback_streakz)SessionDB.get_compression_fallback_streak:  sM    	1Z 	 	:D|	 	 	 	 	 	 	 	 ,,O  hjj 		 	 	 	 	 	 	 	 	 	 	 	 	 	 	 ;1 #w{++C-..Q 	
	q#ejq//***:& 	 	 	11	(   A)AA AB2 2CCstreakc                     sdS t          dt          |                    fd}|                     |           dS )z:Persist the deterministic-fallback streak for one session.Nr   c                 8    |                      df           d S )Nz@UPDATE sessions SET compression_fallback_streak = ? WHERE id = ?r)  r   
normalizedrd   s    rF   r  z6SessionDB.set_compression_fallback_streak.<locals>._doX  s.    LLRZ(    rL   r  rA   r  )rZ   rd   r  r  r  s    `  @rF   set_compression_fallback_streakz)SessionDB.set_compression_fallback_streakR  s`     	FCKK((
	 	 	 	 	 	 	C     rL   c                 X    sdS g fd}|                      |           d         S )zEAtomically increment the session-hygiene failure streak for one chat.rq   c                     |                      df           |                      df                                          }                    t          |d                              d S )NzINSERT INTO gateway_hygiene_state (session_key, failure_streak)
                   VALUES (?, 1)
                   ON CONFLICT(session_key) DO UPDATE SET
                       failure_streak = gateway_hygiene_state.failure_streak + 1zFSELECT failure_streak FROM gateway_hygiene_state WHERE session_key = ?r   )r   rQ  r  rA   )r   r   r  r  s     rF   r  z7SessionDB.increment_hygiene_failure_streak.<locals>._dof  su    LLT    ,,X  hjj  MM#c!f++&&&&&rL   r   r
  )rZ   r  r  r  s    ` @rF    increment_hygiene_failure_streakz*SessionDB.increment_hygiene_failure_streak`  sT     	1	' 	' 	' 	' 	' 	' 	C   ayrL   c                 D    sdS fd}|                      |           dS )z@Clear the persisted session-hygiene failure streak for one chat.Nc                 6    |                      df           d S )Nz7DELETE FROM gateway_hygiene_state WHERE session_key = ?r)  )r   r  s    rF   r  z3SessionDB.reset_hygiene_failure_streak.<locals>._do|  s+    LLI    rL   r
  )rZ   r  r  s    ` rF   reset_hygiene_failure_streakz&SessionDB.reset_hygiene_failure_streakw  sE     	F	 	 	 	 	 	C     rL   c                    |sdS | j         5  | j        }|	 ddd           dS |                    d|f                                          }ddd           n# 1 swxY w Y   |dS t	          |t
          j                  r|d         n|d         }	 t          dt          |pd                    S # t          t          f$ r Y dS w xY w)a  Return the persisted ineffective-compaction strike count.

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

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

        This does not resurrect a lock somebody else already took: SQLite
        serialises writes, so a reclaim (DELETE-expired + INSERT-or-IGNORE in
        :meth:`try_acquire_compression_lock`) and this UPDATE never interleave.
        Reclaim-first replaces ``holder``, so this UPDATE matches nothing and
        returns False; refresh-first pushes ``expires_at`` into the future, so
        the reclaimer's DELETE-expired matches nothing and its acquire fails.
        Fc                 L    |                      df          }|j        dk    S )NzOUPDATE compression_locks SET expires_at = ? WHERE session_id = ? AND holder = ?r   r  )r   r	  r}  rn   rd   s     rF   r  z/SessionDB.refresh_compression_lock.<locals>._do  s4    ,,6Z0 C
 <!##rL   z'refresh_compression_lock(%s) failed: %sN)rT  r   r  rR  rr  r=  r  )rZ   rd   rn   r  r)  r  r\  r}  s    ``    @rF   refresh_compression_lockz"SessionDB.refresh_compression_lock  s    0  	 	5ikk;&
	$ 	$ 	$ 	$ 	$ 	$ 	$	++C00111} 	 	 	NN9C   55555	s   !A BA==Bc                 P  	 sdS t          j                     		|z   	fd}	 |                     |          \  }}|rt                              d|           t	          |          S # t
          j        $ r'}t                              d|           Y d}~dS d}~ww xY w)u  Try to atomically acquire the compression lock for ``session_id``.

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

        Expired locks (``expires_at < now``) are reclaimed transparently.
        Structured holders whose local ``pid=`` no longer exists are reclaimed
        immediately, so a gateway killed during compression does not stall the
        replacement process for the full lease TTL.

        Implementation: single-transaction DELETE-expired + INSERT-or-IGNORE,
        followed by a SELECT to confirm we got the row. SQLite serialises
        writes, so the whole sequence is atomic against other writers.
        Fc                 V   d }|                      d	f                                          }|t          |t          j                  r|d         n|d         }t          |t          j                  r|d         n|d         }|k     st          |          r|                      d	|f           |}|                      d	f           |                      d	f                                          }|d uo-t          |t          j                  r|d         n|d         k    }||fS )	Nr|  rn   r   r}  rq   ADELETE FROM compression_locks WHERE session_id = ? AND holder = ?ziINSERT OR IGNORE INTO compression_locks (session_id, holder, acquired_at, expires_at) VALUES (?, ?, ?, ?)z9SELECT holder FROM compression_locks WHERE session_id = ?)r   rQ  r   rR  r;  r   )
r   reclaimed_holderr   current_holdercurrent_expires_atr  r}  rn   r)  rd   s
         rF   r  z3SessionDB.try_acquire_compression_lock.<locals>._do  so   #,,'  hjj	 
 %/W[%A%AMCMMs1v  *4C)E)EQC%%3q6 # ',,?OO - LL>#^4  
 (6$ LL& VS*5	   ,,K  hjj  $ !+C!=!=IH3q6,H ---rL   z;Reclaimed stale compression lock for session=%s (holder=%s)z+try_acquire_compression_lock(%s) failed: %sN)rT  r  r=  r  r   rR  rr  )
rZ   rd   rn   r  r  r  r  r\  r}  r)  s
    ``     @@rF   try_acquire_compression_lockz&SessionDB.try_acquire_compression_lock  s    .  	5ikk;&
'	. '	. '	. '	. '	. '	. '	. '	.R	)-)<)<S)A)A&H& "$	   >>!} 	 	 	NN=C   55555	s   AA/ /B%>B  B%c                     sdS fd}	 |                      |           dS # t          j        $ r'}t                              d|           Y d}~dS d}~ww xY w)aK  Release the compression lock for ``session_id`` iff we own it.

        Idempotent: no-op when the lock has already expired and been
        reclaimed by a different holder, or when no lock exists. The
        ``holder`` check prevents a late-returning compressor from
        clobbering a fresh lock held by someone else.
        Nc                 8    |                      df           d S )Nr  r)  )r   rn   rd   s    rF   r  z/SessionDB.release_compression_lock.<locals>._doW  s0    LL6V$    rL   z'release_compression_lock(%s) failed: %sr  )rZ   rd   rn   r  r\  s    ``  rF   release_compression_lockz"SessionDB.release_compression_lockL  s      	F	 	 	 	 	 		$$$$$} 	 	 	NN9C        	s   % AAAc                 x   |s|S dt           ffd} ||          }|h}|rq|                    d          }|r||v s|                     |          rn@ ||          }|r|                    d          dk    rn|                    |           |}|q|r$t          |                    d          p|          n|S )a
  Walk compression parents on ``conn`` to the conversation lease key.

        Must run on the same connection as the lease INSERT/UPDATE/DELETE.
        A prior ``get_session`` failure must not compute a child id that the
        later write then persists: refresh would walk to the parent and
        fail-close. Markers bind to ``parent_session_id`` (same contract as
        ``_NON_CONTINUATION_CHILD_FILTER_SQL``). Lock errors propagate so
        ``_execute_write`` / ``acquire_session_turn_lease`` can retry.
        r   c                 |                         d| f                                          }|rt          |          nd S )NzYSELECT id, parent_session_id, source, model_config, end_reason FROM sessions WHERE id = ?)r   rQ  r,  )r   r   r   s     rF   _rowz7SessionDB._session_turn_lease_key_on_conn.<locals>._rows  sE    ,,-  hjj	 
 !$-4999-rL   r  rt  r  r   )r_   r@   _is_explicit_fork_child_rowr  )rZ   r   rd   r  r  seen	parent_idr   s    `      rF   _session_turn_lease_key_on_connz)SessionDB._session_turn_lease_key_on_connf  s     		.c 	. 	. 	. 	. 	. 	. $z""| 	$788I$$33G<< % T)__F VZZ55FFHHYG  	 8?Ns7;;t$$2
333JNrL   c                     |s|S |                                  5 }|                     ||          cddd           S # 1 swxY w Y   dS )al  Return the stable serialization key for every compression segment.

        Acquire/refresh/release resolve this inside their write transaction.
        This helper is for tests and diagnostics; it does not swallow lock
        errors (a swallowed walk plus a later successful write was the
        fail-open that replayed the post-rotation refresh miss).
        N)r  r  )rZ   rd   r   s      rF   _session_turn_lease_keyz!SessionDB._session_turn_lease_key  s      	^^ 	J77jII	J 	J 	J 	J 	J 	J 	J 	J 	J 	J 	J 	J 	J 	J 	J 	J 	J 	Js   <A A r  r  c                     rsdS t          j                     t          dt          |                    z    fd}t                               ||                    S )a  Atomically acquire the cross-process turn lease for a conversation.

        Compression rotates a session into child segments, so the durable key
        is the lineage root rather than the current segment id. The walk and
        INSERT share one write transaction. Expired leases and leases whose
        structured local holder PID is known dead are reclaimed in that same
        transaction.
        Fr  c                                         | 	          }|                     d|f                                          }|H|d         }t          |d                   k    st	          |          r|                     d||f           |                     d|f           |                     d|f                                          }|d uo|d         k    S )NLSELECT holder, expires_at FROM session_turn_leases WHERE conversation_id = ?rn   r}  HDELETE FROM session_turn_leases WHERE conversation_id = ? AND holder = ?zpINSERT OR IGNORE INTO session_turn_leases (conversation_id, holder, acquired_at, expires_at) VALUES (?, ?, ?, ?)z@SELECT holder FROM session_turn_leases WHERE conversation_id = ?)r  r   rQ  r-  r   )
r   conversation_idr   r  ownerr}  rn   r)  rZ   rd   s
        rF   r  z5SessionDB.try_acquire_session_turn_lease.<locals>._do  s   "BB4TTO,,, "  hjj	 
 !$X#l+,,33?OO 4 LLC(.9  
 LL& !&#z:	   LLR "  hjj  $BxF)BBrL   r  rT  r  r-  r   r  )rZ   rd   rn   r  r  r  r}  r)  s   ```   @@rF   try_acquire_session_turn_leasez(SessionDB.try_acquire_session_turn_lease  s       	 	5ikk3sE+$6$6777
	C 	C 	C 	C 	C 	C 	C 	C 	C< D''
'CCDDDrL   g      @g      .@)r  wait_secondspoll_interval_secondson_waitwait_notice_interval_secondsshould_abortacquire_patience_sr  r  r  r!  c          
      >   t          j                    t          dt          |                    z   }
d}d}t          dt          |                    }	 |:	  |            rdS n,# t          $ r t
                              dd           Y nw xY w	 |                     ||||	          rdS n0# t          j	        $ r}t          |          dk    r Y d}~nd}~ww xY wt          j                    }|
|z
  }|d	k    rdS ||}|]||dk    s	||z
  |k    rL	  |t          d||z
                       n,# t          $ r t
                              d
d           Y nw xY w|}t          j        t          t          dt          |                    |                     K)a0  Wait for a cross-process turn lease without holding a SQLite lock.

        ``on_wait(elapsed_seconds)`` is best-effort: invoked when the first
        attempt fails (elapsed ~0) and again about every
        ``wait_notice_interval_seconds`` while still waiting, so UIs can show
        that another process holds the conversation.

        When ``should_abort()`` returns True (for example the agent received
        ``/stop`` while waiting), acquisition stops immediately and returns
        False without consuming the full ``wait_seconds`` budget.
        r5  NTFz/session turn lease should_abort callback failedr  r  r  r   z*session turn lease on_wait callback failedg{Gz?)rT  r  r  r-  rB   r=  rY  r  rR  rr  r  rU  rE  )rZ   rd   rn   r  r  r  r  r  r   r!  r  wait_startedlast_notice_atnotice_everyr\  r)  	remainings                    rF   acquire_session_turn_leasez$SessionDB.acquire_session_turn_lease  s?   0 >##c#u\/B/B&C&CC3&B C CDD+	P'#|~~ %$u%    LLI!% !     
66 +1	 7      4  =    .c22h>> ?>>>>	 .""C 3IA~~u#""&3&&.(\99GCS<%7889999    LLD!% !     
 "%Js3tU+@%A%ABBINNOOOW+	Ps<   
A$ $&BBB- -C<CCD2 2&EE)r  c                     rsdS t          j                     t          dt          |                    z    fd}t                               |                    S )z8Extend a turn lease only while ``holder`` still owns it.Fr  c                 x                         |           }|                     d|f          }|j        dk    S )NVUPDATE session_turn_leases SET expires_at = ? WHERE conversation_id = ? AND holder = ?r   )r  r   r~  )r   r  r   r}  rn   rZ   rd   s      rF   r  z1SessionDB.refresh_session_turn_lease.<locals>._do#  sI    "BB4TTO\\;_f5 F
 ?Q&&rL   r  )rZ   rd   rn   r  r  r}  s   ```  @rF   refresh_session_turn_leasez$SessionDB.refresh_session_turn_lease  s      	 	5Y[[3sE+,>,>#?#??
	' 	' 	' 	' 	' 	' 	' 	' D'',,---rL   c                 P     rsdS  fd}                      |           dS )z>Release a turn lease iff ``holder`` still owns it; idempotent.Nc                 d                         |           }|                     d|f           d S )Nr  )r  r   )r   r  rn   rZ   rd   s     rF   r  z1SessionDB.release_session_turn_lease.<locals>._do3  sE    "BB4TTOLL; &)    rL   r
  )rZ   rd   rn   r  s   ``` rF   release_session_turn_leasez$SessionDB.release_session_turn_lease.  sX     	 	F	 	 	 	 	 	 	 	C     rL   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 >= ?rn   r   )rT  r:  r   rQ  r   rR  r;  )rZ   rd   r)  r   s       rF   get_compression_lock_holderz%SessionDB.get_compression_lock_holder=  s}    
  	4ikkj  7
 
 (**	 	
 ;4 *3 < <Hs8}}#a&HrL   )description
provenancer  r1  r2  c                   	
 sdS ddl m}m} t          ||nt	          j                              
 ||           ||          j        		
fd}|                     || j                   dS )a-  Stamp durable mid-turn session activity (observation-only).

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

        Never moves ``last_activity_at`` backwards. When the timestamp
        advances, bounded ``last_activity_description`` /
        ``last_activity_provenance`` are written with it. No-ops when
        ``session_id`` is empty or the row does not exist.
        Nr   )bound_activity_descriptionnormalize_activity_provenancec                 >    |                      df           d S )NzUPDATE sessions SET last_activity_at = ?, last_activity_description = ?, last_activity_provenance = ? WHERE id = ? AND (last_activity_at IS NULL OR last_activity_at < ?)r)  )r   descprovrd   whens    rF   r  z-SessionDB.touch_session_activity.<locals>._dol  s9    LLV
 tT:t4    rL   r  )agent.session_activityr4  r5  r-  rT  rD   r  _ACTIVITY_WRITE_PATIENCE_S)rZ   rd   r  r1  r2  r4  r5  r  r7  r8  r9  s    `      @@@rF   touch_session_activityz SessionDB.touch_session_activityN  s    &  	F	
 	
 	
 	
 	
 	
 	
 	

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

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

        Response-critical-path contract (#76354 review S1): runs in the
        turn's ``finally``; a no-op clear (labels already empty) skips the
        write transaction entirely, and a real clear uses the same short
        sub-second busy budget as :meth:`touch_session_activity` instead of
        the full routine write patience.
        Nr   r   zUSELECT last_activity_description, last_activity_provenance FROM sessions WHERE id = ?last_activity_descriptionrq   last_activity_provenancec                 N    |                      ddj        j        f           d S )Nz\UPDATE sessions SET last_activity_description = ?, last_activity_provenance = ? WHERE id = ?rp   )r   UNKNOWNrD   )r   r   rd   s    rF   r  z4SessionDB.clear_session_activity_labels.<locals>._do  s;    LL '/5zB    rL   r  )r:  r   r:  r   rQ  rR  rr  r   r;  rA  rD   r  r;  )rZ   rd   r   r7  r8  r  r   s    `    @rF   clear_session_activity_labelsz'SessionDB.clear_session_activity_labels}  s9     	F======	*$$-  hjj	 C
 } 	 	 	CCC	?!+C!=!=c3q663GbCcD!+C!=!=b3q663GaCbD  $6$>$DDD	 	 	 	 	 	 	CD,KLLLLLs   .= AAc                     |sdS |                      |          }|sdS ddlm}  ||                    d          |                    d          |                    d                    S )z?Return the durable activity snapshot for *session_id*, or None.Nr   )build_activity_snapshotlast_activity_atr>  r?  )rE  r>  r?  )get_sessionr:  rD  r@   )rZ   rd   r   rD  s       rF   get_session_activityzSessionDB.get_session_activity  s     	4z** 	4BBBBBB&& WW%788&)gg.I&J&J%(WW-G%H%H
 
 
 	
rL   model_config_jsonc                 l    |                                   fd}|                     |           dS )a  Update model_config and optionally model for an existing session.

        Uses COALESCE so that passing model=None leaves the stored model
        column unchanged.  Routes through _execute_write for the standard
        BEGIN IMMEDIATE + jitter-retry + lock guarantee.
        c                 :    |                      df           d S )NzMUPDATE sessions SET model_config = ?, model = COALESCE(?, model) WHERE id = ?r)  )r   r  rH  rd   s    rF   r  z*SessionDB.update_session_meta.<locals>._do  s0    LL_"E:6    rL   NrD  r  )rZ   rd   rH  r  r  s    ``` rF   update_session_metazSessionDB.update_session_meta  sX     	!!!	 	 	 	 	 	 	
 	C     rL   c                 D      fd}                      |           dS )z0Store the full assembled system prompt snapshot.c                                          |           }|                     d|f                               |            d S )NzMUPDATE sessions SET system_prompt_hash = ?, system_prompt = NULL WHERE id = ?)r'  r   r*  )r   r  rZ   rd   rf   s     rF   r  z+SessionDB.update_system_prompt.<locals>._do  sY    !%!:!:4!O!OLLP#Z0  
 44T:::::rL   Nr
  )rZ   rd   rf   r  s   ``` rF   update_system_promptzSessionDB.update_system_prompt  sD    	; 	; 	; 	; 	; 	; 	; 	C     rL   providerc                 p                                         fd}                     |           dS )a  Update the model for a session after a mid-session switch.

        Unlike ``update_token_counts`` which uses ``COALESCE(model, ?)``
        (only filling in NULL), this unconditionally sets the model column
        so that the dashboard reflects the user's latest /model choice.
        Also nulls ``system_prompt`` so stale ``Model:`` / ``Provider:``
        footer metadata is rebuilt on the next turn. A successful /model
        switch explicitly replaces any confirmed Browser runtime lock while
        preserving unrelated lineage markers in ``model_config``.

        When *provider* is given, it is merged into ``model_config``
        alongside the model (``$.model`` / ``$.provider``) so a later
        resume recombines the persisted model with the provider that
        actually serves it instead of the config.yaml primary provider
        (#79536). Callers without provider knowledge leave any stored
        provider untouched.
        c                     dd i}r|d<   r|d<                        | |          }|t          u rd S |                     d|f                               |            d S )Nbrowser_model_lockr  rP  zmUPDATE sessions SET model = ?, model_config = ?, system_prompt = NULL, system_prompt_hash = NULL WHERE id = ?_merge_model_config_json_MODEL_CONFIG_ROW_MISSINGr   r*  )r   patchmergedr  rP  rZ   rd   s      rF   r  z+SessionDB.update_session_model.<locals>._do  s    
 &:4$@E '!&g -$,j!224UKKF222LL 
+   44T:::::rL   NrK  )rZ   rd   r  rP  r  s   ```` rF   update_session_modelzSessionDB.update_session_model  s^    4 	!!!	; 	; 	; 	; 	; 	; 	; 	;* 	C     rL   skip
on_missingrW  r\  c                   |                     d|f                                          }||dk    rt          d|           t          S t	          |t
          j                  r|d         n|d         }i }t	          |t                    r_|                                rK	 t          j
        |          }t	          |t                    r|}nB# t          j        t          f$ r i }Y n(w xY wt	          |t                    rt          |          }|                                D ]#\  }	}
|
|                    |	d           |
||	<   $|rt          j        |          ndS )u=  SELECT + tolerant-parse + merge ``patch`` into a session's model_config.

        Shared by every model_config writer (``update_session_runtime_lock``,
        ``set_session_yolo``, ``archive_and_compact``,
        ``patch_session_model_config``) so the merge discipline that keeps
        lineage markers like ``_branched_from`` / ``_delegate_from`` alive
        lives in exactly one place. A ``None`` patch value deletes that key.
        Must run inside an open write transaction (callers own the UPDATE).

        Returns the serialized merged JSON — ``None`` when the merged dict is
        empty (matching ``create_session``'s NULL convention) — or the
        ``_MODEL_CONFIG_ROW_MISSING`` sentinel when the row doesn't exist and
        ``on_missing == "skip"``; ``on_missing == "raise"`` raises ValueError.
        .SELECT model_config FROM sessions WHERE id = ?NraisezSession not found: r   r   )r   rQ  rw   rV  r   rR  r;  r_   r   r  r  r,  JSONDecodeErrorrv   r  r-  r(  )rZ   r   rd   rW  r\  r   rz  ry  r  r8   rD   s              rF   rU  z"SessionDB._merge_model_config_json  su   , ll<M
 
 (** 	 ;W$$ !Cz!C!CDDD,,%/W[%A%AMc.!!s1v!#c3 	CIIKK 	Cfd++ $#F()4   T"" 	#YYF++-- 	$ 	$JC}

3%%%%#s%+5tz&!!!5s   !+C C('C(c                 P     rsdS  fd}                      |           dS )a  Merge ``patch`` into a session's model_config JSON atomically.

        A ``None`` patch value removes that key. No-op when the session row
        doesn't exist or the patch is empty. This is the standalone setter for
        callers that need to update model_config *without* rewriting the
        transcript (the transcript-coupled path is ``archive_and_compact``'s
        ``model_config_patch``, which shares the same merge helper).
        Nc                 |                         |           }|t          u rd S |                     d|f           d S )N1UPDATE sessions SET model_config = ? WHERE id = ?)rU  rV  r   )r   rX  rW  rZ   rd   s     rF   r  z1SessionDB.patch_session_model_config.<locals>._doQ  sV    224UKKF222LLC$    rL   r
  )rZ   rd   rW  r  s   ``` rF   patch_session_model_configz$SessionDB.patch_session_model_configC  sZ      	 	F	 	 	 	 	 	 	 	C     rL   r8   r  c                    |                      |          pi }|                    d          }i }t          |t                    r_|                                rK	 t          j        |          }t          |t                    r|}n5# t
          j        t          f$ r i }Y nw xY wt          |t                    r|}|                    ||          S )zCRead one key out of a session's model_config JSON (tolerant parse).r   )
rF  r@   r   r_   r   r  r  r,  r`  rv   )rZ   rd   r8   r  sessionrz  ry  r  s           rF   get_session_model_config_valuez(SessionDB.get_session_model_config_value\  s     "":..4"kk.))!#c3 	CIIKK 	Cfd++ $#F()4   T"" 	Fzz#w'''s   +B B B )r  rP  model_optionsroute_source	confirmedrh  ri  rj  c                     |pdpd|pi |pdt          |          t          j                    d fd}                     |           dS )aK  Persist a Browser / API client runtime lock without clobbering lineage markers.

        Merges ``browser_model_lock`` into the existing ``model_config`` JSON so
        ``_branched_from`` / ``_delegate_from`` survive. Nulls ``system_prompt``
        so cached ``Model:`` / ``Provider:`` footers cannot lie after a switch.
        rp   )rP  r  rh  ri  rj  
updated_atc                                          | di          }|t          u rd S |                     d|f                               |            d S )NrS  zUPDATE sessions SET
                   model_config = ?,
                   model = COALESCE(?, model),
                   system_prompt = NULL,
                   system_prompt_hash = NULL
                   WHERE id = ?rT  )r   rX  lockr  rZ   rd   s     rF   r  z2SessionDB.update_session_runtime_lock.<locals>._do  s{    22j#7"> F 222LL# 
+   44T:::::rL   N)r   rT  r  )	rZ   rd   r  rP  rh  ri  rj  r  rn  s	   ```     @rF   update_session_runtime_lockz%SessionDB.update_session_runtime_lockn  s    " !B[b*0b(.Bi)++
 
	; 	; 	; 	; 	; 	; 	; 	;  	C     rL   enabledc                 L     sdS  fd}                      |           dS )uY  Persist the per-session YOLO bypass flag into ``model_config``.

        Merges ``yolo_mode`` into the existing ``model_config`` JSON (same
        merge discipline as ``update_session_runtime_lock`` so lineage
        markers like ``_branched_from`` / ``_delegate_from`` survive). The
        CLI resume paths read this flag back so a ``/yolo ON`` toggle — or a
        ``--yolo`` launch — survives ``hermes --resume`` into a fresh
        process. No-op when the session row doesn't exist yet; the
        creation-time ``model_config`` carries the flag for ``--yolo``
        launches.
        Nc                                          | dt                    i          }|t          u rd S |                     d|f           d S )N	yolo_moderc  )rU  r   rV  r   )r   rX  rp  rZ   rd   s     rF   r  z'SessionDB.set_session_yolo.<locals>._do  sh    22j;W"> F 222LLC$    rL   r
  )rZ   rd   rp  r  s   ``` rF   set_session_yolozSessionDB.set_session_yolo  sS      	F		 		 		 		 		 		 		 	C     rL   session_metac                    | pi                      d          }t          |t                    r'	 t          j        |          }n# t
          $ r Y dS w xY wt          |t                    sdS t          |                     d                    S )u  Read the persisted YOLO flag off a session row dict.

        Accepts the dict returned by ``get_session`` (``model_config`` is a
        JSON string) or an already-parsed dict. Returns False on any parse
        failure — resume must never enable the bypass by accident.
        r   Frs  )r@   r   r_   r  r  rB   r,  r   )ru  rz  s     rF   session_yolo_enabledzSessionDB.session_yolo_enabled  s     !r&&~66c3 	joo   uu#t$$ 	5CGGK(()))s   A 
AAc                    | pi                      d          t          t                    r(	 t          j                  n# t
          $ r i Y nw xY wt          t                    si                      d          }t          |t                    r3|                     d          rd |                                D             S fddD             }|r|S t          | pi                      d          pd                                          }|r|	                                t          vrd|iS t          |t                    r d	 |pi                                 D             ni S )
u>  Read the persisted runtime route off a session row dict.

        Accepts the dict returned by ``get_session`` (``model_config`` is a
        JSON string) or an already-parsed dict. Prefers the nested
        ``gateway_runtime`` key (written by the gateway's
        ``_sync_session_model_from_agent`` and the CLI ``/model`` persist),
        falling back to the top-level ``provider``/``base_url``/``api_mode``
        keys the TUI gateway's ``_runtime_model_config`` writes. As a last
        resort, falls back to the ``billing_provider`` column (written on
        every session's first accounted API call) so sessions that never ran
        ``/model`` still restore the provider that actually served them.
        Returns an empty dict on any parse failure — resume falls back to
        ambient config resolution.
        r   gateway_runtimerP  c                     i | ]
\  }}|||S r  rK   r   r  r  s      rF   r  z5SessionDB.session_gateway_runtime.<locals>.<dictcomp>  s    FFFTQAqrL   c                 f    i | ]-}                     |          |                     |          .S rK   r@   )r   r8   rz  s     rF   r  z5SessionDB.session_gateway_runtime.<locals>.<dictcomp>  sE     
 
 
wws||

 
 
rL   )rP  base_urlapi_modebilling_providerrp   c                     i | ]
\  }}|||S r  rK   r{  s      rF   r  z5SessionDB.session_gateway_runtime.<locals>.<dictcomp>  s    JJJAAM1MMMrL   )r@   r   r_   r  r  rB   r,  r  r   r   _BARE_BILLING_PROVIDERS)ru  runtime	top_levelr  rz  s       @rF   session_gateway_runtimez!SessionDB.session_gateway_runtime  s     !r&&~66c3 	joo   #t$$ 	C''+,,gt$$ 	GZ)@)@ 	G GFW]]__FFFF
 
 
 
;
 
 
	
  	 R$$%788>B
 

%'' 	 	2 &&((0GGG 011NXY`bfNgNgoJJ'-R!6!6!8!8JJJJmoos   A AA)billing_moder~  r  c                t                                         fd}                     |           dS )u  Unconditionally update the billing provider/base_url for a session.

        Unlike ``update_token_counts`` which uses ``COALESCE(billing_provider, ?)``
        (only filling in NULL), this unconditionally sets the billing fields so
        that the dashboard reflects the user's latest /model switch.

        Also nulls ``system_prompt`` so the cached snapshot (which embeds a
        stale ``Model:`` / ``Provider:`` header) is rebuilt — matching the
        behavior of ``update_session_model`` (see #48173, #48248).
        c                 f    |                      df                               |            d S )Na  UPDATE sessions SET
                   billing_provider = ?,
                   billing_base_url = ?,
                   billing_mode = COALESCE(?, billing_mode),
                   system_prompt = NULL,
                   system_prompt_hash = NULL
                   WHERE id = ?)r   r*  )r   r~  r  rP  rZ   rd   s    rF   r  z3SessionDB.update_session_billing_route.<locals>._do  sH    LL# 8\:>	 	 	 44T:::::rL   NrK  )rZ   rd   rP  r~  r  r  s   ````` rF   update_session_billing_routez&SessionDB.update_session_billing_route  sd    & 	!!!	; 	; 	; 	; 	; 	; 	; 	; 	; 	C     rL   )input_tokensoutput_tokenscache_read_tokenscache_write_tokensreasoning_tokensapi_call_count)estimated_cost_usdactual_cost_usd)r  cost_statuscost_sourcepricing_versionr  billing_base_urlr  c                 8   | j         5  | j        }| j        o|du p|                                 }|s| j                            ||f           ||                                sst          j        | j        dd          }|| _        |	                                 | j
        5t          j        |           dfd}|| _
        t          j        |           | j                                          ddd           n# 1 swxY w Y   |r | j        |fi | dS dS )u  Enqueue a token/cost delta for the background writer.

        Accepts the same keyword arguments as :meth:`update_token_counts`
        and applies them asynchronously with identical semantics.  Cheap
        (append + notify) — safe to call on the turn thread after every
        API call.  After close() has stopped the writer, falls back to the
        synchronous path and may raise like :meth:`update_token_counts`.
        Nzsession-db-token-writerT)r  r|   daemonr:   c                  L                 } | |                                   d S d S r  )_drain_token_queue_at_exit)dbself_refs    rF   _drain_at_exitz4SessionDB.queue_token_counts.<locals>._drain_at_exit\  s2    !)B!~ " = = ? ? ? ? ?  .~rL   r:   N)rd  re  rf  is_aliverb  r  rL  Thread_token_writer_loopstartrh  weakrefrefr  register
notify_allupdate_token_counts)rZ   rd   r  threadwriter_stoppedr  r  s         @rF   queue_token_countszSessionDB.queue_token_counts9  s    # !	4 !	4.F!4 $7foo&7&7"7  " 4!((*f)=>>>>):):> '-#66#  F
 17D-LLNNN.6#*;t#4#4@ @ @ @ @ @
 3A/777&11333C!	4 !	4 !	4 !	4 !	4 !	4 !	4 !	4 !	4 !	4 !	4 !	4 !	4 !	4 !	4D  	; %D$Z::6:::::	; 	;s   C(C==DDr  c                 >   | j         s	| j        sdS d}| j        5  t          j                    |z   }| j         s| j        r| j        }||                                s<| j        s5d| _        t          | j                   }| j                                          nR|t          j                    z
  }|dk    r	 ddd           dS | j        	                    |           | j         | j        ddd           n# 1 swxY w Y   |r	 | 
                    |           | j        5  d| _        | j                                         ddd           n# 1 swxY w Y   nD# | j        5  d| _        | j                                         ddd           w # 1 swxY w Y   w xY wdS )uO  Block until every queued token delta has been applied.

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

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

1a 0 06::a3C3C CF1II6 C CA"JJqMME( &,ZZ]]%9cU$Bq	C sJV=>>>>33F3333rL         $@join_timeoutc                    | j         5  d| _        | j                                          | j        }ddd           n# 1 swxY w Y   |n|                                rZ|                    |           |                                r0t                              d|t          | j	                             dS | j         5  t          j                    |z   }| j        ry|t          j                    z
  }|dk    r<t                              d|t          | j	                             	 ddd           dS | j                             |           | j        yt          | j	                  }|r d| _        | j	                                         ddd           n# 1 swxY w Y   |r	 |                     |           | j         5  d| _        | j                                          ddd           dS # 1 swxY w Y   dS # | j         5  d| _        | j                                          ddd           w # 1 swxY w Y   w xY wdS )z@Stop the writer thread and drain remaining deltas. Never raises.TN)r  zZasync token accounting: writer did not stop within %.0fs; %d queued delta(s) not persistedr   zfasync token accounting: concurrent drain did not finish within %.0fs; %d queued delta(s) not persistedF)rd  rf  r  re  r  r   r=  r  r   rb  rT  r  rg  r  r   r  r  )rZ   r  r  r  r&  r  s         rF   r  zSessionDB._stop_token_writer  s~   # 	/ 	/&*D#"--///.F	/ 	/ 	/ 	/ 	/ 	/ 	/ 	/ 	/ 	/ 	/ 	/ 	/ 	/ 	/ &//"3"3KKK---   	 7 #d&7"8"8  
  # 	* 	*~'',6H) 	7$t~'7'77	>>NNP$c$*;&<&<  
 	* 	* 	* 	* 	* 	* 	* 	* &++I666 ) 	7  *++E **.'!'')))+	* 	* 	* 	* 	* 	* 	* 	* 	* 	* 	* 	* 	* 	* 	*,  	88''...+ 8 8.3D+*557778 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8T+ 8 8.3D+*557778 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8		8 	8sk   (<A A >A)F4AFFF%G< !G//G36G3<H=!H1%H=1H55H=8H59H=c                 R    	 |                                   d S # t          $ r Y d S w xY wr  )r  rB   r@  s    rF   r  z$SessionDB._drain_token_queue_at_exit+  sA    	##%%%%% 	 	 	DD	s    
&&r   r  r  r  r  r  r  r  r  r  r  r  r  r  r  c                 h   	
                       d           |rdndt          ppp	pppp	          		
|rndrndrndrndf| opp	pppp	
 fd}                     |           dS )u  Update token counters and backfill model if not already set.

        When *absolute* is False (default), values are **incremented** — use
        this for per-API-call deltas (CLI path).

        When *absolute* is True, values are **set directly** — use this when
        the caller already holds cumulative totals (gateway path, where the
        cached agent accumulates across messages).
        r  )r  a  UPDATE sessions SET
                   input_tokens = ?,
                   output_tokens = ?,
                   cache_read_tokens = ?,
                   cache_write_tokens = ?,
                   reasoning_tokens = ?,
                   estimated_cost_usd = COALESCE(?, 0),
                   actual_cost_usd = CASE
                       WHEN ? IS NULL THEN actual_cost_usd
                       ELSE ?
                   END,
                   cost_status = COALESCE(?, cost_status),
                   cost_source = COALESCE(?, cost_source),
                   pricing_version = COALESCE(?, pricing_version),
                   billing_provider = COALESCE(billing_provider, ?),
                   billing_base_url = COALESCE(billing_base_url, ?),
                   billing_mode = COALESCE(billing_mode, ?),
                   model = COALESCE(model, ?),
                   api_call_count = ?
                   WHERE id = ?a^  UPDATE sessions SET
                   input_tokens = input_tokens + ?,
                   output_tokens = output_tokens + ?,
                   cache_read_tokens = cache_read_tokens + ?,
                   cache_write_tokens = cache_write_tokens + ?,
                   reasoning_tokens = reasoning_tokens + ?,
                   estimated_cost_usd = COALESCE(estimated_cost_usd, 0) + COALESCE(?, 0),
                   actual_cost_usd = CASE
                       WHEN ? IS NULL THEN actual_cost_usd
                       ELSE COALESCE(actual_cost_usd, 0) + ?
                   END,
                   cost_status = COALESCE(?, cost_status),
                   cost_source = COALESCE(?, cost_source),
                   pricing_version = COALESCE(?, pricing_version),
                   billing_provider = COALESCE(billing_provider, ?),
                   billing_base_url = COALESCE(billing_base_url, ?),
                   billing_mode = COALESCE(billing_mode, ?),
                   model = COALESCE(model, ?),
                   api_call_count = COALESCE(api_call_count, 0) + ?
                   WHERE id = ?Nc                    |                      df                                          }||d         nd }||d         nd }t          ||d         ndpd          }|dk    o+o)t                    ot          
          o|k    p|
k    }|r|                      d
	f           |                                 r'                    | 
	           d S d S )NzISELECT model, billing_provider, api_call_count FROM sessions WHERE id = ?r  r  r  r   zUPDATE sessions
                       SET model = ?, billing_provider = ?,
                       billing_base_url = ?, billing_mode = ?
                       WHERE id = ?)r  r  r  r  r  r  r  r  r  r  r  r  r  r  )r   rQ  rA   r   _record_model_usage)r   r   existing_modelexisting_providerexisting_api_callsfirst_accounted_router  r  r  r  r  r  r  r  r  r  has_accounted_usager  r  r  rF  r  record_model_usagerZ   rd   r  s         rF   r  z*SessionDB.update_token_counts.<locals>._do  s   ,,[  hjj  .1_S\\$N;>?$6 7 7PT!$sc*:&;&;TU%[Z[!\!\ #a' W'WKKW )**W $u,U0AEU0U " % ' ,.>jY   LLf%%%! ((%5%5!-!-"/&7'9%5'9$3 + +#1! )      rL   )r  r   r  )rZ   rd   r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  rF  r  r  s   ```````````` ````  @@@@rF   r  zSessionDB.update_token_counts1  s   B 	  Ye DDD )	##CC*#C( # 5M 5-> 5!5%559G5!5%4
 
  3= 3=/9LLT(2EEd#
@ #+l 
 "M "-> "!"%5"9G"! 	/	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	` 	C     rL   )taskr  c                   |                     d|f                                          }||d         nd}||d         nd}||d         nd}||d         nd}|r|pd}|pd}|pd}|pd}n|p|pd}|p|pd}|p|pd}|p|pd}t          j                    }|                     d	||||||pd|pd
|pd
|pd
|	pd
|
pd
|pd
t          |pd          t          |pd          ||||f           dS )u  Accumulate a per-API-call usage delta into session_model_usage.

        Runs inside the caller's write transaction (after the ``sessions``
        UPDATE) so the per-model rows stay consistent with the summary row.
        When the caller omits the model/provider (some paths only pass token
        deltas), fall back to the values already recorded on the session row —
        the same COALESCE-from-session behaviour the summary update uses.

        ``task`` distinguishes what kind of work consumed the tokens:
        ``''`` (empty) is the main agent loop; auxiliary calls record their
        task name (``vision``, ``compression``, ``title_generation``, ...)
        via :meth:`record_auxiliary_usage` (issue #23270).
        zYSELECT model, billing_provider, billing_base_url, billing_mode FROM sessions WHERE id = ?Nr  r  r  r  r  rp   a  INSERT INTO session_model_usage (
                   session_id, model, billing_provider, billing_base_url, billing_mode,
                   task, api_call_count, input_tokens, output_tokens,
                   cache_read_tokens, cache_write_tokens, reasoning_tokens,
                   estimated_cost_usd, actual_cost_usd, cost_status, cost_source,
                   first_seen, last_seen
               ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
               ON CONFLICT(session_id, model, billing_provider, billing_base_url, billing_mode, task)
               DO UPDATE SET
                   api_call_count = api_call_count + excluded.api_call_count,
                   input_tokens = input_tokens + excluded.input_tokens,
                   output_tokens = output_tokens + excluded.output_tokens,
                   cache_read_tokens = cache_read_tokens + excluded.cache_read_tokens,
                   cache_write_tokens = cache_write_tokens + excluded.cache_write_tokens,
                   reasoning_tokens = reasoning_tokens + excluded.reasoning_tokens,
                   estimated_cost_usd = estimated_cost_usd + excluded.estimated_cost_usd,
                   actual_cost_usd = actual_cost_usd + excluded.actual_cost_usd,
                   cost_status = COALESCE(excluded.cost_status, cost_status),
                   cost_source = COALESCE(excluded.cost_source, cost_source),
                   last_seen = excluded.last_seenr   r5  )r   rQ  rT  r-  )rZ   r   rd   r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r   
sess_modelsess_providersess_base_urlsess_billing_mode	eff_modeleff_providereff_base_urleff_billing_moder)  s                               rF   r  zSessionDB._record_model_usage  s   D ll)M
 
 (**	 	
 &)_S\\$
36?.//36?.//36?C//  		G*I+1rL+1rL+1r88yI+B}BL+B}BL+F/@FBikk5*  
#!!"!&Q"'a %A(/C00o,--%+)	
 )	
 )	
 )	
 )	
rL   r  c                 (     | j         ||fd|i| |S )zHEnsure a session row exists (INSERT OR IGNORE). Accepts optional kwargs.r  r  )rZ   rd   r  r  r  s        rF   ensure_sessionzSessionDB.ensure_session@  s,     	! VKK5KFKKKrL   rq   )
r  r  r  r  r  r  r  r  r  r  c       
             	
 rsdS                       d           	
 fd}                     |           dS )u@  Record an auxiliary LLM call's usage against *session_id* (issue #23270).

        Auxiliary calls (vision, compression, title_generation, web_extract,
        session_search, ...) historically discarded their usage, leaving the
        dashboard's per-model analytics blind to aux model spend. This writes
        a per-(model, provider, task) delta into ``session_model_usage`` —
        the same table the main loop's ``update_token_counts`` feeds — WITHOUT
        touching the ``sessions`` summary row. That separation is deliberate:
        the gateway overwrites session counters with absolute main-loop totals,
        so folding aux tokens into the summary row would either be clobbered
        or double-counted. Insights/analytics read the union of both.

        ``api_call_count`` defaults to 1 (one aux LLM call). Background-review
        forks record an aggregate of N fork API calls in one write with
        ``task='background_review'`` (issue #87250).

        Best-effort by contract: callers must never fail an aux call because
        accounting failed.
        Nr  c                                          | d pd	pdpdpd
pdd d d dnt                               d S )Nr   rq   )r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  )r  rA   )r   r  r  r  r  r  r  r  r  r  r  rZ   rd   r  s    rF   r  z-SessionDB.record_auxiliary_usage.<locals>._dou  s    $$!1!1!).Q+0q"3"8q#5#:!1!6Q#5 $  '/AAS5H5H' %     rL   )r  r  )rZ   rd   r  r  r  r  r  r  r  r  r  r  r  r  s   ````````````` rF   record_auxiliary_usagez SessionDB.record_auxiliary_usageK  s    F  	 	F 	  Y777	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	, 	C     rL   zOptional[Path]c                      t          j                     dz
   fd}                     |          pg }|r|r|D ]}                     ||           t          |          S )zCRemove empty TUI ghost sessions (no messages, no title, >24hr old).Q c                    |                      df                                          }d |D             }|rTd                    dt          |          z            }|                      d| d|                               |            |S )NaZ  
                SELECT id FROM sessions
                WHERE source = 'tui'
                  AND title IS NULL
                  AND ended_at IS NOT NULL
                  AND started_at < ?
                  AND NOT EXISTS (
                      SELECT 1 FROM messages WHERE messages.session_id = sessions.id
                  )
            c                 f    g | ].}t          |t          t          f          r|d          n|d         /S )r   r   )r   r  r   rr  s     rF   r   zESessionDB.prune_empty_ghost_sessions.<locals>._do.<locals>.<listcomp>  s7    SSS:a%77D1Q44QtWSSSrL   r   r   r   r   )r   r   r   r   r*  )r   r  r   r  r.  rZ   s       rF   r  z1SessionDB.prune_empty_ghost_sessions.<locals>._do  s    << 	! 	 	 %HJJ  TSdSSSC ?"xxc#hh77HHHH#   88>>>JrL   )rT  r  _remove_session_filesr   )rZ   r7  r  removed_idsr   r.  s   `    @rF   prune_empty_ghost_sessionsz$SessionDB.prune_empty_ghost_sessions  s    u$	 	 	 	 	 	( ))#..4" 	>K 	>" > >**<====;rL   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
                  )
                )rT  r   r~  )r   r)  r  r.  s      rF   r  z=SessionDB.finalize_orphaned_compression_sessions.<locals>._do  s9    )++C\\( f+ F. ?"rL   r   r  )rZ   r  r.  s     @rF   &finalize_orphaned_compression_sessionsz0SessionDB.finalize_orphaned_compression_sessions  sI     v%	# 	# 	# 	# 	#6 ""3'',1,rL   c                 
   |                                   |                                 5 }|                    d|f          }|                                }ddd           n# 1 swxY w Y   |r|                     |          ndS )zGet a session by ID.zSELECT s.*, COALESCE(sp.prompt, s.system_prompt) AS _system_prompt_resolved FROM sessions s LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash WHERE s.id = ?N)rD  r  r   rQ  r/  )rZ   rd   r   r   r   s        rF   rF  zSessionDB.get_session  s    
 	!!!^^ 		$\\!
  F //##C		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ /2;t%%c***t;s   ,A!!A%(A%c                     |                                   |                                 5 }|                    d|f                                          }ddd           n# 1 swxY w Y   |rt	          |          ndS )at  Return the main-loop model route that served most API calls.

        ``sessions`` is a legacy aggregate row and can hold model/provider fields
        written by different route changes. ``session_model_usage`` keeps the
        coherent per-call tuple, so persisted status and billing reads should use
        its dominant main-loop route when one is available.
        aW  SELECT model, billing_provider, billing_base_url, billing_mode,
                          api_call_count
                     FROM session_model_usage
                    WHERE session_id = ?
                      AND task = ''
                      AND model <> 'unknown'
                      AND billing_provider <> ''
                    ORDER BY api_call_count DESC,
                             (input_tokens + output_tokens + cache_read_tokens +
                              cache_write_tokens + reasoning_tokens) DESC,
                             last_seen DESC
                    LIMIT 1N)rD  r  r   rQ  r,  rZ   rd   r   r   s       rF    get_dominant_session_model_routez*SessionDB.get_dominant_session_model_route  s     	!!!^^ 	,,   hjj 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	   )tCyyyT)s   *AA#&A#session_id_or_prefixc                 P   |                      |          }|r|d         S t          |          }| 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   zSSELECT id FROM sessions WHERE id LIKE ? ESCAPE '\' ORDER BY started_at DESC LIMIT 2r  c                     g | ]
}|d          S r   rK   r  s     rF   r   z0SessionDB.resolve_session_id.<locals>.<listcomp>  s    >>>Ss4y>>>rL   Nrq   r   )rF  r   rN  r:  r   r   r   )rZ   r  rM  escapedr   matchess         rF   resolve_session_idzSessionDB.resolve_session_id  s       !566 	;344Z 	? 	?Z''f  F ?>FOO,=,=>>>G	? 	? 	? 	? 	? 	? 	? 	? 	? 	? 	? 	? 	? 	? 	? w<<11:ts   >B  BBr  derivedllmr$  r  c                 z    || j         | j                 S | j                             t          |          d          S )u  Rank a stored title_source. NULL means a pre-provenance row.

        Rows written before this column existed carry NULL. They were almost
        always set by the old auto-titler, but a manual ``/title`` from that
        era is indistinguishable — so treat NULL as ``user`` and refuse to
        overwrite it. Auto-titling only ever fills genuinely empty titles on
        legacy rows, which is the conservative direction.
        Nr   )_TITLE_SOURCE_RANKTITLE_SOURCE_USERr@   r_   )clsr  s     rF   _title_rankzSessionDB._title_rank(  s9     >)#*?@@%))#f++q999rL   titlec                 p   | sdS t          |           } t          j        dd|           }t          j        dd|          }t          j        dd|                                          }|sdS t	          |          t
          j        k    r-t          dt	          |           dt
          j         d	          |S )
a  Validate and sanitize a session title.

        - Strips leading/trailing whitespace
        - Removes ASCII control characters (0x00-0x1F, 0x7F) and problematic
          Unicode control chars (zero-width, RTL/LTR overrides, etc.)
        - Collapses internal whitespace runs to single spaces
        - Normalizes empty/whitespace-only strings to None
        - Enforces MAX_TITLE_LENGTH

        Returns the cleaned title string or None.
        Raises ValueError if the title exceeds MAX_TITLE_LENGTH after cleaning.
        Nz [\x00-\x08\x0b\x0c\x0e-\x1f\x7f]rp   zB[\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff\ufffc\ufff9-\ufffb]z\s+r^  zTitle too long (z chars, max r   )r	   resubr   r   r  MAX_TITLE_LENGTHrw   )r  cleaneds     rF   sanitize_titlezSessionDB.sanitize_title6  s      	4 %U++
 &<b%HH &Q
 
 &g..4466 	4w<<)444Z3w<<ZZY=WZZZ   rL   ancestor_iddescendant_idc                    |r|r||k    rdS t          j        d          }|                    d| d|||f                                          }|duS )u  Return True if *ancestor_id* is a compression predecessor of
        *descendant_id* (walking parent links up the continuation chain).

        The continuation edge is the canonical one shared with
        :func:`_ephemeral_child_sql` / :meth:`set_session_archived`
        (``_COMPRESSION_CHILD_SQL``): a parent → child edge counts only when the
        parent ended with ``end_reason = 'compression'`` and the child started
        at or after the parent's ``ended_at``, which distinguishes continuations
        from delegate subagents / branch children that also carry a
        ``parent_session_id``. Expressed as a single recursive CTE rather than a
        per-hop Python walk so the edge definition lives in exactly one place.
        Fr  )aa9  
            WITH RECURSIVE ancestors(id) AS (
                SELECT ?
                UNION
                SELECT parent.id
                FROM ancestors a
                JOIN sessions child ON child.id = a.id
                JOIN sessions parent ON parent.id = child.parent_session_id
                WHERE z`
            )
            SELECT 1 FROM ancestors WHERE id = ? AND id != ? LIMIT 1
            N)r   rx  r   rQ  )rZ   r   r  r  edger   s         rF   _is_compression_ancestorz"SessionDB._is_compression_ancestorf  s      	- 	;-3O3O5 &,w777ll    K7
 
 (** 	 $rL   c                                                      j        k    s                               nd fd}                     |          }|dk    S )u  Write a title, enforcing provenance precedence.

        ``source`` is one of ``TITLE_SOURCE_{DERIVED,LLM,USER}``. A ``user``
        write always lands — an explicit rename is authoritative. An automatic
        write (``derived``/``llm``) lands only when the row is untitled or the
        stored title has strictly lower authority, so the instant ``derived``
        title upgrades to ``llm`` exactly once and neither can ever overwrite a
        name the user typed. Re-running the titler on an already-``llm`` row is
        a no-op, which is what stops a session renaming itself.

        The read and the write are one compare-and-swap inside a single
        transaction, so a manual ``/title`` racing an in-flight generation
        cannot be clobbered by the late arrival.
        Nc           	         |                      df                                          }|dS s)|d         !                    |d                   k    rdS 
r{|                      d
f          }|                                }|rM|d         }                    | |          r|                      d|f           nt	          d	
 d
|           |                      d

r	nd |d         |d         f          }|j        S )N5SELECT title, title_source FROM sessions WHERE id = ?r   r  title_sourcez3SELECT id FROM sessions WHERE title = ? AND id != ?r   )r  r  z-UPDATE sessions SET title = NULL WHERE id = ?zTitle 'z' is already in use by session zaUPDATE sessions SET title = ?, title_source = ? WHERE id = ? AND title IS ? AND title_source IS ?)r   rQ  r  r  rw   r~  )r   r  r   conflictconflict_idis_usernew_rankrZ   rd   r  r  s        rF   r  z)SessionDB._set_session_title.<locals>._do  su   llG  hjj  q ww/;##GN$;<<HH1 IJ'  "??,, "*4.K 44+Z 5   
 K(N   
 )YeYYKYY   \\D #-FFG$N+
 
F ?"rL   r   )r  r  r  r  )rZ   rd   r  r  r  r~  r
  r  s   ````  @@rF   _set_session_titlezSessionDB._set_session_title  s    * ##E**D223:D4##F+++8	# 8	# 8	# 8	# 8	# 8	# 8	# 8	# 8	# 8	#t &&s++!|rL   c                 <    |                      ||| j                  S )a  Set or update a session's title on the user's behalf.

        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).

        This records ``user`` provenance, so auto-titling will never replace
        the result. Automatic callers must use :meth:`set_auto_title`.
        r  )r  r  rZ   rd   r  s      rF   set_session_titlezSessionDB.set_session_title  s+     &&d&< ' 
 
 	
rL   c                v    || j         | j        fvrt          d|          |                     |||          S )zSet an automatically generated title, honoring provenance precedence.

        Returns True when the title was written, False when a higher-authority
        title already holds the row (nothing is modified in that case).
        z invalid automatic title source: r  )TITLE_SOURCE_DERIVEDTITLE_SOURCE_LLMrw   r  )rZ   rd   r  r  s       rF   set_auto_titlezSessionDB.set_auto_title  sL     $3T5JKKKJJJKKK&&z5&HHHrL   c                 <    |                      ||| j                  S )zBack-compat shim: set an LLM title only if nothing better exists.

        Retained because older callers (and third-party plugins) reference it
        by name. New code should call :meth:`set_auto_title` with an explicit
        source.
        r  )r  r  r  s      rF   set_auto_title_if_emptyz!SessionDB.set_auto_title_if_empty  s+     ""d&; # 
 
 	
rL   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  rN  r:  r   rQ  rZ   rd   r   r   s       rF   get_session_titlezSessionDB.get_session_title   s    Z 	$ 	$Z''9J= F //##C		$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$
  #,s7||,   1AA	A	c                     | j         5  | j                            d|f          }|                                }ddd           n# 1 swxY w Y   |r|d         dS |d         S )z?Get the provenance of a session's title, or None when untitled.r  Nr  r  r  r  s       rF   get_session_title_sourcez"SessionDB.get_session_title_source   s    Z 	$ 	$Z''G F //##C	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$  	c'l*4>""r  c                 z    | j         vrt          d          fd}|                     |          dk    S )a!  Overwrite a title's provenance without touching the title text.

        Used when a title is carried across a session boundary (compression
        rotation) and the copy must keep the original's authority rather than
        the authority of whichever setter performed the copy.
        zinvalid title source: c                 B    |                      df          }|j        S )NzGUPDATE sessions SET title_source = ? WHERE id = ? AND title IS NOT NULLr  )r   r   rd   r  s     rF   r  z/SessionDB.set_session_title_source.<locals>._do$   s-    \\5$ F
 ?"rL   r   )r  rw   r  )rZ   rd   r  r  s    `` rF   set_session_title_sourcez"SessionDB.set_session_title_source   sd     000@f@@AAA	# 	# 	# 	# 	# 	# ""3''!++rL   archivedc                 H    fd}|                      |          }|dk    S )u  Archive or unarchive a session.

        Archived sessions are hidden from the default session list but keep all
        their messages — this is a soft hide, not a delete. For compression
        chains, archive the whole logical conversation. Desktop lists compression
        roots projected forward to their latest continuation; updating only the
        displayed tip lets the still-unarchived root resurrect it on refresh.
        Returns True when at least one row was updated.
        c                     |                      drdndf          }|j        }||dk     r-|                      d                                          d         }|S )Na[  
                WITH RECURSIVE
                  ancestors(id) AS (
                    SELECT ?
                    UNION
                    SELECT parent.id
                    FROM ancestors a
                    JOIN sessions child ON child.id = a.id
                    JOIN sessions parent ON parent.id = child.parent_session_id
                    WHERE parent.end_reason = 'compression'
                  ),
                  descendants(id) AS (
                    SELECT ?
                    UNION
                    SELECT child.id
                    FROM descendants d
                    JOIN sessions parent ON parent.id = d.id
                    JOIN sessions child ON child.parent_session_id = parent.id
                    WHERE parent.end_reason = 'compression'
                  ),
                  lineage(id) AS (
                    SELECT id FROM ancestors
                    UNION
                    SELECT id FROM descendants
                  )
                UPDATE sessions
                SET archived = ?
                WHERE id IN (SELECT id FROM lineage)
                rq   r   SELECT changes()r   r~  rQ  )r   r   r~  r!  rd   s      rF   r  z+SessionDB.set_session_archived.<locals>._do8   sp    \\: Zh)=A>= F@ H8a<<<<(:;;DDFFqIOrL   r   r
  )rZ   rd   r!  r  r~  s    ``  rF   set_session_archivedzSessionDB.set_session_archived.   sB    $	 $	 $	 $	 $	 $	J &&s++!|rL   pinnedc                 H    fd}|                      |          }|dk    S )ul  Pin or unpin a session (and its whole compression lineage).

        ``pinned`` is a durable "keep" flag: pinned sessions are exempt from
        the ``sessions.auto_archive`` stale sweep (see
        :meth:`archive_stale_sessions`). Desktop is the current writer — its
        sidebar pins mirror here so a backend/other-surface sweep honours
        them. Like :meth:`set_session_archived` the whole compression chain is
        flipped as a unit, so pinning the surfaced tip protects the root (and
        vice-versa) no matter which id the caller holds. Returns True when at
        least one row changed.
        c                     |                      drdndf          }|j        }||dk     r-|                      d                                          d         }|S )NaY  
                WITH RECURSIVE
                  ancestors(id) AS (
                    SELECT ?
                    UNION
                    SELECT parent.id
                    FROM ancestors a
                    JOIN sessions child ON child.id = a.id
                    JOIN sessions parent ON parent.id = child.parent_session_id
                    WHERE parent.end_reason = 'compression'
                  ),
                  descendants(id) AS (
                    SELECT ?
                    UNION
                    SELECT child.id
                    FROM descendants d
                    JOIN sessions parent ON parent.id = d.id
                    JOIN sessions child ON child.parent_session_id = parent.id
                    WHERE parent.end_reason = 'compression'
                  ),
                  lineage(id) AS (
                    SELECT id FROM ancestors
                    UNION
                    SELECT id FROM descendants
                  )
                UPDATE sessions
                SET pinned = ?
                WHERE id IN (SELECT id FROM lineage)
                rq   r   r$  r%  )r   r   r~  r'  rd   s      rF   r  z)SessionDB.set_session_pinned.<locals>._dol   p    \\: Zf);!<= F@ H8a<<<<(:;;DDFFqIOrL   r   r
  )rZ   rd   r'  r  r~  s    ``  rF   set_session_pinnedzSessionDB.set_session_pinned`   sB    $	 $	 $	 $	 $	 $	J &&s++!|rL   hiddenc                 H    fd}|                      |          }|dk    S )u  Hide or unhide a session (and its whole compression lineage).

        ``hidden`` is a generic "don't show in the global Sessions sidebar"
        flag: a hidden session is dropped from the default
        :meth:`list_sessions_rich` listing (which omits ``include_hidden``) but
        stays fully resumable by the surface that owns it — useful for plugins
        that manage their own sessions (e.g. kanban) and don't want them
        cluttering the shared recents list. Like :meth:`set_session_archived`
        / :meth:`set_session_pinned` the whole compression chain is flipped as
        a unit, so hiding the surfaced tip hides the root (and vice-versa) no
        matter which id the caller holds. Returns True when at least one row
        changed.
        c                     |                      drdndf          }|j        }||dk     r-|                      d                                          d         }|S )NaY  
                WITH RECURSIVE
                  ancestors(id) AS (
                    SELECT ?
                    UNION
                    SELECT parent.id
                    FROM ancestors a
                    JOIN sessions child ON child.id = a.id
                    JOIN sessions parent ON parent.id = child.parent_session_id
                    WHERE parent.end_reason = 'compression'
                  ),
                  descendants(id) AS (
                    SELECT ?
                    UNION
                    SELECT child.id
                    FROM descendants d
                    JOIN sessions parent ON parent.id = d.id
                    JOIN sessions child ON child.parent_session_id = parent.id
                    WHERE parent.end_reason = 'compression'
                  ),
                  lineage(id) AS (
                    SELECT id FROM ancestors
                    UNION
                    SELECT id FROM descendants
                  )
                UPDATE sessions
                SET hidden = ?
                WHERE id IN (SELECT id FROM lineage)
                rq   r   r$  r%  )r   r   r~  r,  rd   s      rF   r  z)SessionDB.set_session_hidden.<locals>._do   r*  rL   r   r
  )rZ   rd   r,  r  r~  s    ``  rF   set_session_hiddenzSessionDB.set_session_hidden   sB    $	 $	 $	 $	 $	 $	J &&s++!|rL   readc                 H    fd}|                      |          }|dk    S )u  Mark a session read or unread (and its whole compression lineage).

        Read state is a watermark, not a flag: ``last_read_at`` records when
        the conversation was last read, and it counts as unread when activity
        postdates that watermark (the derived ``unread`` key on
        :meth:`list_sessions_rich` rows). New messages therefore flip a read
        conversation back to unread without any write on the message path.
        Three states:

        * NULL — never tracked (every pre-feature row): treated as read, so
          shipping the column doesn't badge a user's entire history at once.
        * 0 — explicitly marked unread: any activity postdates it.
        * timestamp — read up to that moment.

        Like :meth:`set_session_archived` / :meth:`set_session_pinned`, the
        whole compression chain is stamped as a unit, so reading the surfaced
        tip clears the root (and vice-versa) no matter which id the caller
        holds. Returns True when at least one row changed.
        c                     |                      drt          j                    ndf          }|j        }||dk     r-|                      d                                          d         }|S )Na_  
                WITH RECURSIVE
                  ancestors(id) AS (
                    SELECT ?
                    UNION
                    SELECT parent.id
                    FROM ancestors a
                    JOIN sessions child ON child.id = a.id
                    JOIN sessions parent ON parent.id = child.parent_session_id
                    WHERE parent.end_reason = 'compression'
                  ),
                  descendants(id) AS (
                    SELECT ?
                    UNION
                    SELECT child.id
                    FROM descendants d
                    JOIN sessions parent ON parent.id = d.id
                    JOIN sessions child ON child.parent_session_id = parent.id
                    WHERE parent.end_reason = 'compression'
                  ),
                  lineage(id) AS (
                    SELECT id FROM ancestors
                    UNION
                    SELECT id FROM descendants
                  )
                UPDATE sessions
                SET last_read_at = ?
                WHERE id IN (SELECT id FROM lineage)
                r5  r   r$  )r   rT  r~  rQ  )r   r   r~  r0  rd   s      rF   r  z'SessionDB.set_session_read.<locals>._do   sv    \\: Z)E#F= F@ H8a<<<<(:;;DDFFqIOrL   r   r
  )rZ   rd   r0  r  r~  s    ``  rF   set_session_readzSessionDB.set_session_read   sB    ($	 $	 $	 $	 $	 $	J &&s++!|rL   session_rowc                     |                      d          }|dS |                      d          p|                      d          }t          |pd          t          |          k    S )a
  Derive unread from a session row's watermark and activity.

        Shared by ``list_sessions_rich`` and any future surface that holds a
        row (or projected row) with ``last_read_at`` and ``last_active``.
        NULL watermark = never tracked = read.
        last_read_atNFrZ  rY  r   )r@   r-  )r4  	last_readrZ  s      rF   session_unreadzSessionDB.session_unread!  sb      OON33	5!oom44U8U8U[%A&&y)9)999rL   c                     |                                  5 }|                    d|f          }|                                }ddd           n# 1 swxY w Y   |r|                     |          ndS )z?Look up a session by exact title. Returns session dict or None.zSELECT s.*, COALESCE(sp.prompt, s.system_prompt) AS _system_prompt_resolved FROM sessions s LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash WHERE s.title = ?N)r  r   rQ  r/  )rZ   r  r   r   r   s        rF   get_session_by_titlezSessionDB.get_session_by_title!  s    ^^ 		$\\$
  F //##C		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ /2;t%%c***t;s   ,AAAc                 6   |                      |          }t          |          }|                                 5 }|                    d| df          }|                                }ddd           n# 1 swxY w Y   |r|d         d         S |r|d         S dS )ad  Resolve a title to a session ID, preferring the latest in a lineage.

        If the exact title exists, returns that session's ID.
        If not, searches for "title #N" variants and returns the latest one.
        If the exact title exists AND numbered variants exist, returns the
        latest numbered variant (the most recent continuation).
        zaSELECT id, title, started_at FROM sessions WHERE title LIKE ? ESCAPE '\' ORDER BY started_at DESC #%Nr   r   )r:  r   r  r   r   )rZ   r  rM  r  r   r   numbereds          rF   resolve_session_by_titlez"SessionDB.resolve_session_by_title"!  s     ))%00 u%%^^ 	)\\J" F
 ((H	) 	) 	) 	) 	) 	) 	) 	) 	) 	) 	) 	) 	) 	) 	)  	A;t$$ 	;ts   /A44A8;A8
base_titlec           	         t          j        d|          }|r|                    d          }n|}t          |          }| 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+)$rq   zESELECT title FROM sessions WHERE title = ? OR title LIKE ? ESCAPE '\'r<  c                     g | ]
}|d          S )r  rK   r  s     rF   r   z7SessionDB.get_next_title_in_lineage.<locals>.<listcomp>T!  s    BBBGBBBrL   Nz^.* #(\d+)$z #)
r  r   ru   r   rN  r:  r   r   r  rA   )
rZ   r?  r   r   r  r   r  max_numr  ms
             rF   get_next_title_in_lineagez#SessionDB.get_next_title_in_lineage?!  so    *J77 	;;q>>DDD 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   ?BBBc                 r   |}|r|hnt                      }t          d          D ]}| j        5  | j                            dt          d           d|f          }|                                }ddd           n# 1 swxY w Y   ||c S |d         }|r||v r|c S |                    |           |}|S )a  Walk the compression-continuation chain forward and return the tip.

        A compression continuation is a child of a session whose
        ``end_reason = 'compression'``.  Older builds tried to distinguish
        continuations from branches/subagents by requiring
        ``child.started_at >= parent.ended_at``.  That ordering is too brittle:
        gateway + compression races can insert the real continuation row before
        the parent row's ``ended_at`` is written, while a stale websocket later
        creates/reuses a sibling that *does* satisfy the timestamp test.  The
        visible symptom is brutal: desktop resume follows the stale sibling and
        the user's latest messages look "lost" even though they are persisted in
        the real continuation chain.

        Instead, only follow children of compression-ended parents, exclude
        explicit branch/delegate/tool children, and prefer children that are
        themselves continuing the compression chain (``end_reason='compression'``)
        or still live over stale closed siblings such as ``ws_orphan_reap``.
        Returns the latest continuation tip, or the input id when no
        continuation exists.
        r  a  
                    SELECT child.id
                    FROM sessions parent
                    JOIN sessions child ON child.parent_session_id = parent.id
                    WHERE parent.id = ?
                      AND parent.end_reason = 'compression'
                      AND json_extract(COALESCE(child.model_config, '{}'), '$._branched_from') IS NULL
                      AND json_extract(COALESCE(child.model_config, '{}'), '$._delegate_from') IS NULL
                      AND COALESCE(child.source, '') != 'tool'
                    ORDER BY
                      CASE
                        WHEN child.end_reason = 'compression' THEN 0
                        WHEN child.ended_at IS NULL THEN 1
                        ELSE 2
                      END,
                      r  z DESC,
                      child.started_at DESC,
                      child.id DESC
                    LIMIT 1
                    Nr   )r   rP  rN  r:  r   r"   rQ  r  )rZ   rd   r  r  r[  r   r   child_ids           rF   get_compression_tipzSessionDB.get_compression_tipb!  s3   * #.yy s  	  	A ( (++ 088  ( J+ . oo''1( ( ( ( ( ( ( ( ( ( ( ( ( ( (2 {4yH x4//HHXGGs   AA<<B 	B 	>   rf   r  r  _session_compact_cols_sql)min_message_countinclude_archivedrI  rJ  c                r   ddg}|g}|s|                     d           |                                 5 }|                    dd                    |           d|                                          }ddd           n# 1 swxY w Y   t          |d         pd          t          |d	         pd
          dS )u  Tokens and spend across this store, as one aggregate.

        The sidebar shows a profile's totals beside a page of its sessions, so
        summing the rows it happens to have loaded would report a fraction of
        the truth and shrink as paging changed. SQLite adds the columns up over
        every row instead, at the cost of one scan.

        Spend is the billed figure when the provider returned one and the
        estimate otherwise — the same precedence a single row renders.
        zparent_session_id IS NULLzmessage_count >= ?zCOALESCE(archived, 0) = 0z
                SELECT COALESCE(SUM(COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0)), 0),
                       COALESCE(SUM(COALESCE(actual_cost_usd, estimated_cost_usd, 0)), 0)
                  FROM sessions
                 WHERE  AND 
                Nr   rq   r5  )tokenscost_usd)r  r  r   r   rQ  rA   r-  )rZ   rI  rJ  whererF  r   r   s          rF   usage_totalszSessionDB.usage_totals!  s    -.BC./ 	6LL4555^^ 		,,  U++	     hjj 		 		 		 		 		 		 		 		 		 		 		 		 		 		 		 c!fk**c!fm8L8LMMMs   A A??BB   sourcesexclude_sourcesr   rE   offsetinclude_childrenproject_compression_tipsorder_by_last_activearchived_onlyid_querysearch_querycompact_rowsinclude_pinnedinclude_hiddenc                    |                                   g }g }|s?|                    t                     |                    t          d           d           |r|gnt	          |pg           }|rMd                    d |D                       }|                    d| d           |                    |           |r*|                    d           |                    |           |rMd                    d |D                       }|                    d	| d           |                    |           |r<t          |          \  }}|                    |           |                    |           |d
k    r*|                    d           |                    |           |r|                    d           n|s|                    d           |s|                    d           |rdd                    |           nd}t	          |          }|rdnd}|rdnd}|pd                                	                                }|pd                                	                                }|
rc|}g } g }!dt          dt          fd}"|r3|!                    d           |                      |"|                     |rt          j        dd|          }#d}$d}%|                      |"|          gdz             |#r:|%d|$                    d           dz  }%|                      |"|#                     |!                    |%dz              |!r#d                    |!          }&|r| d|& nd|& }|r|                                 nd }'d!| d"t          d#           d$|' | d%t            d&t#          d'           d(| d)| d*}(||z   | z   ||gz   }nU|r|                                 nd }'d+|' | d%t            d&t#          d'           d,| d)| d-}(|                    ||g           |                                 5 })|)                    |(|          }*|*                                }+d.d.d.           n# 1 swxY w Y   g },|+D ]h}-|                     |-          }.t-          |.                    d/d                    |.d0<   |.                    d1d.           |,                    |.           i|rd2 |,D             }/|r| d3nd4}0|r|                                 nd }'d+|' | d%t            d5| d)|0 d6
}1|                                 5 })|)                    |1|          }2|2                                }3d.d.d.           n# 1 swxY w Y   |3D ]x}-|                     |-          }.|.d7         |/v r"t-          |.                    d/d                    |.d0<   |/                    |.d7                    |,                    |.           y|	r(|s%i }4|,D ]N}.|.                    d8          d9k    r|                     |.d7                   }5|5|.d7         k    r|5|4|.d7         <   O|4r6|                     t9          |4                                          |:          ni }6g }7|,D ]}.|4                    |.d7                   }5|5r|6                    |5          nd.}8|8s|7                    |.           Nt=          |.          }9d;D ]}:|:|8v r|8|:         |9|:<   |.d7         |9d<<   |7                    |9           |7},|,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 (freshest of last_activity_at heartbeat and latest
        message timestamp, else started_at).

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

        By default, child sessions that represent implementation details
        (subagent runs, compression continuations) are excluded. User-visible
        branch and reset children remain listable. Pass ``include_children=True``
        to include every child.

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

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

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

        Pass ``compact_rows=True`` for dashboard and picker callers that only
        need lightweight metadata. This omits the ``system_prompt`` blob from
        the SELECT so SQLite never copies it out of the B-tree page — a
        significant I/O saving on large databases where the blob routinely
        runs to tens of kilobytes per row.

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

        Pass ``session_key`` to restrict results to one stable gateway
        conversation scope (DM, group, channel, or thread, including the
        configured per-user isolation policy).
        s.model_config IS NULLr   c              3      K   | ]}d V  dS r  rK   r  s     rF   r+  z/SessionDB.list_sessions_rich.<locals>.<genexpr>+"  "      #A#AAC#A#A#A#A#A#ArL   s.source IN (r   zs.session_key = ?c              3      K   | ]}d V  dS r  rK   r  s     rF   r+  z/SessionDB.list_sessions_rich.<locals>.<genexpr>2"  rc  rL   s.source NOT IN (r   s.message_count >= ?s.archived = 1s.archived = 0zs.hidden = 0zWHERE rL  rp   zA, COALESCE(sp.prompt, s.system_prompt) AS _system_prompt_resolvedz=LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hashneedler:   c                 (    dt          |            dS )Nr  )r   )rj  s    rF   _like_patternz3SessionDB.list_sessions_rich.<locals>._like_patternl"  s    2<//2222rL   znEXISTS (SELECT 1 FROM chain cq        WHERE cq.root_id = s.id          AND LOWER(cq.cur_id) LIKE ? ESCAPE '\')z[\W_]+z`REPLACE(REPLACE(REPLACE(REPLACE(LOWER(COALESCE({0}, '')), '-', ''), '_', ''), '.', ''), ' ', '')zEXISTS (SELECT 1 FROM chain cq JOIN sessions cs ON cs.id = cq.cur_id WHERE cq.root_id = s.id AND (LOWER(COALESCE(cs.title, '')) LIKE ? ESCAPE '\' OR LOWER(cq.cur_id) LIKE ? ESCAPE '\'r  z OR zcs.titlez LIKE ? ESCAPE '\'r   zs.*zr
                WITH RECURSIVE chain(root_id, cur_id) AS (
                    SELECT s.id, s.id FROM sessions s a  
                    UNION ALL
                    SELECT c.root_id, child.id
                    FROM chain c
                    JOIN sessions parent ON parent.id = c.cur_id
                    JOIN sessions child ON child.parent_session_id = c.cur_id
                    WHERE parent.end_reason = 'compression'
                      AND json_extract(COALESCE(child.model_config, '{}'), '$._branched_from') IS NULL
                      AND json_extract(COALESCE(child.model_config, '{}'), '$._delegate_from') IS NULL
                      AND COALESCE(child.source, '') != 'tool'
                ),
                chain_max AS (
                    SELECT
                        root_id,
                        MAX(cur_idz) AS effective_last_active
                    FROM chain
                    GROUP BY root_id
                )
                SELECT z@,
                    COALESCE(
                        (SELECT a  
                         FROM messages m
                         WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
                         ORDER BY m.timestamp, m.id LIMIT 1),
                        ''
                    ) AS _preview_raw,
                    sz AS last_active,
                    COALESCE(cm.effective_last_active, s.started_at) AS _effective_last_active
                FROM sessions s
                LEFT JOIN chain_max cm ON cm.root_id = s.id
                rM  z
                ORDER BY _effective_last_active DESC, s.started_at DESC, s.id DESC
                LIMIT ? OFFSET ?
            z
                SELECT z@ AS last_active
                FROM sessions s
                zY
                ORDER BY s.started_at DESC
                LIMIT ? OFFSET ?
            N_preview_rawpreview_effective_last_activec                     h | ]
}|d          S r   rK   r   rn  s     rF   r   z/SessionDB.list_sessions_rich.<locals>.<setcomp>"  s    222A$222rL   z AND s.pinned = 1zWHERE s.pinned = 1a  
                         FROM messages m
                         WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
                         ORDER BY m.timestamp, m.id LIMIT 1),
                        ''
                    ) AS _preview_raw,
                    COALESCE(
                        (SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
                        s.started_at
                    ) AS last_active
                FROM sessions s
                z8
                ORDER BY s.started_at DESC
            r   rt  r  )r\  )r   rs  rt  rU   tool_call_countr  rZ  rp  r  rf   r   r  r   _lineage_root_idunread) rD  r  r   r   r   r   r  r   r   r   r_   r  r  rx  _compact_session_colsr#   r   r"   r  r   r   r/  r!   r-  r  r@   rG  _get_session_rich_rows_batchr   valuesr,  r8  );rZ   r  rS  rT  r   rE   rU  rV  rI  rW  rX  rJ  rY  rZ  r[  r\  r]  r  r^  where_clausesrF  include_sourcesr  clauseclause_params	where_sqlbase_where_paramsprompt_selectprompt_join	id_needlesearch_needleouter_where	id_paramsfilter_clausesrl  compact_needlecompact_sqlsearch_clausecombined_selrE  r   r   r  r>   r   rn  seen_idspinned_wherepinned_querypinned_cursorpinned_rowstip_ids_by_roottip_idtip_rows	projectedtip_rowrX  r8   s;                                                              rF   list_sessions_richzSessionDB.list_sessions_rich!  s
   Z 	!!! 	U   !4555  $78H$I$I!S!S!STTT&,E6(($w}"2E2E 	+88#A#A#A#A#AAAL  !@!@!@!@AAAMM/*** 	'  !4555MM+&&& 	+88#A#A#A#A#AAAL  !D\!D!D!DEEEMM/*** 	)$6z$B$B!FM  (((MM-(((q    !7888MM+,,, 	3  !12222! 	3  !1222 	1  000>KS:W\\-88:::QS	 !LL UBBT 	
  QBBP 	 ^**,,2244	%+2244::<< x	+ $K#%I(*N3c 3c 3 3 3 3  ; %%I  
   y!9!9::: < "$	2}!E!E> 
>    --">">!?!!CDDD! D!R{11*==RRRM $$]]>%B%BCCC%%md&:;;; "<<775>Wy11x111DWXDWDW  4@J4--///UD$7@$ $  <HEE!$ $( )$( ,)$ $, "5-$ $8 .c229$ $@ A$ $B C$ $ $EN f_y0E6?BFF3?J4--///UD+  "5  .c22      E  MM5&/***^^ 	%\\%00F??$$D	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	%  	 	C&&s++A)!%%*C*CDDAiLEE*D111OOA  !	#22222H3<V9////BV  4@J4--///UD+  "5      L$ !! 7T $\;L M M+44667 7 7 7 7 7 7 7 7 7 7 7 7 7 7 # # #**3//T7h&&-aeeNB.G.GHH)QtW%%%"""" $ )	!,< )	! /1O 6 655&&-7711!D'::QtW$$/5OAdG, #11..0011 2      I ) )(,,QtW5528B(,,v...d $$Q''' a 3 3C
 g~~&-cls-.tW)*  (((( H  	1 	1A--a00AhKKs$   +Q;;Q?Q?+VVVc                    d |pg D             }|si S d |D             }d                     d |D                       }d| d}|                                 5 }|                    ||                                          }ddd           n# 1 swxY w Y   |D ]<}t	          |d         t          |d	                   |d
                   ||d         <   =|S )u}  Classify each session's lifecycle state from its LAST message row.

        Returns ``{session_id: status}`` where status is one of:

        - ``'complete'``    — last message is a normal assistant reply
        - ``'interrupted'`` — last message is a user turn, a pending assistant
          tool call (no tool result followed), or a tool result the assistant
          never responded to
        - ``'error'``       — last message carries an error finish_reason
        - ``'empty'``       — session has no messages

        Cost-bounded by design: one query that resolves each listed session's
        newest message id via ``MAX(id)`` (an index seek on
        ``idx_messages_session_id``) and joins back for that single row's
        role/tool_calls/finish_reason. Never scans transcripts, so it stays
        cheap on large databases regardless of total message volume.
        c                     g | ]}||S rK   rK   r   s     rF   r   z8SessionDB.session_lifecycle_statuses.<locals>.<listcomp>T#  s    999sS9s999rL   c                     i | ]}|d S )r  rK   r   s     rF   r  z8SessionDB.session_lifecycle_statuses.<locals>.<dictcomp>W#  s    #@#@#@SC#@#@#@rL   r   c              3      K   | ]}d V  dS r  rK   r  s     rF   r+  z7SessionDB.session_lifecycle_statuses.<locals>.<genexpr>X#  s"      11111111rL   a3  
            SELECT m.session_id, m.role,
                   m.tool_calls IS NOT NULL AS has_tool_calls,
                   m.finish_reason
            FROM messages m
            JOIN (
                SELECT session_id, MAX(id) AS max_id
                FROM messages
                WHERE session_id IN (z[)
                GROUP BY session_id
            ) latest ON m.id = latest.max_id
        Nr#  r  r  )r#  r  r  rd   )r   r  r   r   r   r   )	rZ   r0  r   statusesr  rE  r   r  r   s	            rF   session_lifecycle_statusesz$SessionDB.session_lifecycle_statuses@#  sQ   ( :9{0b999 	I#@#@C#@#@#@xx11S11111 '3   ^^ 	7<<s++4466D	7 	7 	7 	7 	7 	7 	7 	7 	7 	7 	7 	7 	7 	7 	7 	 	C*A[#C(8$9::!/2+ + +HS&''
 s   )BBBz json:r&  c                 6   t          |t                    rt          |          S |"t          |t          t          t
          f          r|S 	 | j        t          j        |          z   S # t          t          f$ r t          t          |                    cY S w xY w)ah  Serialize structured (list/dict) message content for sqlite.

        sqlite3 can only bind ``str``, ``bytes``, ``int``, ``float``, and ``None``
        to query parameters. Multimodal messages have ``content`` as a list of
        parts (``[{"type": "text", ...}, {"type": "image_url", ...}]``), which
        raises ``ProgrammingError: Error binding parameter N: type 'list' is
        not supported`` when bound directly.

        Returns the value unchanged when it's already a safe scalar, or a
        sentinel-prefixed JSON string for lists/dicts. Paired with
        :meth:`_decode_content` on read.
        )r   r_   r	   rV  rA   r-  _CONTENT_JSON_PREFIXr  r(  rv   rw   r  r&  s     rF   _encode_contentzSessionDB._encode_contenty#  s     gs## 
	1 (000?j5#u2EFF?N	6 +dj.A.AAA:& 	6 	6 	6'G55555	6s   A( (-BBc                 2   t          |t                    r|                    | j                  rg	 t	          j        |t          | j                  d                   S # t          j        t          f$ r t          
                    d           |cY S w xY w|S )z;Reverse :meth:`_encode_content`; returns scalars unchanged.NzCFailed to decode JSON-encoded message content; returning raw string)r   r_   r(  r  r  r  r   r`  rv   r=  r  r  s     rF   _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display_metadatac                    | sdS t          | t                    r	 t          j        |           }n7# t          j        t
          f$ r t                              d           Y dS w xY wt          |t                    st                              d           dS t          j	        |          S t          | t                    rt          j	        |           S t                              dt          |           j                   dS )a~  Serialize ``display_metadata`` for its TEXT column without double-encoding.

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

        Shared by :meth:`append_message` and :meth:`append_messages_batch` so
        the two writers can never diverge on these correctness invariants
        (this guard has already needed targeted fixes — see the #74478
        patience note below).
        r  Nrn   z7Session turn lease lost; refusing transcript write for r}  r*  r  rr  rs  rt  r  )r  r   rQ  r  rT  r-  r  r  )
rZ   r   rd   r  r  r  r  leaser)  rf  s
             rF   _check_transcript_write_guardsz(SessionDB._check_transcript_write_guards#  sa   2  	"BB4TTOLL, "  hjj	 
 }h3D D D/*%* *   )++CU<())S00
 ? c#u-C'D'DEEE')   ,,DM
 
 (** 	
 
#/%66/
;;;	  //66rL   rz  c                    | dS 	 t          | t                    rt          j        |           n| }t          |t                    rt          j        |          }n7# t          j        t
          f$ r t                              d           Y dS w xY wt          |t                    st                              d           dS |S )a  Decode a ``display_metadata`` column into the dict every reader expects.

        Every message read path must go through this. Returning the raw TEXT
        instead reaches the desktop as a string, where ``'task_count' in meta``
        throws and fails the whole resume. Rows written before the encode guard
        landed are double-encoded, so unwrap a second layer when we find one.
        Nz0Ignoring invalid display metadata on message rowz3Ignoring non-object display metadata on message row)	r   r_   r  r  r`  rv   r=  r  r,  )rz  metas     rF   _decode_display_metadataz"SessionDB._decode_display_metadata$  s     ;4	&0c&:&:C4:c???D$$$ (z$''$i0 	 	 	NNMNNN44	 $%% 	NNPQQQ4s   AA 0BBrD   c                 `    | sdS t          | t                    r| S t          j        |           S )u}  Serialize a structured reasoning field for its TEXT column.

        ``reasoning_details`` / ``codex_reasoning_items`` / ``codex_message_items``
        arrive as list/dict structures from the live runtime, but callers that
        round-trip stored rows — ``get_messages`` straight into
        ``replace_messages``, e.g. the POST /api/sessions/{id}/fork handler —
        hand back the raw TEXT these columns already hold, because
        ``get_messages`` only deserializes ``content`` and ``tool_calls``.
        Re-dumping that TEXT double-encodes it, and the forked session's next
        ``get_messages_as_conversation`` json.loads then yields the inner
        string instead of the original list, so every reasoning-replay consumer
        (all of which check ``isinstance(..., list)``) silently drops it.
        Strings are therefore stored as-is; structures are dumped.
        N)r   r_   r  r(  r   s    rF   _reasoning_json_textzSessionDB._reasoning_json_text$  s8       	4eS!! 	Lz%   rL   r#  r  r8  tool_call_idtoken_countr  	reasoningreasoning_contentreasoning_detailscodex_reasoning_itemscodex_message_itemsplatform_message_idobservedeffect_disposition	timestampapi_contentdisplay_kindc                    	
                        |                               |                               |                               |          t          |t                    r4	 t	          j        |          }n# t          j        t          f$ r g }Y nw xY w|rt	          j        |          nd  	                    |          t          j
                    |u	 t          |d          r"t          |                                          nt          |          n2# t          t          f$ r t                              d|           Y nw xY wd|&t          |t"                    rt%          |          nd	
  fd}                     | j                  S )u  
        Append a message to a session. Returns the message row ID.

        Also increments the session's message_count (and tool_call_count
        if role is 'tool' or tool_calls is present).

        ``platform_message_id`` is the external messaging platform's own
        message ID (e.g. Telegram update_id, Yuanbao msg_id).  It is
        independent of the SQLite autoincrement primary key and is used by
        platform-specific flows like yuanbao's recall guard to redact a
        message by its platform-side identifier.

        ``api_content`` is the exact content string sent to the API for this
        message when it differs from ``content`` (ephemeral memory/plugin
        injections, persist overrides).  It is a byte-fidelity sidecar for
        prompt-cache-stable replay — stored as sent, except lone surrogates
        (which sqlite3 cannot bind and which the conversation loop scrubs
        from every outgoing payload anyway, so the scrubbed form IS the
        wire bytes).
        Nr  /Ignoring invalid explicit message timestamp: %rr   rq   c                                         |            |                     dt                    	
t                    t                    rdnddt          t                    rt                    nd t          t                    rt                    nd f          }|j        }dk    r|                     df           n|                     df           |S )Nr  r    INSERT INTO messages (session_id, role, content, tool_call_id,
                   tool_calls, tool_name, effect_disposition, timestamp, token_count, finish_reason,
                   reasoning, reasoning_content, reasoning_details, codex_reasoning_items,
                   codex_message_items, platform_message_id, observed, active, api_content, display_kind, display_metadata)
                   VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)rq   r   zUPDATE sessions SET message_count = message_count + 1,
                       tool_call_count = tool_call_count + ? WHERE id = ?zBUPDATE sessions SET message_count = message_count + 1 WHERE id = ?)r  r   r   r   r_   	lastrowid)r   r   msg_idr  codex_items_jsoncodex_message_items_jsonr  r  display_metadata_jsonr  r  message_timestampnum_tool_callsr  r  r  r  reasoning_details_jsonr#  rZ   rd   stored_contentr  r  tool_calls_jsonr  r  r  s      rF   r  z%SessionDB.append_message.<locals>._do$  sV   //'"3'= 0    \\] " #%i00&%!%i00%&788*$,'!(AAq6@c6R6R\%k222X\7A,PS7T7T^%l333Z^)+ F< %F !!M#Z0    XM   MrL   r  )r  r  r   r_   r  r  r`  rv   r(  r  rT  hasattrr-  r  rw   r=  rY  r   r   r  r  )!rZ   rd   r#  r&  r  r8  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  s!   ``` ` `````   ``` `` ``` @@@@@@@@rF   append_messagezSessionDB.append_message3$  sq   ` !% = =>N O O!%!:!:;L!M!M445JKK#'#<#<=P#Q#Q  j#&& 	  !Z
33

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


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

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

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

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

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

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

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

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

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

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

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

        Lets a reaction event carry the target's role so a renderer can match
        a live message that doesn't know its durable row id yet.
        NzHSELECT role FROM messages WHERE id = ? AND session_id = ? AND active = 1r   r  )rZ   rd   r  r   s       rF   get_message_rolezSessionDB.get_message_role%  s      	4Z 	 	*$$ZVj)  hjj 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 &s1vv$&s   =AAAc                    t          j                     }d}d}|D ]<}|                    dd          }|                    d          }	|}
|                    d          	 |                    d          }t          |d          r"t          |                                          }
nt          |          }
nE# t
          t          f$ r1 t                              d|                    d                     Y nw xY w|dk    r|                    d	          nd}|dk    r|                    d
          nd}|dk    r|                    d          nd}| 	                    |          }| 	                    |          }| 	                    |          }t          |	t                    r4	 t          j        |	          }	n# t          j        t
          f$ r g }	Y nw xY w|	rt          j        |	          nd}|                    d          p|                    d          }|                    d          }|                    d|||                     |                    d                    |                    d          |t%          |                    d                    |                    d          |
|                    d          |                    d          |dk    r"t%          |                    d                    nd|dk    r"t%          |                    d                    nd|||||                    d          rdnddt          |t                    rt%          |          ndt          |                    d          t                    r"t%          |                    d                    nd|                     |                    d                    f          }t          |t(                    r|j        
|j        |d<   |dz  }|	)|t          |	t,                    rt/          |	          ndz  }t1          |dz   |
dz             }>||fS )u  Insert *messages* as fresh active rows for *session_id*.

        Shared by :meth:`replace_messages` (delete-then-insert) and
        :meth:`archive_and_compact` (soft-archive-then-insert). Runs inside the
        caller's write transaction (takes the live ``conn``). Returns
        ``(inserted_count, tool_call_count)``. Does NOT touch sessions.* counters
        — the caller owns that, since the two flows reconcile counts differently.
        r   r#  r  r8  r  Nr  r3  r  r  r  r  
message_idr  r  r&  r  r  r  r  r  r  r  r  rq   r  r  _row_idgư>)rT  r@   r  r-  r  rv   rw   r=  rY  r  r   r_   r  r  r`  r(  r   r  r   r  r,  r  r   r   r  )rZ   r   rd   r1  now_tsr  r  r  r#  r8  r  ts_valuer  r  r  r  r  r  r  platform_msg_idr  r	  s                         rF   r  zSessionDB._insert_message_rows&  sw     N	B N	BC7769--D..J &ww{##/j"ww{33Hx55 <,1(2D2D2F2F,G,G)),1(OO)!:. j j jLL!RTWT[T[\gThThiiiiij@D@S@S(; < < <Y]48K4G4G/000T " 37+2E2E-...4   &*%>%>?P%Q%Q"#889NOO'+'@'@AT'U'U$
 *c** $$!%J!7!7JJ,i8 $ $ $!#JJJ$8BLdj444O -..G#'',2G2G  ''-00K,,] ((););<<GGN++#%cggk&:&:;;GG011%GGM**GGO,,?C{?R?R%cggk&:&:;;;X\GK{GZGZ%cgg.A&B&BCCC`d*$,#,,3AA!6@c6R6R\%k222X\BLSWWUcMdMdfiBjBjt%cggn&=&=>>>pt11#'':L2M2MNN+ C< #t$$ /)B!$IMH% '1*d'C'CJC
OOO  $(9D(@AAFF)))s%    AB77?C98C9(F==GGarchive_droppedc                 X     |rdnd fd}                      |           dS )u,  Atomically replace the stored messages for a session.

        Used by transcript-rewrite flows such as /retry, /undo, and /compress.
        The delete + reinsert sequence must commit as one transaction so a
        mid-rewrite failure does not leave SQLite with a partial transcript.

        DESTRUCTIVE by default: every row for the session is DELETEd (and drops
        out of the FTS index). For compaction that must preserve the
        pre-compaction transcript under the same id, use
        :meth:`archive_and_compact` instead.

        Pass ``active_only=True`` to replace ONLY the live (``active = 1``) rows,
        leaving soft-archived rows (``active = 0`` — e.g. the ``compacted = 1``
        turns that :meth:`archive_and_compact` keeps on disk for #38763
        durability, or rewind/undo rows) untouched. Callers that share a session
        id with an agent already running in-place compaction must use this so a
        full-history rewrite doesn't wipe the rows the agent deliberately
        archived. ``message_count``/``tool_call_count`` then track the live set,
        matching :meth:`archive_and_compact`.

        Pass ``archive_dropped=True`` to SOFT-archive the live rows instead of
        DELETEing them: the replaced turns stay on disk with ``active = 0``,
        ``compacted = 0`` — the same "the user took it back" marking
        :meth:`rewind_to_message` applies — and stay readable via
        :meth:`get_messages` with ``include_inactive=True``. This is the mode a
        rewind/edit/regenerate must use: those flows overwrite a transcript the
        user may not have meant to drop, and a plain DELETE also evicts the rows
        from the FTS index, leaving nothing to recover from (#82756). It implies
        active-only handling — already-archived rows are never touched — so
        ``active_only`` is redundant with it. The rewritten set is inserted as
        fresh active rows exactly as in the destructive path, so the live view
        is identical either way; only the durability of the dropped turns
        differs.
         AND active = 1rp   c                    |                      df                                          }|#|d         |d         dk    rt                    r|                      df           n|                      d f           |                      df                               |           \  }}|                      d||f           d S )	Nrr  rs  rt  r  zBUPDATE messages SET active = 0 WHERE session_id = ? AND active = 1)DELETE FROM messages WHERE session_id = ?GUPDATE sessions SET message_count = 0, tool_call_count = 0 WHERE id = ?r  )r   rQ  r  r  )	r   rf  r  r  active_clauser  r1  rZ   rd   s	       rF   r  z'SessionDB.replace_messages.<locals>._do&  s(   llH  hjj 
 #J'3L)]::3J???  :M    OOOM   LLY   04/H/Hj(0 0,N, LLY!1:>    rL   Nr
  )rZ   rd   r1  r?  r  r  r
  s   ``` ` @rF   replace_messageszSessionDB.replace_messagesc&  sd    T .9@))b%	 %	 %	 %	 %	 %	 %	 %	 %	N 	C     rL   c                     | j         5  | j                            d|f          }|                                ducddd           S # 1 swxY w Y   dS )ux  Return True if the session has any soft-archived (``active = 0``) rows.

        Cheap existence probe — does not load rows. NOTE: production rewrite
        paths no longer branch on this (they pass ``active_only=True``
        unconditionally — a probe can fail open or race a concurrent
        ``archive_and_compact``, #80216); kept for tests and diagnostics.
        zBSELECT 1 FROM messages WHERE session_id = ? AND active = 0 LIMIT 1Nr  rZ   rd   r   s      rF   has_archived_messageszSessionDB.has_archived_messages&  s     Z 	1 	1Z''T F ??$$D0	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1s   2AAAc                     |sdS |                                  5 }|                    d|f                                          }ddd           n# 1 swxY w Y   |rt          |d                   ndS )uS  MAX(id) of the session's active rows — the compression watermark.

        Captured at compression START (before the slow provider summary call).
        Every active row with ``id > watermark`` at commit time arrived
        concurrently and must survive the compaction verbatim. Returns 0 for
        an empty/unknown session.
        r   zMSELECT COALESCE(MAX(id), 0) FROM messages WHERE session_id = ? AND active = 1N)r  r   rQ  rA   r  s       rF   get_active_message_watermarkz&SessionDB.get_active_message_watermark&  s      	1^^ 	,,6  hjj	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 "(s3q6{{{q(s   *AAAcompacted_messagesmodel_config_patchlock_holderc                 L      fd}                      |          S )u9
  Non-destructive in-place compaction for a single durable session id.

        Soft-archives the active messages (``active = 0``) and inserts
        *compacted_messages* as fresh active rows — atomically, in one write
        transaction. The conversation keeps ONE session id for life (#38763)
        WITHOUT destroying history:

        - The live-context load (:meth:`get_messages_as_conversation`,
          :meth:`get_messages`) filters ``active = 1`` by default, so the model
          reloads ONLY the compacted set.
        - The archived pre-compaction turns stay on disk (active=0) and stay
          DISCOVERABLE: they are marked compacted=1, and search_messages()
          includes compacted=1 rows by default — so session_search still finds
          them, unlike rewind/undo rows (active=0, compacted=0) which stay
          hidden. They remain in the FTS index (the messages_fts* triggers
          index on INSERT / drop on DELETE and don't key on active/compacted;
          flipping to active=0 is a content-preserving UPDATE) and are
          recoverable via get_messages(..., include_inactive=True).

        Concurrent-append safety (#75316): when *watermark* is provided (the
        value of :meth:`get_active_message_watermark` captured at compression
        START), rows that arrived during the slow provider summary call
        (``id > watermark``) are NOT summarized away. They are re-sequenced
        after the compacted set by a pure-SQL column clone (every column
        except ``id`` — content, api_content, platform_message_id, token
        counts, reasoning sidecars all survive byte-exact, and the FTS
        triggers index the clones naturally), and the originals are archived.
        NOTE: re-sequencing assigns the tail rows fresh ids; consumers that
        reference durable row ids re-resolve by content (see 3e8ab0610).
        ``watermark=None`` preserves the historical archive-everything
        behavior.

        Commit-fence safety: when *lock_holder* is provided, the commit
        verifies INSIDE the transaction that the compression lock is still
        held by that holder and unexpired — a compression whose lease was
        reclaimed (crash cleanup, TTL expiry, competing writer) fails the
        commit instead of clobbering the winner's transcript.

        ``message_count`` is set to the ACTIVE count after commit, matching
        what the live load returns. ``model_config_patch`` is merged into the
        session's JSON config in the same transaction; a ``None`` value
        removes that key. Returns the new active count.
        c           	         t|                      df                                          }|6|d         k    s*t          |d                   t          j                    k    rt	          dd          d }                    | d          }g }d}|                      d	t                    f                                          D ]}|                    t          |d
                              |d         }|rm	 t          |t                    rt          j        |          n|}|t          |t                    rt          |          ndz  }# t          t           f$ r Y w xY w|                      df                               |           \  }}	|rd                    d |D                       }
d                     |           D             }d                    |          }|                      d| d| d|
 d|           |t          |          z  }|	|z  }	|                      d||	f           n|                      d||	|f           |S )Nr|  rn   r}  zCompression lease for z; lost before commit; refusing to publish a stale compactionr_  r[  r   z^SELECT id, tool_calls FROM messages WHERE session_id = ? AND active = 1 AND id > ? ORDER BY idr   r8  zQUPDATE messages SET active = 0, compacted = 1 WHERE session_id = ? AND active = 1r   c              3      K   | ]}d V  dS r  rK   r  s     rF   r+  z=SessionDB.archive_and_compact.<locals>._do.<locals>.<genexpr>Q'  s"      '>'>'>'>'>'>'>'>rL   c                     g | ]}|d v|	S ))r   r  r  rK   r  s     rF   r   z>SessionDB.archive_and_compact.<locals>._do.<locals>.<listcomp>R'  s.        === ===rL   rX  r  z, active, compacted) SELECT z", 1, 0 FROM messages WHERE id IN (r  r  zYUPDATE sessions SET message_count = ?, tool_call_count = ?, model_config = ? WHERE id = ?)r   rQ  r-  rT  r  rU  rA   r   r  r   r_   r  r  r   r   rv   rw   r  r   r  )r   r  patched_model_configr  tail_tool_callsr   rz  r  r  r  r  r  r  r  r  r  rZ   rd   r  s                rF   r  z*SessionDB.archive_and_compact.<locals>._do'  sG   &<<+M  (**	  $)[88Xl344	CC;I I I I  
 $( !-
 (,'D'D*&8W (E ( ($ #%HO$<<"  Y0	 
 (**! !C OOCD	NN333l+C !!8B38L8L%UTZ___RUF+jQU>V>V/]s6{{{\]]OO ):6 ! ! ! D!	! LL6  
 *.)B)Bj"4* *&H&  4  #xx'>'>X'>'>'>>> #99$??  
  99Z00@X @ @&@ @$0@ @ @ 	   CMM) O3  ")]/<   
 4/1EzR  
 Os   AEE0/E0r
  )rZ   rd   r  r  r  r  r  s   `````` rF   archive_and_compactzSessionDB.archive_and_compact&  s\    h`	 `	 `	 `	 `	 `	 `	 `	 `	 `	D ""3'''rL   c                     t          | dd          }|r|S d |                    d                                          D             }|| _        |S )z>Column names of the messages table, cached per-connection era._message_columns_cacheNc                     g | ]
}|d          S rq   rK   rr  s     rF   r   z3SessionDB._message_column_names.<locals>.<listcomp>v'  s    UUU!UUUrL   zPRAGMA table_info(messages))r  r   r   r  )rZ   r   cachedcolss       rF   r  zSessionDB._message_column_namesq'  sZ    7>> 	MUUdll+HIIRRTTUUU&*#rL   c                 j    |                      |          fd}|                     |          S )u  Backfill the ``api_content`` sidecar onto the newest ACTIVE user row.

        In-place preflight compaction (:meth:`archive_and_compact`) inserts the
        current turn's user row BEFORE the turn prologue composes the
        prefetch/plugin sidecar, and the subsequent crash persist identity-skips
        every compacted dict — without this backfill the stamped sidecar would
        never land in the DB and any reload would replay clean content,
        re-introducing the prompt-cache divergence the sidecar exists to close.

        The ``content`` match is a defensive guard: if the newest active user
        row is not the message the caller stamped (racing rewrite, unexpected
        tail shape), nothing is written. Returns the number of rows updated
        (0 or 1).
        c                 ^    |                      dt                    f          }|j        S )NzUPDATE messages SET api_content = ? WHERE id = (SELECT id FROM messages WHERE session_id = ? AND role = 'user' AND active = 1 ORDER BY id DESC LIMIT 1) AND content IS ?)r   r   r~  )r   r   r  encodedrd   s     rF   r  z2SessionDB.set_latest_user_api_content.<locals>._do'  s9    \\%
 #;//WE F ?"rL   )r  r  )rZ   rd   r&  r  r  r#  s    ` ` @rF   set_latest_user_api_contentz%SessionDB.set_latest_user_api_contentz'  sS    " &&w//		# 		# 		# 		# 		# 		# 		# ""3'''rL   include_inactiveinclude_compactedlatestafter_idc                    ||s|rt          d          ||rt          d          |rd}n|rd}nd}|dnd}	d| |	 d	|rd
nd }
|g}||                    |           |r#|                                 5 }|                    d|z   dz   |g          }|                                }ddd           n# 1 swxY w Y   i }|D ]l}|d         |d         |d         |d         |d         |d         f}|                    |          }|"|d         |d         f|d         |d         fk    r|||<   mt          |                                d           }|r|ddd         }||d         }|
|d|         }|r|ddd         }n||r |
dz  }
|                    |dn||g           |                                 5 }|                    |
|          }|                                }ddd           n# 1 swxY w Y   |r|	                                 g }|D ]}t          |          }d|v r|                     |d                   |d<   |                    d          rZ	 t          j        |d                   |d<   n;# t          j        t          f$ r" t                               d           g |d<   Y nw xY w|                    d          |                     |d                   |d<   |                    |           |S )u  Load messages for a session in insertion order.

        By default only active messages are returned. Pass
        ``include_inactive=True`` to load soft-deleted rows (e.g. for
        audit / debug views of rewound history). See
        :meth:`rewind_to_message` for the soft-delete mechanic.

        Pass ``include_compacted=True`` to additionally load rows preserved
        by in-place context compaction (``active=0, compacted=1``). Those are
        durable display history, not soft-deleted rows — a user-visible
        transcript read must not drop them, or earlier turns silently become
        unreachable once the UI exhausts its active-only window. Soft-deleted
        Undo/Rewind rows (``active=0, compacted=0``) stay excluded; use
        ``include_inactive`` for those.

        Ordered by AUTOINCREMENT id (true insertion order) rather than
        timestamp — see c03acca50 for the WSL2 clock-regression rationale.

        When ``limit`` is provided, returns at most ``limit`` messages
        starting from ``offset`` (0-based, in insertion order). Enables
        pagination for the API endpoint to avoid loading entire transcripts.
        With ``latest=True``, the offset is measured back from the newest
        message and the selected page is still returned in chronological
        order. ``offset`` alone (without ``limit``) also pages — SQLite
        requires a LIMIT clause for OFFSET, so it's emitted as ``LIMIT -1``
        (unbounded).

        ``after_id`` enables keyset pagination (``id > after_id``): O(1)
        page seeks on huge transcripts where OFFSET degrades to O(n) per
        page. Ascending order only (incompatible with ``latest``/``offset``).
        Nz2after_id is incompatible with latest/offset pagingzYafter_id is incompatible with include_compacted (deduped display reads use offset paging)rp   z" AND (active = 1 OR compacted = 1)r  z AND id > ?z+SELECT * FROM messages WHERE session_id = ?z ORDER BY id DESCASCz ORDER BY id ASCr#  r&  r  r  r8  r  r  r   c                     | d         S )Nr   rK   )rs  s    rF   r6  z(SessionDB.get_messages.<locals>.<lambda>'  s
    qw rL   )r8   r   z LIMIT ? OFFSET ?zDFailed to deserialize tool_calls in get_messages, falling back to []r  )rw   r  r  r   r   r@   r9  ry  r  r7  r,  r  r  r  r`  rv   r=  r  r  )rZ   rd   r%  r&  rE   rU  r'  r(  r
  keyset_clauser  rF  r   r   all_rowsr  r   r8   r	  r  r  r  s                         rF   get_messageszSessionDB.get_messages'  sR   R VvQRRR$5xyyy 		.MM 	. AMM-M)1)=2XX+X XDJ:U&&PUX X 	 #|MM(### 2	 !! -TAMQ()L 
 "??,,- - - - - - - - - - - - - - - D $ $ K	N$'%$ hhsmm;3x=#d)"<HsSWy?Y"Y"Y #DI$++--->->???D "DDbDz=D FUF| "DDbDz F **U]rrvFGGG!! )Tc622(() ) ) ) ) ) ) ) ) ) ) ) ) ) )   	 	Cs))CC!%!5!5c)n!E!EIww|$$ ++(,
3|3D(E(EC%%,i8 + + +NN#ijjj(*C%%%+ ww)**6*.*G*GL^H_*`*`&'MM#s6   :2B88B<?B<+G88G<?G<&J5J<;J<c                    g }d |D             }t          dt          |          d          D ]}|||dz            }d                    dt          |          z            }|                                 5 }|                    d| d|                                          }ddd           n# 1 swxY w Y   |                    d	 |D                        |S )
u  Tool results in these sessions that mention a GitHub PR url.

        A candidate scan, deliberately loose: it hands back every tool result
        containing ``/pull/`` and leaves the caller to decide which ones make a
        claim (see the desktop's PR recovery, which only accepts an output that
        is a bare PR url — the signature of ``gh pr create``). Ordered
        oldest-first per session so the caller can take the last match.
        c                     g | ]}||S rK   rK   rs  s     rF   r   z2SessionDB.find_pr_url_messages.<locals>.<listcomp>&(  s    +++Q+q+++rL   r   i  r   r   zVSELECT session_id, content FROM messages
                        WHERE session_id IN (zq)
                          AND role = 'tool' AND content LIKE '%/pull/%'
                        ORDER BY id ASCNc              3   8   K   | ]}|d          |d         dV  dS )r   rq   )rd   r&  NrK   r  s     rF   r+  z1SessionDB.find_pr_url_messages.<locals>.<genexpr>2(  s2      SSsA3q6BBSSSSSSrL   )rP  r   r   r  r   r   r  )	rZ   r0  r   r   r  chunkr  r   r  s	            rF   find_pr_url_messageszSessionDB.find_pr_url_messages(  s8    ')++++++1c#hh,, 	T 	TE+,E88C#e**$455L!! T||+.:+ + +   (**                LLSSdSSSSSSSs   5-B..B2	5B2	   around_message_idwindowc                 8   |dk     rd}|                                  5 }|                    d||f                                          }|sg dddcddd           S |                    d|||dz   f                                          }|                    d|||f                                          }ddd           n# 1 swxY w Y   t	          t          |                    t	          |          z   }g }	|D ]}
t          |
          }d|v r|                     |d                   |d<   |                    d	          rZ	 t          j
        |d	                   |d	<   n;# t          j        t          f$ r" t                              d
           g |d	<   Y nw xY w|                    d          |                     |d                   |d<   |	                    |           t#          dt%          |          dz
            }t%          |          }|	||dS )u  Load a window of messages anchored on a specific message id.

        Returns a dict with:
          - ``window``: up to ``window`` messages before the anchor, the anchor
            itself, and up to ``window`` messages after, ordered by id ascending.
          - ``messages_before``: count of messages strictly before the anchor
            still in the session (== window unless we hit the start).
          - ``messages_after``: count of messages strictly after the anchor
            still in the session (== window unless we hit the end).

        Used by ``session_search`` for both the discovery shape (anchored on the
        FTS5 match) and the scroll shape (anchored on any message id). The
        ``messages_before`` / ``messages_after`` counts let the caller detect
        session boundaries: when either is less than ``window``, the agent has
        reached one end of the session.

        Returns an empty window when ``around_message_id`` is not a real id in
        ``session_id`` — callers decide how to surface that.
        r   z>SELECT 1 FROM messages WHERE id = ? AND session_id = ? LIMIT 1)r7  messages_beforemessages_afterNzPSELECT * FROM messages WHERE session_id = ? AND id <= ? ORDER BY id DESC LIMIT ?rq   zNSELECT * FROM messages WHERE session_id = ? AND id > ? ORDER BY id ASC LIMIT ?r&  r8  zKFailed to deserialize tool_calls in get_messages_around, falling back to []r  )r  r   rQ  r   r   reversedr,  r  r@   r  r  r`  rv   r=  r  r  r  r  r   )rZ   rd   r6  r7  r   anchor_existsbefore_rows
after_rowsr  r  r   r  r9  r:  s                 rF   get_messages_aroundzSessionDB.get_messages_around5(  s   2 A::F^^ 	 LLP"J/  hjj  ! Q"$aPP	 	 	 	 	 	 	 	 ,,+ .
;	 
 hjj  * .7	 
 hjj #	 	 	 	 	 	 	 	 	 	 	 	 	 	 	2 H[))**T*-=-== 	 	Cs))CC!%!5!5c)n!E!EIww|$$ ++(,
3|3D(E(EC%%,i8 + + +NNe   )+C%%%	+
 ww)**6*.*G*GL^H_*`*`&'MM# a[!1!1A!566Z.,
 
 	
s*   2CACCCE##5FFc           	         |s|S 	 |                      |          }n# t          $ r |}Y nw xY w|r||k    r|}| j        5  |}|h}d}t          d          D ]}	 | j                            d|f                                          }n # t          $ r |cY c cddd           S w xY w||}	 | j                            dt          dt                     d|f                                          }n # t          $ r |cY c cddd           S w xY w| nAt          |d          r|d         n|d	         }	|	r|	|v r n|
                    |	           |	}||n|cddd           S # 1 swxY w Y   dS )
u  Redirect a resume target to the descendant session that holds the messages.

        Context compression ends the current session and forks a new child session
        (linked via ``parent_session_id``). The flush cursor is reset, so the
        child is where new messages actually land — the parent ends up with
        ``message_count = 0`` rows unless messages had already been flushed to
        it before compression. See #15000.

        This helper walks ``parent_session_id`` forward from ``session_id`` and
        returns the descendant in the chain that has the **most recent** messages.
        Unlike the original logic, it does NOT short-circuit when the starting
        session already has messages — a descendant that was created by
        compression may hold the continuation content and should be preferred
        by the WebUI and gateway for ``--resume`` and session loading.

        If no descendant (including the starting session) has any messages,
        the original ``session_id`` is returned unchanged.

        The chain is always walked via the child whose ``started_at`` is
        latest; that matches the single-chain shape that compression creates.
        A depth cap (32) guards against accidental loops in malformed data.
        N    z3SELECT 1 FROM messages WHERE session_id = ? LIMIT 1aC  SELECT id FROM sessions AS child WHERE child.parent_session_id = ?   AND json_extract(COALESCE(child.model_config, '{}'), '$._branched_from') IS NULL   AND json_extract(COALESCE(child.model_config, '{}'), '$._delegate_from') IS NULL   AND json_extract(COALESCE(child.model_config, '{}'), '$._reset_from') IS NULL   AND NOT r  za   AND COALESCE(child.source, '') != 'tool' ORDER BY child.started_at DESC, child.id DESC LIMIT 1keysr   r   )rG  rB   rN  rP  r:  r   rQ  r    r   r  r  )
rZ   rd   tipr  r  bestr[  r   	child_rowrF  s
             rF   resolve_resume_session_idz#SessionDB.resolve_resume_session_id(  s   .  		**:66CC 	 	 	CCC	 	3*$$JZ 0	< 0	< G9DD2YY )# )#&*,,M 
  hjj C ! & & &%%%%%0	< 0	< 0	< 0	< 0	< 0	< 0	< 0	<&?"D& $
 2 2P
 &=WF\%]%]P P P !

! 
! hjj I ! & & &%%%%%O0	< 0	< 0	< 0	< 0	< 0	< 0	< 0	<L&$E.5i.H.HZ9T??iXYl 8t#3#3E""""+44a0	< 0	< 0	< 0	< 0	< 0	< 0	< 0	< 0	< 0	< 0	< 0	< 0	< 0	< 0	< 0	< 0	< 0	<sg    ++E+.B	E+	B&E+%B&&E+.AC43E+4D E+DAE++E/2E/include_ancestorsrepair_alternationinclude_row_idsc           
         |g}|r*|                      |          s|                     |          }|rdnd}|                                 5 }d                    d |D                       }	|                    d| j         d|	 d| dt          |                                                    }
d	d	d	           n# 1 swxY w Y   |                     |
||||
          S )u  
        Load messages in the OpenAI conversation format (role + content dicts).
        Used by the gateway to restore conversation history.

        By default only active messages are returned. Pass
        ``include_inactive=True`` to load soft-deleted (rewound) rows
        as well. See :meth:`rewind_to_message`.

        ``repair_alternation=True`` runs ``repair_message_sequence`` over the
        loaded list before returning it. Callers that restore a session for
        LIVE REPLAY should pass it: a durable alternation violation (e.g. a
        ``user;user`` pair left by a turn that persisted no assistant row)
        otherwise re-triggers the pre-request defensive repair on every
        single request for the rest of the session's life — the repair
        mutates only the per-request list, never the stored transcript.
        Inspection/export consumers keep the default and see the transcript
        verbatim.
        rp   r  r   c              3      K   | ]}d V  dS r  rK   r  s     rF   r+  z9SessionDB.get_messages_as_conversation.<locals>.<genexpr>)  "      #=#=AC#=#=#=#=#=#=rL   zSELECT $ FROM messages WHERE session_id IN (r   r  Nrd   rG  rH  rI  	_is_explicit_branch_session_session_lineage_root_to_tipr  r   r   _CONVERSATION_ROW_COLUMNSr  r   _rows_to_conversation)rZ   rd   rG  r%  rH  rI  r0  r
  r   r  r  s              rF   get_messages_as_conversationz&SessionDB.get_messages_as_conversation(  s_   4 "l 	HT%E%Ej%Q%Q 	H;;JGGK.E4E^^ 	88#=#=#=#=#===L<<
/$8 
/ 
/6B
/ 
/ !
/ 
/ 
/ k""  hjj 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	" ))!/1+ * 
 
 	
s   
A$B::B>B>a  id, role, content, tool_call_id, tool_calls, tool_name, effect_disposition, finish_reason, reasoning, reasoning_content, reasoning_details, codex_reasoning_items, codex_message_items, platform_message_id, observed, timestamp, api_content, display_kind, display_metadata)rI  c                $   g }|D ]7}|                      |d                   }|d         dv r6t          |t                    r!t          |                                          }|d         |d}	|r|d         |d         |	d<   |d         r|d         |	d<   |d	         r|d	         |	d	<   |d
         r"|                     |d
                   }
|
|
|	d
<   |d         r|d         |	d<   |d         r|d         |	d<   |d         r|d         |	d<   |d         r|d         |	d<   |d         rZ	 t          j        |d                   |	d<   n;# t          j        t          f$ r" t                              d           g |	d<   Y nw xY w|d         r|d         |	d<   |d         rd|	d<   |d         dk    r_|d         r|d         |	d<   |d         r|d         |	d<   |d         |d         |	d<   |d         rZ	 t          j        |d                   |	d<   n;# t          j        t          f$ r" t                              d           d|	d<   Y nw xY w|d         rZ	 t          j        |d                   |	d<   n;# t          j        t          f$ r" t                              d           d|	d<   Y nw xY w|d         rZ	 t          j        |d                   |	d<   n;# t          j        t          f$ r" t                              d           d|	d<   Y nw xY w|r|                     ||	          r"|                    |	           9t          |          }t          |          }|r2|r0dd lm}  |d|          }|rt                              d!||           |S )"ap  Decode fetched message rows into the OpenAI conversation format.

        Extracted from get_messages_as_conversation so get_resume_conversations
        can build the model-fed and display views from one SELECT. ``rows`` must
        already be ordered by ``id`` (insertion order) and filtered to the
        desired session set / active state by the caller.
        r&  r#  >   r$  r3  )r#  r&  r   Nr   r  r  r  r  r  r  r  r8  zKFailed to deserialize tool_calls in conversation replay, falling back to []r  r  r  Tr3  r  r  r  r  z=Failed to deserialize reasoning_details, falling back to Noner  zAFailed to deserialize codex_reasoning_items, falling back to Noner  z?Failed to deserialize codex_message_items, falling back to Noner   )repair_message_sequenceu   Repaired %d message-alternation violation(s) while restoring session %s — durable transcript kept them, see repair_message_sequence)r  r   r_   r   r   r  r  r  r`  rv   r=  r  #_is_duplicate_replayed_user_messager  r6  r@  agent.agent_runtime_helpersrV  r>  )rZ   r  rd   rG  rH  rI  r1  r   r&  r  decodedrV  r?  s                rF   rS  zSessionDB._rows_to_conversation%)  s      P	! P	!C**3y>::G6{333
7C8P8P3*73399;;v;7;;C  +3t9#8!$TI =! 8%(%7M">" :&).&9N#%& 677<N8OPP&.5C*+; 4#&{#3K >" :&).&9N#; 4#&{#3K '( F,/0D,E()<  ++(,
3|3D(E(EC%%,i8 + + +NN#pqqq(*C%%%+ () ?$'(=$>L!: '"&J 6{k))' @+.+?C({# 8'*;'7C$*+7/23F/GC+,*+ 8837:cBU>V3W3W/00 0)< 8 8 8'fggg37/0008 ./ <<7;z#F]B^7_7_344 0)< < < <'jkkk7;3444< ,- ::59ZDY@Z5[5[122 0)< : : :'hiii591222: ! T%M%MhX[%\%\ OOC     4H== 2(;; 	( 	 LKKKKK..tX>>H 2    sH   (E5E>=E>0H5III005J('J(4K5L
	L
c                    |                                rgn|                               }|                                 5 }d                    d |D                       }|                    d| j         d| dt          |                                                    }ddd           n# 1 swxY w Y   fd|D             }|                     |dd	d	
          }|                     |d	dd	
          }||fS )uN  Return ``(model_history, display_history)`` for a session resume in ONE SELECT.

        ``session.resume`` needs two projections of the same lineage:

        - ``model_history`` — the tip session's active rows, alternation-repaired
          (the live-replay working conversation). Equivalent to
          ``get_messages_as_conversation(session_id, repair_alternation=True)``.
        - ``display_history`` — the full compression lineage (ancestors → tip),
          verbatim, with replayed-user dedup. Explicit ``/branch`` sessions are
          excluded from this lineage because their own rows already contain the
          copied transcript; including the live parent's rows would let messages
          written to the original after the fork leak into the branch.

        The display fetch already reads a superset of the model fetch (the tip
        rows are part of the lineage), so serving both from one lineage SELECT
        halves the resume's DB work versus two separate calls, with byte-identical
        output (see test_get_resume_conversations_matches_separate_reads).
        r   c              3      K   | ]}d V  dS r  rK   r  s     rF   r+  z5SessionDB.get_resume_conversations.<locals>.<genexpr>)  rL  rL   SELECT session_id, rM  ) AND active = 1 ORDER BY idNc                 ,    g | ]}|d          k    |S rd   rK   r   rs  rd   s     rF   r   z6SessionDB.get_resume_conversations.<locals>.<listcomp>)  s'    EEE!q*'D'DA'D'D'DrL   FTrN  rO  )	rZ   rd   r0  r   r  r  r  model_historydisplay_historys	    `       rF   get_resume_conversationsz"SessionDB.get_resume_conversations)  s   . //
;;?ZLL22:>> 	
 ^^ 		88#=#=#=#=#===L<<d&D  6B  
 k""  hjj 		 		 		 		 		 		 		 		 		 		 		 		 		 		 		 FEEEtEEE22!##  3 
 
 44!"$  5 
 
 o--s   A!B00B47B4c                 f   |                      |          }d                    d |D                       }|                                 5 }|                    d| dt	          |                                                    }ddd           n# 1 swxY w Y   t          |r|d         nd          S )z7Count active rows that a full resume would materialize.r   c              3      K   | ]}d V  dS r  rK   r  s     rF   r+  z5SessionDB.get_resume_message_count.<locals>.<genexpr>)  "      99999999rL   z3SELECT COUNT(*) FROM messages WHERE session_id IN (z) AND active = 1Nr   )rQ  r   r  r   r  rQ  rA   )rZ   rd   r0  r  r   r   s         rF   get_resume_message_countz"SessionDB.get_resume_message_count)  s    77
CCxx99[99999^^ 	,,G(4G G Gk""  hjj	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 S'3q66a(((s   	:BBBmax_messagesc                    |t                      }|dk     rt          d          |dk    rdS |                     |          }d                    d |D                       }|                                 5 }|                    d| dg ||dz   R                                           }ddd           n# 1 swxY w Y   t          |r|d         nd          }||k    rt          ||          |S )	a  Return resume row count or reject a transcript too large to load.

        ``max_messages=None`` resolves the limit from config
        (``sessions.max_resume_messages``); 0 disables the guard and returns
        the (bounded) count without raising.
        Nr   !max_messages must be non-negativer   c              3      K   | ]}d V  dS r  rK   r  s     rF   r+  z/SessionDB.assert_resume_safe.<locals>.<genexpr>*  rf  rL   zBSELECT COUNT(*) FROM (SELECT 1 FROM messages WHERE session_id IN (z) AND active = 1 LIMIT ?)rq   )	rM   rw   rQ  r   r  r   rQ  rA   rT   )rZ   rd   rh  r0  r  r   r   rU   s           rF   assert_resume_safezSessionDB.assert_resume_safe)  sb    799L!@AAA1
 177
CCxx99[99999^^ 	,,?K   1+0|a/00  hjj 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 c0CFFq11<'',]LIIIs   65B77B;>B;c                 r   |t                      }|dk     rt          d          |dk    rdS |                                 5 }|                    d||dz   f                                          }ddd           n# 1 swxY w Y   t          |r|d         nd          }||k    rt          |||          |S )a  Return active row count or reject an unsafe in-memory export.

        Exporting one session does not include compression ancestors, so this
        guard deliberately counts only the requested segment. The limited
        subquery stops as soon as it proves the transcript exceeds the bound.

        ``max_messages=None`` resolves the limit from config
        (``sessions.max_export_messages``); 0 disables the guard and returns
        the active row count without raising.
        Nr   rj  zYSELECT COUNT(*) FROM (SELECT 1 FROM messages WHERE session_id = ? AND active = 1 LIMIT ?)rq   )rR   rw   r  r   rQ  rA   rc   )rZ   rd   rh  r   r   rU   s         rF   assert_export_safezSessionDB.assert_export_safe*  s    799L!@AAA1 1^^ 	,, \A-.	 
 hjj 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 c0CFFq11<'',ZUUUs   .A<<B B c                    |                                rg S |                               }t          |          dk    rg S |                                 5 }d                    d |D                       }|                    d| j         d| dt          |                                                    }ddd           n# 1 swxY w Y   fd|D             }|sg S | 	                    |d	d
          S )uG  Return the ancestor-only display messages for a session lineage.

        These are messages from parent/grandparent sessions (compression
        ancestors) that appear in the display transcript but NOT in the
        tip session's model-fed history. Used by ``session.resume`` to
        build the ``display_history_prefix`` that ``_live_session_payload``
        prepends to the live model history.

        Previously the prefix was calculated as
        ``display_history[:len(display) - len(raw)]``, but that overcounts
        when ``repair_message_sequence`` removes messages from the MIDDLE
        of the tip history (e.g. verification candidates collapsed by the
        consecutive-assistant merge) — the length difference includes both
        ancestor messages AND repair-removed tip messages, but the slice
        only captures the first N display messages (which are tip messages
        when there are no ancestors), causing duplication. This method
        returns ONLY the genuine ancestor messages, identified by
        ``session_id != tip_session_id``. (#65919)
        rq   r   c              3      K   | ]}d V  dS r  rK   r  s     rF   r+  z8SessionDB.get_ancestor_display_prefix.<locals>.<genexpr>R*  rL  rL   r\  rM  r]  Nc                 ,    g | ]}|d          k    |S r_  rK   r`  s     rF   r   z9SessionDB.get_ancestor_display_prefix.<locals>.<listcomp>Y*  s'    JJJqAlOz,I,I,I,I,IrL   TF)rd   rG  rH  )
rP  rQ  r   r  r   r   rR  r  r   rS  )rZ   rd   r0  r   r  r  ancestor_rowss    `     rF   get_ancestor_display_prefixz%SessionDB.get_ancestor_display_prefix7*  ss   ( ++J77 	I77
CC{q  I^^ 	88#=#=#=#=#===L<<d&D  6B   k""	 
 hjj 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 KJJJDJJJ 	I))!"$	 * 
 
 	
s   A!CCCc                    |sdS |                                  5 }|                    d|f                                          }ddd           n# 1 swxY w Y   |dS t          |d          r|d         n|d         }|sdS 	 t	          |t
                    rt          j        |          n|}n# t          j        t          f$ r Y dS w xY wt	          |t                    o!t          |                    d                    S )a  Return whether *session_id* is a copied user-facing branch.

        Branches and compression continuations both use ``parent_session_id``,
        but they have different history semantics: a branch owns a copied
        transcript, while a compression continuation needs its ended parent's
        archived rows for display. The durable ``_branched_from`` marker is the
        existing discriminator written by all branch creation paths.
        Fr^  NrB  r   r   _branched_from)r  r   rQ  r  r   r_   r  r  r`  rv   r,  r   r@   )rZ   rd   r   r   
raw_configry  s         rF   rP  z%SessionDB._is_explicit_branch_sessionc*  sV     	5^^ 	,,@  hjj 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	
 ;5,3C,@,@LS((c!f
 	5	/9*c/J/JZTZ
+++PZFF$i0 	 	 	55	&$''ND<L1M1M,N,NNs#   *AAA+B/ /C	C	c                 T    |                      |          }|r|d         r|d         n|S )u'  Return the ROOT id of *session_id*'s lineage chain.

        The root is the stable "conversation id": context compression
        rotates ``session_id`` to a new segment linked via
        ``parent_session_id``, and delegate subagents hang off their
        parent the same way. Walking to the root gives every segment of
        one user-facing conversation (and its delegation tree) a single
        identifier — used for Nous Portal ``conversation=`` usage tagging.
        Returns *session_id* unchanged when it has no recorded parent.
        r   )rQ  )rZ   rd   chains      rF   get_conversation_rootzSessionDB.get_conversation_root~*  s3     11*==!>eAh>aJ?rL   c                    |s|gS g }|}t                      }|                                 5 }t          d          D ]}|r||v r nx|                    |           |                    |           |                    d|f                                          }| n!t          |d          r|d         n|d         }d d d            n# 1 swxY w Y   t          t          |                    p|gS )Nr  z3SELECT parent_session_id FROM sessions WHERE id = ?rB  r  r   )
r   r  rP  r  r  r   rQ  r  r   r;  )rZ   rd   rx  r  r  r   r[  r   s           rF   rQ  z&SessionDB._session_lineage_root_to_tip*  sa    	 <uu^^ 	W3ZZ W W 'T//E!!!W%%%llIJ  (**  ;E6=c66J6JV#122PSTUPV	W 	W 	W 	W 	W 	W 	W 	W 	W 	W 	W 	W 	W 	W 	W HUOO$$44s   BC

CCr  c                    |                     d          dk    rdS |                     d          }t          |t                    r|sdS t          |           D ]}}|                     d          dk    r|                     d          |k    r dS |                     d          dk    r-|                     d          s|                     d          r dS ~dS )Nr#  r$  Fr&  Tr3  r8  )r@   r   r_   r;  )r1  r  r&  prevs       rF   rW  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urL   target_message_idc                    | j         5  | j                            df                                          }ddd           n# 1 swxY w Y   |t	          d d           t          |          }|                    d          dk    r)t	          d|                    d          d d	          |                     |                    d
                    |d
<   g }fd}|                     |          }| j         5  | j                            df                                          }ddd           n# 1 swxY w Y   |r|d         |d         nd}t          |          ||dS )u  Soft-delete all messages with id >= ``target_message_id`` in *session_id*.

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

        Returns a dict::

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

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

        Always increments ``sessions.rewind_count`` — even when the
        target is already inactive — so the counter accurately reflects
        the number of rewind operations performed against the session.
        Idempotent on the ``active`` flag: re-rewinding past the same
        target is a no-op on row state but still bumps the counter.
        z6SELECT * FROM messages WHERE id = ? AND session_id = ?Nzmessage z not found in session r#  r$  z1rewind target must be a 'user' message (got role=z, id=r   r&  c                    |                      df          }d |                                D             }|r9d                    d |D                       }|                      d| d|           |                      df           |S )NzGSELECT id FROM messages WHERE session_id = ? AND id >= ? AND active = 1c                     g | ]
}|d          S r  rK   rr  s     rF   r   z<SessionDB.rewind_to_message.<locals>._do.<locals>.<listcomp>*      333A1Q4333rL   r   c              3      K   | ]}d V  dS r  rK   r  s     rF   r+  z;SessionDB.rewind_to_message.<locals>._do.<locals>.<genexpr>*  "      '9'9'9'9'9'9'9'9rL   z,UPDATE messages SET active = 0 WHERE id IN (r   zMUPDATE sessions SET rewind_count = COALESCE(rewind_count, 0) + 1 WHERE id = ?)r   r   r   )r   r   r   r  rd   r}  s       rF   r  z(SessionDB.rewind_to_message.<locals>._do*  s    \\B./ F
 43!2!2333C "xx'9'9S'9'9'999R<RRR   LL  
 JrL   z@SELECT MAX(id) FROM messages WHERE session_id = ? AND active = 1r   )rewound_counttarget_messagenew_head_id)
rN  r:  r   rQ  rw   r,  r@   r  r  r   )	rZ   rd   r}  r   
target_rowrewoundr  head_rowr  s	    ``      rF   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  rK   rr  s     rF   r   z:SessionDB.restore_rewound.<locals>._do.<locals>.<listcomp>+  r  rL   r   c              3      K   | ]}d V  dS r  rK   r  s     rF   r+  z9SessionDB.restore_rewound.<locals>._do.<locals>.<genexpr>+  r  rL   z,UPDATE messages SET active = 1 WHERE id IN (r   )r   r   r   r   )r   r   r   r  rd   r  s       rF   r  z&SessionDB.restore_rewound.<locals>._do+  s    \\B-. F
 43!2!2333C "xx'9'9S'9'9'999R<RRR   s88OrL   r
  )rZ   rd   r  r  s    `` rF   restore_rewoundzSessionDB.restore_rewound+  s8    	 	 	 	 	 	 ""3'''rL   r   c                 .    dt          d           d}g }g }|r*|                    d           |                    |           |r<t          |          \  }}	|                    |           |                    |	           |rdd                    |           nd}
|                    ||g            j        5   j                            | |
 d|          } fd	|                                D             cd
d
d
           S # 1 swxY w Y   d
S )a  List sessions, optionally filtered by source.

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

        Pass ``workspace_key`` to scope rows to one workspace - matching
        :func:`workspace_key` semantics (git repo root, else cwd). Used by
        ``hermes -c``/``--resume`` so the "last" session is the last one in
        the *current* workspace, not the global MRU.
        zMSELECT s.*, COALESCE(sp.prompt, s.system_prompt) AS _system_prompt_resolved, rn  z^ AS last_active FROM sessions s LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash s.source = ?ry  rL  rp   zI ORDER BY last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ?c                 :    g | ]}                     |          S rK   rB  )r   r   rZ   s     rF   r   z-SessionDB.search_sessions.<locals>.<listcomp>R+  s'    MMMCD**3//MMMrL   N)	r"   r  r   r  r   rN  r:  r   r   )rZ   r  rE   rU  r   select_with_last_activerz  rF  	ws_clause	ws_paramsr~  r   s   `           rF   search_sessionszSessionDB.search_sessions'+  s   &M',,M M M 	   	"  000MM&!!! 	%#8#G#G Iy  +++MM)$$$?LT;gll=99;;;RT	ufo&&&Z 	N 	NZ''* [[ [ [ 	 F NMMM6??;L;LMMM	N 	N 	N 	N 	N 	N 	N 	N 	N 	N 	N 	N 	N 	N 	N 	N 	N 	Ns   =A D

DDexclude_childrenc	                    g }	g }
|r?|	                     t                     |	                     t          d           d           |r|gnt          |pg           }|rMd                    d |D                       }|	                     d| d           |
                    |           |rMd                    d |D                       }|	                     d| d           |
                    |           |r<t          |          \  }}|	                     |           |
                    |           |d	k    r*|	                     d
           |
                     |           |r|	                     d           n|s|	                     d           |	rdd                    |	           nd}| j        5  | j        	                    d| |
          }|
                                d	         cddd           S # 1 swxY w Y   dS )u&  Count sessions, optionally filtered by source.

        Pass ``exclude_children=True`` to count only the conversations that
        ``list_sessions_rich`` surfaces (root + branch/reset 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`  ra  r   c              3      K   | ]}d V  dS r  rK   r  s     rF   r+  z*SessionDB.session_count.<locals>.<genexpr>|+  rc  rL   rd  r   c              3      K   | ]}d V  dS r  rK   r  s     rF   r+  z*SessionDB.session_count.<locals>.<genexpr>+  rc  rL   rf  r   rg  rh  ri  ry  rL  rp   zSELECT COUNT(*) FROM sessions sN)r  r   r   r   r   r  r   rN  r:  r   rQ  )rZ   r  rS  r   rI  rJ  rY  r  rT  rz  rF  r{  r  r|  r}  r~  r   s                    rF   session_countzSessionDB.session_countX+  s   2  	U   !4555  $78H$I$I!S!S!STTT&,E6(($w}"2E2E 	+88#A#A#A#A#AAAL  !@!@!@!@AAAMM/*** 	+88#A#A#A#A#AAAL  !D\!D!D!DEEEMM/*** 	)$6z$B$B!FM  (((MM-(((q    !7888MM+,,, 	3  !12222! 	3  !1222?LT;gll=99;;;RT	Z 	( 	(Z''(U)(U(UW]^^F??$$Q'	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	(s   :8G??HHr  c                     | j         5  | j                            d|f          }|                                }ddd           n# 1 swxY w Y   t	          |          |k    S )u  Check if at least N sessions exist (archived included).

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

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

        ``exclude_children=True`` mirrors ``list_sessions_rich`` visibility
        (roots + branch/reset sessions, excluding sub-agent runs, delegates,
        and compression continuations) so the source counts match what the
        Sessions page actually lists.
        r`  ra  rh  ri  ry  rL  rp   NzSessionDB connection is closedzYSELECT COALESCE(NULLIF(s.source, ''), 'cli') AS source, COUNT(*) AS count FROM sessions szC GROUP BY COALESCE(NULLIF(s.source, ''), 'cli') ORDER BY count DESCc                 d    i | ]-}t          |d                    t          |d         pd          .S )r  r  r   )r_   rA   r  s     rF   r  z5SessionDB.session_count_by_source.<locals>.<dictcomp>+  s7    KKKsCM""CG(9$:$:KKKrL   )	r  r   r   r   rN  r:  r  r   r   )rZ   rJ  rY  r  rz  rF  r~  r  s           rF   session_count_by_sourcez!SessionDB.session_count_by_source+  s   (  	U  !4555  $78H$I$I!S!S!STTT 	3  !12222! 	3  !1222?LT;gll=99;;;RT	Z 		 		z!"#CDDD:%%&"+& & &   hjj 		 		 		 		 		 		 		 		 		 		 		 		 		 		 		 LKdKKKKs   AC,,C03C0c                     | j         5  |r| j                            d|f          }n| j                            d          }|                                d         cddd           S # 1 swxY w Y   dS )z2Count messages, optionally for a specific session.z2SELECT COUNT(*) FROM messages WHERE session_id = ?r  r   Nr  r  s      rF   rU   zSessionDB.message_count+  s    Z 	( 	( M++H:-  ++,KLL??$$Q'	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	(s   AA((A,/A,c                     | j         5  | j                            d||f          }|                                ducddd           S # 1 swxY w Y   dS )ab  Check if a message with the given platform_message_id exists.

        Uses the idx_messages_platform_msg_id partial index for efficient
        lookup. Used by the gateway's transient-failure dedupe guard (#47237)
        to skip re-persisting a user message that was already saved on a
        prior retry of the same inbound platform message.
        zOSELECT 1 FROM messages WHERE session_id = ? AND platform_message_id = ? LIMIT 1Nr  )rZ   rd   r  r   s       rF   has_platform_message_idz!SessionDB.has_platform_message_id+  s     Z 	1 	1Z''K01 F
 ??$$D0	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1s   3AAArf  c                    |                     d          dk    rdS |                     d          }|sdS 	 t          |t                    rt          j        |          n|}n# t
          t          j        f$ r Y dS w xY wt          |t                    sdS |                     d          }|                     d          }|                     d          }|r||k    p||k    S |d	up|d	uS )
u  True when ``session`` is a branch, delegate, or tool child of its parent.

        Markers only count as a fork when they point at ``parent_session_id``.
        Compression copies ``model_config`` onto the continuation
        (``publish_compression_child`` callers pass
        ``agent._session_init_model_config``), so a delegate's continuation
        carries ``_delegate_from=<the delegate's own parent>``. Presence-only
        matching would treat that real continuation as a fork — the same
        misclassification ``_NON_CONTINUATION_CHILD_FILTER_SQL`` already
        avoids by binding both markers to the queried parent.
        r  r  Tr   Fr  ru  _delegate_fromN)r@   r   r_   r  r  rv   r`  r,  )rZ   rf  rz  r  r  branched	delegateds          rF   r  z%SessionDB._is_explicit_fork_child_row+  s    ;;x  F**4kk.)) 	5	%/S%9%9B$*S///sCC4/0 	 	 	55	#t$$ 	5KK 344	77+,,GG,--	 	Cy(BI,BBt#<y'<<s   +A" "A<;A<r  c                     |                     d          }|r|                     |          rdS |                     |          }t          |o|                     d          dk              S )Nr  Frt  r  )r@   r  rF  r   )rZ   r  r  r   s       rF   _is_compression_child_rowz#SessionDB._is_compression_child_row,  sm    II122	 	D<<UCC 	5!!),,FHvzz,77=HIIIrL   c                    |                      |          }|r|                     |          r|r|gng S |}|d         h}|                     |          rZ|                      |d                   }|r
|d         |v rn2|}|                    |d                    |                     |          Z|d         g}|d         h}|}|                    d          dk    r| j        5  | j                            d|d         f                                          }	ddd           n# 1 swxY w Y   d}
|	D ]*}t          |          }|                     |          r|}
 n+|
r
|
d         |v rn^|
                    |
d                    |                    |
d                    |
}|d         |k    r|                    d          dk    ||v r|n|gS )z@Return compression ancestors through tip in chronological order.r   r  rt  r  z
                    SELECT * FROM sessions
                    WHERE parent_session_id = ?
                    ORDER BY started_at ASC
                    N)rF  r  r  r  r@   rN  r:  r   r   r,  r  )rZ   rd   rf  r   	ancestorsr   rV  r  r  r  
next_childr   	candidates                rF   get_compression_lineagez!SessionDB.get_compression_lineage,  so   "":.. 	3$::7CC 	3#*2J<<2$ZL	,,T22 	&%%d+>&?@@F VD\Y66DMM$t*%%% ,,T22 	& :,T
|kk,''=88  z))
 T]$  (**                J   II	11)<< !*JE  D!1T!9!9NN:d+,,,HHZ%&&& Gt}
** 1 kk,''=882 %//wwj\As   "5D##D'*D'c                 <    fd}|                      |           dS )z9Delete all messages for a session and reset its counters.c                 d    |                      df           |                      df           d S )Nr  r	  r)  r  s    rF   r  z%SessionDB.clear_messages.<locals>._doG,  sJ    LL;j]   LLY    rL   Nr
  r  s    ` rF   clear_messageszSessionDB.clear_messagesE,  s8    	 	 	 	 	 	C     rL   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.jsonlTr   request_dump_z_*.json)r%  r   glob)r7  rd   rO  r)  s       rF   r  zSessionDB._remove_session_filesQ,  s    F) 	 	F*6f666AD))))   	!&&'Jz'J'J'JKK  HHH----   D 
  	 	 	DD	sA   *
77B  A.-B  .
A;8B  :A;;B   
BBc                    | j         5  | j                            d|f                                          }|sg cddd           S t	          | j        |g          }ddd           n# 1 swxY w Y   |gt          |          S )aE  Return every session row that :meth:`delete_session` would remove.

        The requested session is first, followed by its recursively discovered
        delegate/subagent children. Branch and compression children are not
        included because deletion preserves them by orphaning their parent
        reference.
        r  N)rN  r:  r   rQ  r   r9  )rZ   rd   rG  delegate_idss       rF   get_session_delete_targetsz$SessionDB.get_session_delete_targetsl,  s    Z 	Q 	QZ''=
} hjj   	Q 	Q 	Q 	Q 	Q 	Q 	Q 	Q 7tzJ<PPL	Q 	Q 	Q 	Q 	Q 	Q 	Q 	Q 	Q 	Q 	Q 	Q 	Q 	Q 	Q 2VL1122s   2A)A))A-0A-expected_delete_idsc                      g |t          |          nd fd}                     |          }|r1D ]}                     ||                                |           t          |          S )u  Delete a session and all its messages.

        Delegate subagent children (``model_config._delegate_from``) are
        cascade-deleted with the parent so they never resurface in session
        pickers as orphaned rows. Branch / compression children are orphaned
        (``parent_session_id → NULL``) so they remain accessible independently.
        When *sessions_dir* is provided, also removes on-disk transcript
        files (``.json`` / ``.jsonl`` / ``request_dump_*``) for every deleted
        session. When *expected_delete_ids* is provided, deletion proceeds only
        if the parent plus delegate cascade still matches that exact set. This
        lets export-before-delete callers fail closed if a new delegate appears
        after they materialize their archive. The delegate tree is re-walked
        inside the write transaction on purpose (TOCTOU guard); the cost is
        accepted for correctness. Returns True if the session was found and
        deleted.
        Nc                    |                      df          }|                                dS ht          | g          }|k    rdS                     t	          | g                     |                      df           |                      df           |                      df                               |            dS )Nr  FzHUPDATE sessions SET parent_session_id = NULL WHERE parent_session_id = ?r  !DELETE FROM sessions WHERE id = ?T)r   rQ  r   r  r   r*  )r   r   
actual_idsexpected_idsremoved_delegate_idsrZ   rd   s      rF   r  z%SessionDB.delete_session.<locals>._do,  s    \\=
} F   (u'0
|DD
 -- 5 ''(A$(U(UVVVLL.  
 LLDzmTTTLL<zmLLL44T:::4rL   )r   r  r  r   )	rZ   rd   r7  r  r  r=  delegate_idr  r  s	   ``     @@rF   r:  zSessionDB.delete_session},  s    , +-(;(GC#$$$T 		 	 	 	 	 	 	 	2 %%c** 	A3 F F**<EEEE&&|Z@@@G}}rL   c                       fd}                      |          }|r                     |           t          |          S )u  Delete *session_id* only when it never gained resumable content.

        A session is considered empty when it has no messages and no
        user-assigned title. Used by CLI exit / session-rotation paths so
        immediately-started-and-quit sessions don't pile up in ``/resume``
        and ``hermes sessions list`` output. (Pattern ported from
        google-gemini/gemini-cli#27770.)

        The emptiness check and delete run in one transaction, so a message
        flushed concurrently by another writer can't be lost. Sessions with
        children (delegate subagent runs) are preserved — a parent that
        spawned work is not "empty" even if its own transcript never
        flushed. Returns True if the session was deleted.
        c                     |                      df          }|j        dk    r                    |            |j        dk    S )Na  
                DELETE FROM sessions
                WHERE id = ?
                  AND title IS NULL
                  AND NOT EXISTS (
                      SELECT 1 FROM messages WHERE messages.session_id = sessions.id
                  )
                  AND NOT EXISTS (
                      SELECT 1 FROM sessions child
                      WHERE child.parent_session_id = sessions.id
                  )
                r   )r   r~  r*  )r   r   rZ   rd   s     rF   r  z.SessionDB.delete_session_if_empty.<locals>._do,  sR    \\  F ""88>>>?Q&&rL   )r  r  r   )rZ   rd   r7  r  r=  s   ``   rF   delete_session_if_emptyz!SessionDB.delete_session_if_empty,  sb    &	' 	' 	' 	' 	' 	'( %%c** 	A&&|Z@@@G}}rL   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 rK   )r   r_   r   s     rF   r   z,SessionDB.delete_sessions.<locals>.<setcomp>-  s-    VVV3C9M9MVRUV3VVVrL   c                 d   d                     dt                    z            }|                     d| d          }d |                                D             }|sdS d                     dt          |          z            }                    t          | |                     |                     d| d|           |                     d| d|           |                     d	| d|                               |                                |           t          |          S )
Nr   r   z%SELECT id FROM sessions WHERE id IN (r   c                     g | ]
}|d          S r   rK   r  s     rF   r   z:SessionDB.delete_sessions.<locals>._do.<locals>.<listcomp>-  s    ???cD	???rL   r   r   r   r   )r   r   r   r   r  r   r*  )	r   r  r   r  existing_placeholdersr  r  rZ   
unique_idss	        rF   r  z&SessionDB.delete_sessions.<locals>._do-  sn   88C#j//$9::L \\GGGG F @?V__->->???H q$'HHS3x==-@$A$A! ''(A$(Q(QRRR LLH/DH H H  
 LLU=RUUU   LLM5JMMM   44T:::x(((x== rL   )r   r  r  )	rZ   r0  r7  r  r  r   r  r  r  s	   `     @@@rF   delete_sessionszSessionDB.delete_sessions,  s    :  	1 VV+VVVWW
 	1!#*,"	! "	! "	! "	! "	! "	! "	! "	!H ##C((' 	: 	:C&&|S9999 	: 	:C&&|S9999rL   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  )rZ   r   s     rF   count_empty_sessionszSessionDB.count_empty_sessions8-  s      Z 	( 	(Z''# F ??$$Q'	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	(s   4A		AAc                 z     g  fd}                      |          }D ]}                     ||           |S )uJ  Delete every empty, ended, non-archived session.

        Mirrors :meth:`prune_sessions`' transactional shape:

        * Selects candidate IDs first (``message_count = 0`` AND
          ``ended_at IS NOT NULL`` AND ``archived = 0``) so we never
          touch a live session or one the user deliberately archived.
        * Orphans any child whose parent is in the kill list — children
          of an empty parent are kept and re-parented to ``NULL`` rather
          than cascade-deleted, matching ``delete_session`` /
          ``prune_sessions`` semantics so branch/subagent transcripts
          survive an inadvertent parent cleanup.
        * Deletes the rows in a single ``_execute_write`` callback so
          the operation is atomic — a partial failure (e.g. SIGKILL
          mid-loop) doesn't leave the DB in a "messages-deleted but
          session-row-still-there" half-state.
        * Cleans up on-disk transcript files (``.json`` / ``.jsonl`` /
          ``request_dump_*``) outside the DB transaction when
          ``sessions_dir`` is provided. Empty sessions don't typically
          have transcript files, but the gateway can leave a stub
          ``request_dump_*`` if it crashed before the first reply —
          so we still sweep, matching ``prune_sessions``.

        Returns the number of sessions deleted.
        c                    |                      d          }d |                                D             }|sdS d                    dt          |          z            }|                      d| dt	          |                     |D ]E}|                      d|f           |                      d	|f                               |           F                    |            t          |          S )
NzYSELECT id FROM sessions WHERE message_count = 0 AND ended_at IS NOT NULL AND archived = 0c                     h | ]
}|d          S r   rK   r  s     rF   r   z?SessionDB.delete_empty_sessions.<locals>._do.<locals>.<setcomp>w-      BBB3t9BBBrL   r   r   r   r   r   r  r  r   r   r   r   r   r  r*  )r   r   r0  r  r   r  rZ   s        rF   r  z,SessionDB.delete_empty_sessions.<locals>._dop-  s    \\# F CB0A0ABBBK q88C#k*:*:$:;;LLL?/;? ? ?[!!   # 	( 	(
 ?#   @3&III""3''''44T:::{###rL   )r  r  )rZ   r7  r  r  r   r  s   `    @rF   delete_empty_sessionszSessionDB.delete_empty_sessionsQ-  sl    : "$	$ 	$ 	$ 	$ 	$ 	$@ ##C(( 	: 	:C&&|S9999rL   )last_active_beforelast_active_afterstarted_beforestarted_afterr  
title_likert  r   min_messagesrh  r!  
model_likerP  r  r  r  branch_like
min_tokens
max_tokensmin_costmax_costmin_tool_callsmax_tool_callsr]  r  r  r  r  r  r  r  r  r  r  r  r  r  r  c                 x	   dg}g }| *|                     d           |                     |            |*|                     d           |                     |           |*|                     d           |                     |           |*|                     d           |                     |           |r*|                     d           |                     |           |rM|                     d           |                     d	t          |                                           d	           |r*|                     d
           |                     |           |r<t          |          \  }}|                     |           |                    |           |*|                     d           |                     |           |	*|                     d           |                     |	           |rM|                     d           |                     d	t          |                                           d	           |r<|                     d           |                     |                                           |r*|                     d           |                     |           |r*|                     d           |                     |           |r*|                     d           |                     |           |rM|                     d           |                     d	t          |                                           d	           |*|                     d           |                     |           |*|                     d           |                     |           |*|                     d           |                     |           |*|                     d           |                     |           |*|                     d           |                     |           |*|                     d           |                     |           |
du r|                     d           n|
du r|                     d           |s|                     d           d                    |          |fS )u  Build the shared WHERE clause for bulk prune/archive selection.

        All filters AND together. Only ended sessions are ever candidates
        (``ended_at IS NOT NULL``) so a live session is never selected.
        ``archived`` is a tri-state: ``None`` = both, ``True`` = only
        archived rows, ``False`` = only unarchived rows.

        String matching conventions: ``model_like`` / ``branch_like`` /
        ``title_like`` are case-insensitive substring matches (model slugs
        and branch names vary in prefix format); ``provider`` / ``user_id``
        / ``chat_id`` / ``chat_type`` / ``source`` / ``end_reason`` are
        exact (case-insensitive for provider). Token bounds apply to
        ``input_tokens + output_tokens``; cost bounds apply to
        ``COALESCE(actual_cost_usd, estimated_cost_usd)``.

        The clause references the ``s`` table alias — callers must select
        ``FROM sessions s``.
        s.ended_at IS NOT NULLNzCOALESCE(
                       (SELECT MAX(m.timestamp) FROM messages m
                        WHERE m.session_id = s.id),
                       s.started_at
                   ) < ?zCOALESCE(
                       (SELECT MAX(m.timestamp) FROM messages m
                        WHERE m.session_id = s.id),
                       s.started_at
                   ) >= ?zs.started_at < ?zs.started_at >= ?r  z.LOWER(COALESCE(s.title, '')) LIKE ? ESCAPE '\'r  zs.end_reason = ?rg  zs.message_count <= ?z.LOWER(COALESCE(s.model, '')) LIKE ? ESCAPE '\'z+LOWER(COALESCE(s.billing_provider, '')) = ?zs.user_id = ?zs.chat_id = ?zs.chat_type = ?z3LOWER(COALESCE(s.git_branch, '')) LIKE ? ESCAPE '\'zA(COALESCE(s.input_tokens, 0) + COALESCE(s.output_tokens, 0)) >= ?zA(COALESCE(s.input_tokens, 0) + COALESCE(s.output_tokens, 0)) <= ?z9COALESCE(s.actual_cost_usd, s.estimated_cost_usd, 0) >= ?z9COALESCE(s.actual_cost_usd, s.estimated_cost_usd, 0) <= ?z#COALESCE(s.tool_call_count, 0) >= ?z#COALESCE(s.tool_call_count, 0) <= ?Trh  Fri  zCOALESCE(s.pinned, 0) = 0rL  )r  r   r   r   r  r   )r  r  r  r  r  r  rt  r   r  rh  r!  r  rP  r  r  r  r  r  r  r  r  r  r  r]  clausesrF  r|  r}  s                               rF   _prune_filter_wherezSessionDB._prune_filter_where-  s   \ ,,)NN   MM,---(NN   MM+,,,%NN-...MM.)))$NN.///MM-((( 	"NN>***MM&!!! 	CNNLMMMMMAl:+;+;+=+=>>AAABBB 	&NN-...MM*%%% 	)$6z$B$B!FMNN6"""MM-(((#NN1222MM,'''#NN1222MM,''' 	CNNLMMMMMAl:+;+;+=+=>>AAABBB 	,NNHIIIMM(..**+++ 	#NN?+++MM'""" 	#NN?+++MM'""" 	%NN,---MM)$$$ 	DNNQRRRMMBl;+<+<+>+>??BBBCCC!NNS   MM*%%%!NNS   MM*%%%NNK   MM(###NNK   MM(###%NN@AAAMM.)))%NN@AAAMM.)))tNN+,,,,NN+,,,  	8NN6777||G$$f,,rL   filtersc                     |                     d          5|                     d          "| "t          j                    | dz  z
  |d<   dS dS dS dS )z@Translate the legacy age window into the shared activity filter.r  Nr  r  )r@   rT  )r(  r  s     rF   _apply_prune_age_filterz!SessionDB._apply_prune_age_filter).  sh     KK,--5,--5+,0IKK%'-G()))	 6555++rL   c                    |                      ||            | j        dd|i|\  }}| j        5  | j                            d| d|          }d |                                D             cddd           S # 1 swxY w Y   dS )az  Return the sessions a matching :meth:`prune_sessions` /
        :meth:`archive_sessions` call would touch, without modifying anything.

        Backs ``--dry-run`` and pre-confirmation counts. Accepts the same
        keyword filters as :meth:`_prune_filter_where` (unknown names raise
        ``TypeError`` there). Rows are ordered oldest-first and carry
        ``id, source, title, model, started_at, last_active, ended_at,
        message_count, archived``. ``older_than_days`` is an inactivity
        threshold: it uses the latest message timestamp, falling back to
        ``started_at`` for sessions without messages.
        r  a  SELECT s.id, s.source, s.title, s.model, s.started_at,
                           COALESCE(
                               (SELECT MAX(m.timestamp) FROM messages m
                                WHERE m.session_id = s.id),
                               s.started_at
                           ) AS last_active,
                           s.ended_at, s.message_count, s.archived
                    FROM sessions s WHERE z?
                    ORDER BY last_active ASC, s.started_at ASCc                 ,    g | ]}t          |          S rK   r,  r  s     rF   r   z3SessionDB.list_prune_candidates.<locals>.<listcomp>W.  s    ;;;#DII;;;rL   NrK   )r  r  rN  r:  r   r   rZ   r(  r  r  rP  rF  r   s          rF   list_prune_candidateszSessionDB.list_prune_candidates7.  s    " 	$$_g>>>00JJJ'JJvZ 	< 	<Z''B ,1B B B  F <;):):;;;	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	<s   =A::A>A>c                    |                      ||            | j        dd|i|\  }}| j        5  | j                            d| |          }t          |                                d                   cddd           S # 1 swxY w Y   dS )a  Count sessions a matching prune/archive would touch.

        Same filter surface as :meth:`list_prune_candidates` (including the
        ``include_pinned`` tri-state), but returns only a count. Used by the
        CLI to report how many pinned sessions are being spared.
        r  &SELECT COUNT(*) FROM sessions s WHERE r   NrK   )r  r  rN  r:  r   rA   rQ  r  s          rF   count_prune_matcheszSessionDB.count_prune_matchesY.  s     	$$_g>>>00JJJ'JJvZ 	- 	-Z''@@@& F v((+,,		- 	- 	- 	- 	- 	- 	- 	- 	- 	- 	- 	- 	- 	- 	- 	- 	- 	-s   ABB	Bc                    |                      ||            | j        dd|i|\  }}d}|                    |          st          d          d|t	          |          d          }| j        5  | j                            d| |          }t          |	                                d                   cddd           S # 1 swxY w Y   dS )	aR  Count open sessions excluded from a matching bulk prune.

        This applies every normal prune filter, but inverts only the
        ``ended_at`` safety guard. It is visibility-only: callers can explain
        why an otherwise matching session was skipped without making live
        sessions eligible for destructive pruning.
        r  r  z0prune filter lost its ended-session safety guardzs.ended_at IS NULLNr  r   rK   )
r  r  r(  r  r   rN  r:  r   rA   rQ  )	rZ   r(  r  r  rP  rF  ended_guard
open_wherer   s	            rF   count_open_prune_matchesz"SessionDB.count_open_prune_matchesm.  s2    	$$_g>>>00JJJ'JJv.,, 	SQRRRD%K0@0@0A0A*BDD
Z 	- 	-Z''EEEv F v((+,,		- 	- 	- 	- 	- 	- 	- 	- 	- 	- 	- 	- 	- 	- 	- 	- 	- 	-s   0ACC	Cc                     |                     dd            | j        d||d|}|D ]}|                     |d         d           t          |          S )a  Bulk-archive (soft-hide) every session matching the filters.

        Same filter surface as :meth:`prune_sessions`, but instead of deleting
        rows it flips ``archived = 1`` via :meth:`set_session_archived` so
        each match's compression lineage is archived as a unit (an unarchived
        compression root would otherwise resurrect the conversation in
        Desktop's projected list). Nothing is deleted; messages and transcript
        files are untouched. Returns the number of sessions matched.

        ``archived`` defaults to ``False`` here (only select rows not yet
        archived) so repeat runs are idempotent no-ops.
        r!  F)r(  r  r   TrK   )
setdefaultr  r&  r   )rZ   r(  r  r  r  r   s         rF   archive_sessionszSessionDB.archive_sessions.  s    $ 	:u---)t) 
+F
 
>E
 
  	7 	7C%%c$i66664yyrL   exclude_pinned	idle_daysr  c          	         ||dk     rdS t          j                     t          |          dz  z
  }|rdnd}| j        5  | j                            d| dt          d           d	|f                                          }ddd           n# 1 swxY w Y   d
 |D             }|D ]}|                     |d           t          |          S )uj  Archive every session untouched for at least ``idle_days`` days.

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

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

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

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

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

        Only prunes ended sessions (not active ones).  Child sessions outside
        the prune window are orphaned (parent_session_id set to NULL) rather
        than cascade-deleted.  When *sessions_dir* is provided, also removes
        on-disk transcript files (``.json`` / ``.jsonl`` /
        ``request_dump_*``) for every pruned session, outside the DB
        transaction.
        r  c                    |                      d           }d |                                D             }|sdS d                    dt          |          z            }|                      d| dt	          |                     |D ]E}|                      d|f           |                      d	|f                               |           F                    |            t          |          S )
Nz"SELECT s.id FROM sessions s WHERE c                     h | ]
}|d          S r   rK   r  s     rF   r   z8SessionDB.prune_sessions.<locals>._do.<locals>.<setcomp>/  r  rL   r   r   r   r   r   r  r  r  )	r   r   r0  r  r   r  rZ   rP  where_paramss	        rF   r  z%SessionDB.prune_sessions.<locals>._do.  s    \\<U<<l F CB0A0ABBBK q 88C#k*:*:$:;;LLL?/;? ? ?[!!   # ( (H3&QQQ@3&III""3''''44T:::{###rL   rK   )r  r  r  r  )rZ   r(  r  r7  r  r  r  r   r  rP  r  s   `       @@@rF   prune_sessionszSessionDB.prune_sessions.  s    V 	$$_g>>>6d6PPfPPP|!#	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$0 ##C(( 	: 	:C&&|S9999rL   )dry_runr  r  r  c                *  
 dt           t                   fd
|                                 5 } 
|          }ddd           n# 1 swxY w Y   |rdt          |          |ddS |sddg ddS d}|rddl}|j                                                            d          }| j                            | j        j	         d	|           }| j
        5  | j                            d
t          |          f           ddd           n# 1 swxY w Y   t          |          }t                              d|           
fd}	|                     |	          }|r(t                              dt          |                     dt          |          ||dS )u  Permanently clear bare tool-call marker content (e.g. "[memory]")
        left in the ``messages`` table by sessions persisted before the
        #78148 fix in ``agent.conversation_loop``.

        ``_strip_stale_tool_call_markers`` already repairs this in memory on
        every session load (see ``_rows_to_conversation``), so running this
        is optional — but for long-lived sessions the same rows get
        re-scanned and re-repaired on every resume, which is wasted work
        and keeps the contaminated bytes sitting in the DB (and in any
        downstream cache/backup snapshot of it) indefinitely. This rewrites
        the affected rows once, in place.

        Only the ``content`` column is touched — ``role``, ``tool_calls``,
        and every other column on the row are left exactly as they are, so
        provider tool_call/tool_result pairing is unaffected.

        Unlike the in-memory repair, this UPDATE is permanent and can't be
        undone from within the DB. Since ``backup`` defaults to True, a
        timestamped full snapshot is taken via ``VACUUM INTO`` (safe against
        a live connection, unlike the raw-copy ``_backup_db_file`` used for
        malformed-schema repair) before any row is touched — mirroring
        ``repair_state_db_schema``'s backup-by-default convention for
        destructive state.db operations. No snapshot is taken when there is
        nothing to change.

        With ``dry_run=True``, reports the affected row count/ids without
        writing or backing up (read-only, no write lock taken).

        Returns ``{"dry_run": bool, "rows_affected": int, "row_ids": [...],
        "backup_path": str|None}``.
        r:   c                 *   |                      d          }g }|                                D ]f}|d         }t          |t                    rGt                              |                                          r|                    |d                    g|S )NziSELECT id, content FROM messages WHERE role = 'assistant' AND tool_calls IS NOT NULL AND tool_calls != ''r&  r   )r   r   r   r_   r9  r:  r   r  )r   r   affectedr   r&  s        rF   _find_affectedz?SessionDB.purge_stale_tool_call_markers.<locals>._find_affected>/  s    \\[ F #%H(( / /i.gs++ /0J0T0TU\UbUbUdUd0e0e /OOCI...OrL   NT)r  rows_affectedrow_idsrK  Fr   rB  z.pre-clean-markers-backup-zVACUUM INTO ?z3Backed up state.db to %s before clean-markers writec                      |           }|r?d                     dt          |          z            }|                     d| d|           |S )Nr   r   z.UPDATE messages SET content = '' WHERE id IN (r   )r   r   r   )r   r   r  r  s      rF   r  z4SessionDB.purge_stale_tool_call_markers.<locals>._doj/  sc     .&&C "xxc#hh77T\TTT   JrL   zIPermanently cleared %d stale tool-call marker row(s) in state.db (#78148))r   rA   r  r   r&  r)  rF  r  r  r|   rN  r:  r   r_   r=  r>  r  )rZ   r  r  r   affected_idsrK  r&  rJ  r  r  r  s             @rF   purge_stale_tool_call_markersz'SessionDB.purge_stale_tool_call_markers/  s   F
	DI 
	 
	 
	 
	 ^^ 	0)>$//L	0 	0 	0 	0 	0 	0 	0 	0 	0 	0 	0 	0 	0 	0 	0  	!$\!2!2'#	    	 !"#	   &* 
	\OOO%))++44_EEE<))<$GGGG D  B B
""?SYYLAAAB B B B B B B B B B B B B B Bd))KKKM{[[[	 	 	 	 	 **3// 	KK[L!!  
  ..#&	
 
 	
s#   AAA*DDD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.r  NrD   r   )rN  r:  r   rQ  r   rR  r;  )rZ   r8   r   s      rF   get_metazSessionDB.get_meta/  s     Z 	 	*$$<sf hjj 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 ;4)#w{;;Gs7||QGr   r   c                x    ||                     df           dS fd}|                     |           dS )ut  Write a value to the state_meta key/value store.

        When ``cursor`` is provided the write is issued on that cursor
        inline (used during ``_init_schema``, which already holds an open
        transaction — routing through ``_execute_write`` there would nest
        BEGIN IMMEDIATE and deadlock). Otherwise a normal write transaction
        is used.
        Nr  c                 8    |                      df           d S )Nr  r)  )r   r8   rD   s    rF   r  zSessionDB.set_meta.<locals>._do/  s0    LLHe    rL   )r   r  )rZ   r8   rD   r   r  s    ``  rF   set_metazSessionDB.set_meta/  sp     NNHe  
 F	 	 	 	 	 	 	C     rL   workspaces_rootc                      t          |                              d          sdS d                                dk    rdS  fd}                     |          S )u  Retag legacy kanban worker rows from ``cli`` to ``kanban``.

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

        Gated per workspaces root (``state_meta``) so each board reclaims its
        own rows exactly once. Returns the number of rows retagged.
        r   r   zkanban_worker_source_retagged:r  c                     |                      dt                    dz   f          }|j        pd}                    d|           |S )NzaUPDATE sessions SET source = 'kanban' WHERE source = 'cli' AND (cwd = ? OR cwd LIKE ? ESCAPE '\')r   r   r  r  )r   r   r~  r  )r   r   retaggedgater   rZ   s      rF   r  z3SessionDB.retag_kanban_worker_sessions.<locals>._do/  s\    \\Of--45 F +!HMM$FM333OrL   )r_   r   r  r  )rZ   r  r  r  r   s   `  @@rF   retag_kanban_worker_sessionsz&SessionDB.retag_kanban_worker_sessions/  s     _%%,,U33 	1888==#%%1
	 
	 
	 
	 
	 
	 
	 ""3'''rL   r   c                 >   |sg S |                     dd                               dd                               dd          }| j        5  | j                            d|dz   f                                          }ddd           n# 1 swxY w Y   d	 |D             S )
uu  Return ``[(key, value), ...]`` for state_meta keys with ``prefix``.

        Used by feature stores that persist one row per session under a
        namespaced key (e.g. ``loop:<session_id>``) and need to enumerate
        them across sessions (the gateway's idle /loop wakeup watcher).
        ``prefix`` is matched literally — LIKE wildcards in it are escaped.
        r   z\\r  z\%r[  r  z=SELECT key, value FROM state_meta WHERE key LIKE ? ESCAPE '\'Nc                 .    g | ]}|d          |d         fS )r   rq   rK   r  s     rF   r   z.SessionDB.list_meta_prefix.<locals>.<listcomp>/  s%    111SQQ 111rL   )r   rN  r:  r   r   )rZ   r   r  r  s       rF   list_meta_prefixzSessionDB.list_meta_prefix/  s      	I..v..66sEBBJJ3PUVVZ 	 	:%%P3   hjj 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	
 21D1111s   
2BBBc                 6    d }|                      |           dS )uF  Create Telegram DM topic-mode tables on explicit /topic opt-in.

        This migration is deliberately not part of automatic SessionDB startup
        reconciliation. Operators must be able to upgrade Hermes, keep the old
        Telegram bot behavior running, and only mutate topic-mode state when the
        user executes /topic to opt into the feature.

        Schema versions:
          v1 — initial shape (no ON DELETE CASCADE on session_id FK)
          v2 — session_id FK gets ON DELETE CASCADE so session pruning
               automatically clears bindings.
        c                    |                      d           |                     dd                                          }|r<t          |d                                                   rt          |d                   nd}|dk     rW|                     d                                          }t          d |D                       }|r|                      d           |                     d	d
           d S )Na  
                CREATE TABLE IF NOT EXISTS telegram_dm_topic_mode (
                    chat_id TEXT PRIMARY KEY,
                    user_id TEXT NOT NULL,
                    enabled INTEGER NOT NULL DEFAULT 1,
                    activated_at REAL NOT NULL,
                    updated_at REAL NOT NULL,
                    has_topics_enabled INTEGER,
                    allows_users_to_create_topics INTEGER,
                    capability_checked_at REAL,
                    intro_message_id TEXT,
                    pinned_message_id TEXT
                );

                CREATE TABLE IF NOT EXISTS telegram_dm_topic_bindings (
                    chat_id TEXT NOT NULL,
                    thread_id TEXT NOT NULL,
                    user_id TEXT NOT NULL,
                    session_key TEXT NOT NULL,
                    session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
                    managed_mode TEXT NOT NULL DEFAULT 'auto',
                    linked_at REAL NOT NULL,
                    updated_at REAL NOT NULL,
                    PRIMARY KEY (chat_id, thread_id)
                );

                CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_dm_topic_bindings_session
                ON telegram_dm_topic_bindings(session_id);

                CREATE INDEX IF NOT EXISTS idx_telegram_dm_topic_bindings_user
                ON telegram_dm_topic_bindings(user_id, chat_id);
                r  ) telegram_dm_topic_schema_versionr   r  z5PRAGMA foreign_key_list('telegram_dm_topic_bindings')c              3   J   K   | ]}|d          dk    o|d         pddk    V  dS )r  r>      rp   CASCADENrK   r  s     rF   r+  zHSessionDB.apply_telegram_topic_migration.<locals>._do.<locals>.<genexpr> 0  sQ       $ $ Fj(Hc!fly-H$ $ $ $ $ $rL   a  
                        CREATE TABLE telegram_dm_topic_bindings_new (
                            chat_id TEXT NOT NULL,
                            thread_id TEXT NOT NULL,
                            user_id TEXT NOT NULL,
                            session_key TEXT NOT NULL,
                            session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
                            managed_mode TEXT NOT NULL DEFAULT 'auto',
                            linked_at REAL NOT NULL,
                            updated_at REAL NOT NULL,
                            PRIMARY KEY (chat_id, thread_id)
                        );
                        INSERT INTO telegram_dm_topic_bindings_new
                            SELECT chat_id, thread_id, user_id, session_key,
                                   session_id, managed_mode, linked_at, updated_at
                            FROM telegram_dm_topic_bindings;
                        DROP TABLE telegram_dm_topic_bindings;
                        ALTER TABLE telegram_dm_topic_bindings_new
                            RENAME TO telegram_dm_topic_bindings;
                        CREATE UNIQUE INDEX idx_telegram_dm_topic_bindings_session
                            ON telegram_dm_topic_bindings(session_id);
                        CREATE INDEX idx_telegram_dm_topic_bindings_user
                            ON telegram_dm_topic_bindings(user_id, chat_id);
                        r  )r  2)r  r   rQ  r_   r  rA   r   r.  )r   r  current_versionfk_rowsneeds_rebuilds        rF   r  z5SessionDB.apply_telegram_topic_migration.<locals>._do/  s+   ! ! !L ll<5  hjj  29]S__=T=T=V=V]c'!*ooo\]O"",,K (**  !$ $ $&$ $ $ ! ! ! &&  6 LLH9    rL   Nr
  )rZ   r  s     rF   apply_telegram_topic_migrationz(SessionDB.apply_telegram_topic_migration/  s1    T	 T	 T	j 	C     rL   )has_topics_enabledallows_users_to_create_topicsr'  r(  c                    |                                   t          j                    dt          t                   dt          t                   fdfd}|                     |           dS )zEnable Telegram DM topic mode for one private chat/user.

        This method intentionally owns the explicit topic migration. Ordinary
        SessionDB startup must not create these side tables.
        rD   r:   c                     | d S | rdndS )Nrq   r   rK   r   s    rF   _to_intz5SessionDB.enable_telegram_topic_mode.<locals>._to_intW0  s    }t$111$rL   c                     |                      dt                    t                                          f           d S )Na  
                INSERT INTO telegram_dm_topic_mode (
                    chat_id, user_id, enabled, activated_at, updated_at,
                    has_topics_enabled, allows_users_to_create_topics,
                    capability_checked_at
                ) VALUES (?, ?, 1, ?, ?, ?, ?, ?)
                ON CONFLICT(chat_id) DO UPDATE SET
                    user_id = excluded.user_id,
                    enabled = 1,
                    updated_at = excluded.updated_at,
                    has_topics_enabled = excluded.has_topics_enabled,
                    allows_users_to_create_topics = excluded.allows_users_to_create_topics,
                    capability_checked_at = excluded.capability_checked_at
                )r   r_   )r   r+  r(  r  r'  r)  r  s    rF   r  z1SessionDB.enable_telegram_topic_mode.<locals>._do\0  sf    LL LLLLG.//G9::    rL   N)r&  rT  r   r   rA   r  )rZ   r  r  r'  r(  r  r+  r)  s    ```` @@rF   enable_telegram_topic_modez$SessionDB.enable_telegram_topic_modeG0  s     	++---ikk	%8D> 	%hsm 	% 	% 	% 	%
	 	 	 	 	 	 	 	 	 	4 	C     rL   )clear_bindingsr.  c                @    fd}|                      |           dS )a  Disable Telegram DM topic mode for one private chat.

        When ``clear_bindings`` is True (default) the (chat_id, thread_id)
        bindings for this chat are also cleared so re-enabling later
        starts from a clean slate. Set to False if the operator wants to
        preserve bindings for a later re-enable.

        Never creates the topic-mode tables from scratch; if they don't
        exist there is nothing to disable and the call is a no-op.
        c                     	 |                      dt          j                    t                    f           r&|                      dt                    f           d S d S # t          j        $ r Y d S w xY w)NOUPDATE telegram_dm_topic_mode SET enabled = 0, updated_at = ? WHERE chat_id = ?z8DELETE FROM telegram_dm_topic_bindings WHERE chat_id = ?)r   rT  r_   rR  rS  )r   r  r.  s    rF   r  z2SessionDB.disable_telegram_topic_mode.<locals>._do0  s    (Y[[#g,,/  
 " LLRW     
 +   s   AA# #A65A6Nr
  )rZ   r  r.  r  s    `` rF   disable_telegram_topic_modez%SessionDB.disable_telegram_topic_modex0  s>     	 	 	 	 	 	 	C     rL   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 = ?
                    NFrp  r   )
rN  r:  r   r_   rQ  rR  rS  r   r;  r   )rZ   r  r  r   rp  s        rF   is_telegram_topic_mode_enabledz(SessionDB.is_telegram_topic_mode_enabled0  s0   Z 
	 
		j(( \\3w<<0  (**  +   
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 ;5$.sGK$@$@L#i..c!fG}}5   BA	ABA2#B1A22BB	Bc                @   | j         5  	 | j                            dt          |          t          |          f                                          }n!# t
          j        $ r Y ddd           dS w xY w	 ddd           n# 1 swxY w Y   |rt          |          ndS )z?Return the session binding for a Telegram DM topic, if present.z
                    SELECT * FROM telegram_dm_topic_bindings
                    WHERE chat_id = ? AND thread_id = ?
                    NrN  r:  r   r_   rQ  rR  rS  r,  )rZ   r  r  r   s       rF   get_telegram_topic_bindingz$SessionDB.get_telegram_topic_binding0  s    Z 
	 
		j(( \\3y>>2  (**  +   
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	  )tCyyyT)r5  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 rK   r,  r  s     rF   r   zCSessionDB.list_telegram_topic_bindings_for_chat.<locals>.<listcomp>0  s    ***cS		***rL   )rN  r:  r   r_   r   rR  rS  )rZ   r  r  s      rF   %list_telegram_topic_bindings_for_chatz/SessionDB.list_telegram_topic_bindings_for_chat0  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 = ?
                    Nr7  rZ   rd   r   s      rF   %get_telegram_topic_binding_by_sessionz/SessionDB.get_telegram_topic_binding_by_session0  s    Z 
	 
		j(( __&  (**  +   
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	  )tCyyyT)4   A4;AA4A$A4#A$$A44A8;A8c                    t                    t                    ddifd}|                     |           d         S )u  Remove the binding row for a single (chat, thread) pair.

        Called when the Telegram Bot API confirms a topic was deleted
        externally (``Thread not found`` after the same-thread retry
        already failed).  Without this prune, the stale row keeps
        living in ``telegram_dm_topic_bindings`` and the
        recovery logic in ``gateway.run._recover_telegram_topic_thread_id``
        cheerfully redirects future inbound messages to the deleted
        topic, causing tool progress, approvals, and replies to land
        in the wrong place.  Issue #31501.

        When this prune removes the chat's *last* remaining binding,
        the chat's row in ``telegram_dm_topic_mode`` is also flipped to
        ``enabled = 0`` in the same transaction.  Otherwise the chat
        would be left in topic mode with zero lanes — and
        ``gateway.run._recover_telegram_topic_thread_id`` keeps treating
        the chat as topic-enabled, lobby messages keep hunting for a
        binding that no longer exists, and a user who disabled topics in
        the Telegram client (rather than via ``/topic off``) stays stuck
        until the next send happens to fail. Clearing the flag makes
        recovery fully stand down once the dead topics are gone.

        Returns the number of binding rows deleted (0 when the binding
        was already absent or the topic-mode tables haven't been
        migrated yet — both are silent no-ops; we never raise from
        a cleanup hot path).
        r  r   c                 x   	 |                      df          }|j        pdd<   n# t          j        $ r	 dd<   Y d S w xY wd         sd S 	 |                      df                                          }|+|                      dt          j                    f           d S d S # t          j        $ r Y d S w xY w)Nz
                    DELETE FROM telegram_dm_topic_bindings
                    WHERE chat_id = ? AND thread_id = ?
                    r   r  z
                    SELECT 1 FROM telegram_dm_topic_bindings
                    WHERE chat_id = ? LIMIT 1
                    r1  )r   r~  rR  rS  rQ  rT  )r   r   r&  r  r=  r  s      rF   r  z4SessionDB.delete_telegram_topic_binding.<locals>._do1  s     i(  $*?#7a  +   #$  7#  LL J  (**  $LLLg.     %$ +   s!   $( A A AB& &B98B9)r_   r  )rZ   r  r  r  r=  s    `` @rF   delete_telegram_topic_bindingz'SessionDB.delete_telegram_topic_binding0  sl    B g,,	NN	A,#	 #	 #	 #	 #	 #	 #	J 	C   wrL   r   )managed_moderC  c                8   |                                   t          j                    t                    t                    t                    t                    t                    fd}|                     |           dS )a   Bind one Telegram DM topic thread to one Hermes session.

        A Hermes session may only be linked to one Telegram topic in MVP.
        Rebinding the same topic to the same session is idempotent; trying to
        link the same session to a different topic raises ValueError.
        c                    |                      df                                          }|t          |t          j                  r|d         n|d         }t          |t          j                  r|d         n|d         }t          |          k    st          |          	k    rt          d          |                      d	
f           d S )Nz
                SELECT chat_id, thread_id FROM telegram_dm_topic_bindings
                WHERE session_id = ?
                r  r   r  rq   z3session is already linked to another Telegram topicaI  
                INSERT INTO telegram_dm_topic_bindings (
                    chat_id, thread_id, user_id, session_key, session_id,
                    managed_mode, linked_at, updated_at
                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                ON CONFLICT(chat_id, thread_id) DO UPDATE SET
                    user_id = excluded.user_id,
                    session_key = excluded.session_key,
                    session_id = excluded.session_id,
                    managed_mode = excluded.managed_mode,
                    updated_at = excluded.updated_at
                )r   rQ  r   rR  r;  r_   rw   )r   existing_sessionlinked_chatlinked_threadr  rC  r)  rd   r  r  r  s       rF   r  z*SessionDB.bind_telegram_topic.<locals>._doQ1  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  	    rL   N)r&  rT  r_   r  )	rZ   r  r  r  r  rd   rC  r  r)  s	    `````` @rF   bind_telegram_topiczSessionDB.bind_telegram_topic91  s      	++---ikkg,,	NN	g,,+&&__
%	 %	 %	 %	 %	 %	 %	 %	 %	 %	 %	L 	C     rL   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)rN  r:  r   r_   rQ  rR  rS  r=  s      rF   #is_telegram_session_linked_to_topicz-SessionDB.is_telegram_session_linked_to_topicy1  s     Z 	 	
j((
 __&  (**  +   	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 $r?  
   )rE   c          
         | j         5  	 | j                            dt           dt	          d           dt          |          t          |          f                                          }nw# t          j	        $ re | j                            dt           dt	          d           dt          |          t          |          f                                          }Y nw xY wddd           n# 1 swxY w Y   g }|D ]R}| 
                    |          }t          |                    dd                    |d	<   |                    |           S|S )
uG  List previous Telegram sessions for this user that are not bound to a topic.

        Read-only: does NOT trigger the telegram-topic migration. If the
        topic-mode tables are absent, fall back to a simpler query that
        just returns this user's Telegram sessions — there can't be any
        bindings yet.
        z
                    SELECT s.*,
                        COALESCE(sp.prompt, s.system_prompt)
                            AS _system_prompt_resolved,
                        COALESCE(
                            (SELECT a7  
                             FROM messages m
                             WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
                             ORDER BY m.timestamp, m.id LIMIT 1),
                            ''
                        ) AS _preview_raw,
                        rn  a   AS last_active
                    FROM sessions s
                    LEFT JOIN system_prompts sp
                      ON sp.hash = s.system_prompt_hash
                    WHERE s.source = 'telegram'
                      AND s.user_id = ?
                      AND NOT EXISTS (
                          SELECT 1 FROM telegram_dm_topic_bindings b
                          WHERE b.session_id = s.id
                      )
                    ORDER BY last_active DESC, s.started_at DESC
                    LIMIT ?
                    ae   AS last_active
                    FROM sessions s
                    LEFT JOIN system_prompts sp
                      ON sp.hash = s.system_prompt_hash
                    WHERE s.source = 'telegram'
                      AND s.user_id = ?
                    ORDER BY last_active DESC, s.started_at DESC
                    LIMIT ?
                    Nro  rp   rp  )rN  r:  r   r   r"   r_   rA   r   rR  rS  r/  r!   r-  r  )rZ   r  r  rE   r  r>   r   rf  s           rF   (list_unlinked_telegram_sessions_for_userz2SessionDB.list_unlinked_telegram_sessions_for_user1  s    Z 6	 6	5z))
 &9  2#66  0 \\3u::.3 4 (**5 6 +    z))
 &9  2#66  ( \\3u::.+ , (**- ;6	 6	 6	 6	 6	 6	 6	 6	 6	 6	 6	 6	 6	 6	 6	p *, 	% 	%C,,S11G!/NB0O0O!P!PGIOOG$$$$s6   C0A"A-,C0-A1C!C0 C!!C00C47C4ru  c                    	 | j         5  | j        	 ddd           dS | j                            d                                          d         }| j                            d                                          d         }ddd           n# 1 swxY w Y   t	          |          t	          |          z  S # t
          $ r&}t                              d|           Y d}~dS d}~ww xY w)u3  Database size in bytes as SQLite itself accounts for it.

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

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

        Returns None if the pragmas cannot be read.
        Nr  r   r  z"Could not read logical DB size: %s)rN  r:  r   rQ  rA   rB   r=  rY  )rZ   r  r  r\  s       rF   r  zSessionDB.logical_size_bytes1  s[   "		 Q Q:%Q Q Q Q Q Q Q Q "Z//0CDDMMOOPQR
 J../ABBKKMMaP		Q Q Q Q Q Q Q Q Q Q Q Q Q Q Q
 z??S^^33 	 	 	LL=sCCC44444	sE   B9 	BB9 A$BB9 BB9 B!B9 9
C)C$$C)c                 D   d}	 |                                  }n2# t          $ r%}t                              d|           Y d}~nd}~ww xY w| j        5  	 | j                            d           n2# t          $ r%}t                              d|           Y d}~nd}~ww xY w| j                            d           	 | j                            d           n2# t          $ r%}t                              d|           Y d}~nd}~ww xY w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  z1WAL checkpoint (PASSIVE) before VACUUM failed: %sr  zPRAGMA wal_checkpoint(TRUNCATE)z1WAL checkpoint (TRUNCATE) after VACUUM failed: %s)optimize_ftsrB   r=  r  rN  r:  r   rY  )rZ   	optimizedr\  s      rF   vacuumzSessionDB.vacuum 2  s   . 		I))++II 	I 	I 	INNBCHHHHHHHH	I Z 	W 	WW
""#CDDDD W W WPRUVVVVVVVVWJx(((W
""#DEEEE W W WPRUVVVVVVVVW)	W 	W 	W 	W 	W 	W 	W 	W 	W 	W 	W 	W 	W 	W 	W, sz    
AAADA/.D/
B9BDBD<CD
D!D<DDDDD      retention_daysmin_interval_hoursrS  min_vacuum_interval_daysc                    dddd}	 |                      d          }t          j                    }|r;	 t          |          }	||	z
  |dz  k     rd|d<   |S n# t          t          f$ r Y nw xY w|                     ||          }
|
|d	<   |                      d
          }d}|r4	 |t          |          z
  |dz  k    }n# t          t          f$ r d}Y nw xY w|rx|
dk    rr|rp	 |                                  d|d<   |                     d
t          |                     n2# t          $ r%}t                              d|           Y d}~nd}~ww xY w|                     dt          |                     |
dk    r't                              d|
||d         rdnd           nD# t          $ r7}t                              d|           t          |          |d<   Y d}~nd}~ww xY w|S )uD  Idempotent auto-maintenance: prune inactive sessions + optional VACUUM.

        Records the last run timestamp in state_meta so subsequent calls
        within ``min_interval_hours`` no-op. VACUUM has its own, typically
        longer, throttle controlled by ``min_vacuum_interval_days`` so routine
        pruning does not repeatedly rewrite the database. Designed to be
        called once at startup from long-lived entrypoints (CLI, gateway, cron
        scheduler).

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

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

        Returns a dict with keys:
          - ``"skipped"`` (bool) — true if within min_interval_hours of last run
          - ``"pruned"`` (int)   — number of sessions deleted
          - ``"vacuumed"`` (bool) — true if VACUUM ran
          - ``"error"`` (str, optional) — present only on failure
        Fr   )skippedprunedvacuumedlast_auto_prune  TrZ  )r(  r7  r[  last_vacuumr  r\  zstate.db VACUUM failed: %sNzFstate.db auto-maintenance: pruned %d session(s) inactive for %d days%sz	 + VACUUMrp   z$state.db auto-maintenance failed: %sr  )r  rT  r-  rv   rw   r  rS  r  r_   rB   r=  r  r>  )rZ   rV  rW  rS  r7  rX  r  last_rawr)  last_tsr[  last_vacuum_raw
vacuum_duer\  s                 rF   maybe_auto_prune_and_vacuumz%SessionDB.maybe_auto_prune_and_vacuum52  s}   < .3aU!S!S7	'}}%677H)++C #HooGW}'9D'@@@,0y)% A ":.   D (( .) )  F  &F8 #mmM::OJ &&"%o(>(>">C[^cCc!cJJ!:. & & &!%JJJ& F&1***FKKMMM)-F:&MM-S::::  F F FNN#?EEEEEEEEF
 MM+SXX666zz\"#)*#5=KK2	    	' 	' 	'NNA3GGG!#hhF7OOOOOO	'
 s   *F! !A F! A*'F! )A**8F! #B= <F! =CF! CF! !<D F! 
E(EF! EAF! !
G"+-GG"r  c                 $   ddd}	 |                      d          }t          j                    }|r9	 |t          |          z
  |dz  k     rd|d<   |S n# t          t          f$ r Y nw xY w|                     ||          }||d	<   |                     dt          |                     |dk    rt          	                    d
||           nD# t          $ r7}t                              d|           t          |          |d<   Y d}~nd}~ww xY w|S )u  Idempotent auto-archive: soft-hide sessions idle for ``idle_days``.

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

        Never raises. Returns a dict with:
          - ``"skipped"`` (bool) — within min_interval_hours of last run
          - ``"archived"`` (int) — sessions archived this run
          - ``"error"`` (str, optional) — present only on failure
        Fr   )rZ  r!  last_auto_archiver^  TrZ  r  r!  z=state.db auto-archive: archived %d session(s) idle >= %s daysz state.db auto-archive failed: %sr  N)r  rT  r-  rv   rw   r  r  r_   r=  r>  rB   r  )	rZ   r  rW  r  r  r`  r)  r!  r\  s	            rF   maybe_auto_archivezSessionDB.maybe_auto_archive2  sa   ( .3!B!B	'}}%899H)++C U8__,/AD/HHH,0y)% I ":.   D 22. 3  H "*F: MM-s3xx888!||S  
  	' 	' 	'NN=sCCC!#hhF7OOOOOO	' s;   *C A C A'$C &A''A$C 
D-DDc                 <    fd}|                      |          S )zMark a session as pending handoff to the given platform.

        Returns True if the row was found and not already in flight; False if
        the session is already in a non-terminal handoff state.
        c                 J    |                      df          }|j        dk    S )NzUPDATE sessions SET handoff_state = 'pending',     handoff_platform = ?,     handoff_error = NULL WHERE id = ? AND (handoff_state IS NULL                   OR handoff_state IN ('completed', 'failed'))r   r  )r   r	  r   rd   s     rF   r  z&SessionDB.request_handoff.<locals>._do2  s5    ,,Q :& C <!##rL   r
  )rZ   rd   r   r  s    `` rF   request_handoffzSessionDB.request_handoff2  s8    
	$ 
	$ 
	$ 
	$ 
	$ 
	$ ""3'''rL   c                     	 | j                             d|f          }|                                }|sdS |d         |d         |d         dS # t          $ r Y dS w xY w)zRead the current handoff state for a session.

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

        Used by the gateway's handoff watcher.
        zSELECT s.*, COALESCE(sp.prompt, s.system_prompt) AS _system_prompt_resolved FROM sessions s LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash WHERE s.handoff_state = 'pending' ORDER BY s.started_at ASCc                 :    g | ]}                     |          S rK   rB  rC  s     rF   r   z3SessionDB.list_pending_handoffs.<locals>.<listcomp>3  s'    FFF!D**1--FFFrL   )r:  r   r   rB   )rZ   r	  s   ` rF   list_pending_handoffszSessionDB.list_pending_handoffs2  sm    
	*$$, C GFFFs||~~FFFF 	 	 	III	s   9= AAc                 8    fd}|                      |          S )uC   Atomically transition pending → running. Returns True if claimed.c                 H    |                      df          }|j        dk    S )NzXUPDATE sessions SET handoff_state = 'running' WHERE id = ? AND handoff_state = 'pending'r   r  )r   r	  rd   s     rF   r  z$SessionDB.claim_handoff.<locals>._do3  s/    ,,= C
 <!##rL   r
  r  s    ` rF   claim_handoffzSessionDB.claim_handoff3  s2    	$ 	$ 	$ 	$ 	$ ""3'''rL   c                 <    fd}|                      |           dS )zMark a handoff as completed.c                 6    |                      df           d S )NzRUPDATE sessions SET handoff_state = 'completed', handoff_error = NULL WHERE id = ?r)  r  s    rF   r  z'SessionDB.complete_handoff.<locals>._do3  s-    LL4    rL   Nr
  r  s    ` rF   complete_handoffzSessionDB.complete_handoff3  s8    	 	 	 	 	 	C     rL   c                 @    fd}|                      |           dS )z/Mark a handoff as failed and record the reason.c                 H    |                      dd d         f           d S )NzLUPDATE sessions SET handoff_state = 'failed', handoff_error = ? WHERE id = ?r$  r)  )r   r  rd   s    rF   r  z#SessionDB.fail_handoff.<locals>._do#3  s8    LL1ttj)    rL   Nr
  )rZ   rd   r  r  s    `` rF   fail_handoffzSessionDB.fail_handoff!3  s>    	 	 	 	 	 	 	C     rL   r  rU  r  )r:   r  )NNNNNNNNNNNNNN)T)r  )NNF)NN)r  )r  )r  )r   r   Nr   r   r   NNNNNNNNr   F)r  N)NNNNrR  r   Fr   TFFFNNFFNF)Nr  )NNNNNNNNNNNNFNNNNNNNr  )NNNr  )FF)NNN)FFNr   FN)r5  )FFFF)NrR  r   N)NNNr   FFFNr  )r  NN)r  rT  TNrU  )r  rT  T(   r\   r]   r^   r  rD  r  r;  r  r  r  r  rH  rI  r  r  _FTS_MERGE_MAX_PAGES_PER_INDEX_FTS_MERGE_COMMANDS_PER_PASS_IMPORT_MAX_SESSIONS _IMPORT_MAX_MESSAGES_PER_SESSION_IMPORT_MAX_TOTAL_MESSAGES_IMPORT_MAX_SESSION_BYTES_IMPORT_MAX_TOTAL_BYTESr  staticmethodr   r_   r'  r*  rR  r;  r   r   r/  
Connectionr1  r   r   rY   rw  rt  r{  r  r   r  rS  r  r  Cursorr  r  r  r  r  r  r   r   r-  r  r  r  r  r  r  r  r  r  r  rq  r  _FTS_REBUILD_CHUNK_ROWS_FTS_REBUILD_DUTY_FACTOR_FTS_REBUILD_MIN_PAUSEr  r  r  r   r  r  r  r  r  r   r'  r/  r   rA   r5  r   r>  rG  rO  rQ  rc  rm  rp  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  r  r  r'  r+  r.  r0  r   r<  rB  rG  rL  rO  rY  rU  rd  rg  ro  rt  rw  r  r  r  r  r  r  rD  r  r  r  r  r  r  r  r  r  r  r  rF  r  r  r  r  r  r  r  classmethodr  r  r  r  r  r  r  r  r  r   r&  r+  r/  r3  r8  r:  r>  rD  rG  	frozenset_SESSION_COMPACT_EXCLUDEDrH  __annotations__rQ  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r$  r/  r4  r?  rF  rT  rR  rS  rc  rg  rl  rn  rs  rP  ry  rQ  rW  r  r  r  r  r  r  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  r  r&  r-  r2  r4  r8  r;  r>  rB  rI  rK  rN  _FTS_TABLESr  rS  rd  rg  rj  rp  rs  rv  ry  r|  rK   rL   rF   r  r    s:         H #' 
 "%  # ###!# !%%("#$ 
 '-$!' /. "&(3- HSM    \ 
 
 
 \
 w{ tCH~    \ R1C(D R R R R \Rn5 n5 n5 n5 n5 n5 n5d	[); < [ [ [ [z   ) ) ) )2)Xg.@%A ) ) ) )* + + ^+^ (@ T    \ 
7+C 
 
 
 
 \
 &'. &T & & & \&2
W-E 
$ 
 
 
 

'*B 
t 
 
 
 
t, t, t, t,l 7> d    \  	
 
   < '+N Ng()1,-N UON 
	N N N N`+0	   88 8 8 8 8t 2G,A 2d 2 2 2 \2/G,A /d / / / /b7(= 7$ 7 7 7 7rG G G G>   8T    ,5 ,5 ,5\   ` ""  )&d    " '+!%)!% ! #~O ~O~O ~O 	~O
 38n~O ~O ~O c]~O ~O ~O ~O ~O ~O ~O ~O  !~O" #~O$ 
%~O ~O ~O ~O@ c       .3@! @! @!@! 	@!
 @! @! @! @! @! @! @! (,@! 
@! @! @! @!D! !s !t !t ! ! ! !( BD! ! !!,/!;>!	! ! ! !: 8:! ! !CH~!14!	! ! ! !. <> A A AS A$sCx. A A A A 8:! ! ! I!14!	! ! ! !2'"'2'	d38n	2' 2' 2' 2'hC S    J (,	* * * * tn	*
 
sCx* * * *F #' 	'9 '9 '9 3-'9 	'9
 
d38n	'9 '9 '9 '9\ $(!%0" 0" 0" 0" 	0"
 C=0" #0" 
#0" 0" 0" 0"l "&%)!%#'#'s< s< s< s< #	s<
 c]s< #s< C=s< C=s< 
$sCx.	!s< s< s< s<D "' /3R R R$UOR	d38n	R R R Rh@(@((+@(	@( @( @( @(Z	8 ')K!$)K	$sCx.	!)K )K )K )KVN.c N.d N. N. N. N.n '+! '+*.#'+/^! ^! ^! ^! 	^!
 ^! tCH~&^! ^! 38n^! ^! ^! ^! "%^! $(^! C=^! $C=^!  
!^! ^! ^! ^!@!c !s !t ! ! ! !$! ! ! ! ! !8 .=/ //'*/	/ / / /j %)'+!&K( K(K( K( SM	K(
  }K( K( 
#K( K( K( K(d %)'+*. *.*. *. 	*.
 SM*.  }*. 
*. *. *. *.X!tCH~ !$ ! ! ! !2  $	   }	
 
   2#
#
 
$sCx.	!#
 #
 #
 #
J%
%
 
c3h%
 %
 %
 %
N-- sCx.- 
	- - - -^S T    (# #    0!# !s !t ! ! ! !C C    .! ! ! ! ! !C C    >!C ! !PT ! ! ! !N #	, ,, , 	,
 
, , , ,d #	V VV V 	V
 
V V V Vp3      4$O $O $O $O $O $OLJ# J# J J J J$ #&*3E 3E 3E3E 3E
 3E UO3E 
3E 3E 3E 3Et #$'*.2$'GP GP GPGP GP
 GP GP  %GP ',GP "GP 
GP GP GP GP\ #. . .. .
 . 
. . . ..!S !# !$ ! ! ! !Ic Ihsm I I I I( #-M
 &*37-M -M -M-M UO-M
 c]-M /0-M 
-M -M -M -M^.M .M .M .M .M .M`
s 
xS#X7O 
 
 
 
&  $	! !! ! }	!
 
! ! ! !,!!.6sm!	! ! ! ! FJ1! 1!1!&)1!5=c]1!	1! 1! 1! 1!r !.6 .6 .6 .6 CH~	.6 .6 .6 .6 .6`!!&*38n!	! ! ! !4 9=( (($'(25(	( ( ( (,  $"&26&*)! )! )!)! }	)!
 3-)!  S#X/)! sm)! )! 
)! )! )! )!V!3 ! !$ ! ! ! !6 *8DcN+C * * * * \*" 2phtCH~.F 2p4PSUXPX> 2p 2p 2p \2pt '+!! !! !!!! 	!!
 !! sm!! 
!! !! !! !!h  I!
3;S 3;t 3; 3; 3; 3;j1 1% 1$ 1 1 1 1f8 8 8 8<U3S#X3F-G(H T    04%T#s(^ 3454	eCc3h'(	)4 4 4 4<48 48u 48 48 48 48 48l    !""# !.2+/%)%))-*.*.&*%g! g!g! g! 	g!
 g! g!  g! g! %UOg! "%g! c]g! c]g! "#g! #3-g! #3-g!  sm!g!" #g!$ %g!& 
'g! g! g! g!x 'd
 d
 d
 d

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

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

 $C=O
 SMO
 !&O
 
O
 O
 O
 O
f 6: .  .  . .(+ .69 .IL ."4S>2 . 
 .  .  .  .J ) =( =( =(=( =( }	=( =( 
$tCH~&	'=( =( =( =(~dd/2d	d38n	d d d d, 179. 9. 9.9.*-9.	d38n	9. 9. 9. 9.x /5A\` '  '  ' '(+ '>A 'UY '	# '  '  '  'DCS CXc] C C C C'3 ' ' ' ' ' '"[*S [*DcSVhDX [*]bcfhkck]l [* [* [* [*B " %S! S!S! tCH~&S! 	S!
 S! 
S! S! S! S!j1 1 1 1 1 1)s )s ) ) ) ), 8<#'%)V( V(V( !c3h0V( %T#s(^4	V(
 C=V( c]V( 
V( V( V( V(pT#Y    (((+(:=(	( ( ( (F "'"'#"&@ @@ @  	@
 }@ @ @ 3-@ 
d38n	@ @ @ @DS	 d4S>>R    : 	N
 N
N
 N
 	N

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

 !6
 6
 
d38n	6
 6
 6
 6
x	6  !&C C C 	C
  C !C C 
d38n	C C C CJ7.7.	tDcN#T$sCx.%99	:7. 7. 7. 7.r
)3 
)3 
) 
) 
) 
) '+" "" sm" 
	" " " "N '+" "" sm" 
	" " " "H*
c *
d4S>>R *
 *
 *
 *
XOc Od O O O O6@ @ @ @ @ @5s 5tCy 5 5 5 5, d4S>6J QUVY[^V^Q_ dh    \"U
U
25U
	c3hU
 U
 U
 U
n(# ( ( ( ( ( (< !+N +N+N +N 	+N
 +N 
d38n	+N +N +N +Nf !!"!&#!&%);( ;(;( c;( 	;(
 ;( ;( ;( ;( c;( 
;( ;( ;( ;(z # d    & "'#!&+L +L +L +L 	+L
 +L 
c3h+L +L +L +LZ	( 	( 	(s 	( 	( 	( 	(11471	1 1 1 1,=4S> =d = = = =<JtCH~ J$ J J J J+B# +B$s) +B +B +B +BZ
! 
! 
! 
! 
! 
! HTN  PT    \43S 3T#Y 3 3 3 3( (,37	9 99 tn9 &d3i0	9
 
9 9 9 9| (,* ** tn* 
	* * * *^ (,R R#YR tnR 
	R R R Rh(c ( ( ( (6 (,B BtnB 
B B B BH  /3-1*.)- $$($($(&*&*#'$("&!%!%#'%)$($($($((,(,$3Q- Q- Q-$UOQ- $E?Q- !	Q-
  Q- Q- SMQ- SMQ- SMQ- smQ- smQ- 4.Q- SMQ- 3-Q- #Q-  #!Q-" C=#Q-$ c]%Q-& SM'Q-( SM)Q-* 5/+Q-, 5/-Q-. !/Q-0 !1Q-2 3Q-4 
sDy	5Q- Q- Q- \Q-f !%37S>	   \ ,0 <  <!% <  <
 
d38n	 <  <  <  <H ,0- -!%- -
 
- - - -, ,0- -!%- -
 
- - - -6 ,0 !% 
 
   6 ;?, , ,,37,	, , , ,` ,.'+	K K!%K K tn	K 
K K K K\ "'td
 d
 d
d
04d
	c3hd
 d
 d
 d
PHC HHSM H H H H" KO! ! !!"!/7/G!	! ! ! !6!(C !(C !( !( !( !(F2s 2tE#s(O/D 2 2 2 2$b! b! b! b!R .28</! /! /! /! 	/!
 %TN/! (0~/! 
/! /! /! /!j  $	! ! ! ! 	!
 
! ! ! !B s t    $* * 	*
 
$sCx.	!* * * *(+ + 
d38n		+ + + +** * 
$sCx.	!	* * * *0K  K  	K 
 
K  K  K  K j #>! >! >! >! 	>!
 >! >! >! >! 
>! >! >! >!@     6 K K K K 	K
 K 
d38n	K K K Kf OKHSM    83 3 3 3 3n !"$'+(*X XX  X 	X
 tnX #&X 
c3hX X X Xx "$#	3 33  3 	3
 
c3h3 3 3 3B(# ( ( ( ( ( (&C HT#s(^4L    .tDcN';    $	( 	( 	( 	( 	( 	(!3 !4 ! ! ! !!s !3 !4 ! ! ! ! ! !rL   r  c                   &    e Zd ZdZd	dZdefdZdS )
AsyncSessionDBu   Async door onto SessionDB: offloads each call via asyncio.to_thread so a blocking SQLite call never freezes the event loop. Generic forwarder — the audit confirms no method returns a live cursor/generator.r  r  r:   Nc                     || _         d S r  )_db)rZ   r  s     rF   rY   zAsyncSessionDB.__init__/3  s    rL   r|   c                 ^    t          | j        |          t                    sS fd}|S )Nc                  >   K   t          j        g| R i | d {V S r  )asyncio	to_thread)argsr  attrs     rF   
_offloadedz.AsyncSessionDB.__getattr__.<locals>._offloaded73  s:       *4A$AAA&AAAAAAAAArL   )r  r  callable)rZ   r|   r  r  s      @rF   __getattr__zAsyncSessionDB.__getattr__23  sM    tx&&~~ 	K	B 	B 	B 	B 	B rL   )r  r  r:   N)r\   r]   r^   r  rY   r_   r  rK   rL   rF   r  r  ,3  sO         Z  Z         rL   r  )r   )rA  r  )r  r  r  
contextlibr  ri   r  loggingrx   rO  rF  r  rR  r   rL  rT  r  collectionsr   r   pathlibr   agent.memory_managerr   r:  r   agent.message_sanitizationr	   agent.skill_commandsr
   r   r   hermes_constantsr   hermes_cli.sqlite_runtimer   rm  typingr   r   r   r   r   r   r   r   hermes_state_commonr   r   r   r   r   r   r   r   r   r    r!   r"   r#   r$   r   r%   r&   r'   r(   r)   r*   r+   r,   r-   r.   r/   r0   r1   r2   r3   r4   hermes_state_portabilityr5   hermes_state_schemar6   hermes_state_searchr7   rz   rE  	getLoggerr\   r=  rJ   rQ   r_   rA   rG   rM   rR   rw   rT   rc   compilers   rm   r   r   r   r   r   objectrV  r  r  r   r   r   r   r   r   rr  rQ  r   r   r  r  r   r  r   r   r   r   r   r   r   r  r	  r  r  rH  r`  r  rM  r  r   r  r  r  r  r  r!  r/  r0  r6  r9  r;  r@  rJ  r  r^  ra  rf  ri  r  rs  r{  rS  r}  r  r  r  r  r  rB   r  r  r  r  r  rj  r  r  r  r  PERSISTENCE_ERROR_CAUSESr  r  r  r  r  r   r  r  r  r  _MAX_MALFORMED_BACKUPSr	  r  r  r  r-  r:  r?  rQ  rl  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  SESSION_STATUS_EMPTYr  r   r  r  rK   rL   rF   <module>r     s{               				   				  



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