Prompt:
Gitea at http://143.198.27.163:3000 not responding. Check VPS 143.198.27.163.

---
*Auto-filed by loop-watchdog*

Chosen response:
diff --git a/tests/test_timmy.py b/tests/test_timmy.py
index 2284fbde..53034e70 100644
--- a/tests/test_timmy.py
+++ b/tests/test_timmy.py
@@ -386,3 +386,117 @@ class TestOrchestratorRunCycle:
                     orch.run_cycle()
         mock_reviews.assert_called_once()
         mock_stuck.assert_called_once()
+
+
+# ---------------------------------------------------------------------------
+# GiteaClient.open_issue
+# ---------------------------------------------------------------------------
+
+class TestGiteaClientOpenIssue:
+    def test_open_issue_posts_correct_path(self):
+        client = GiteaClient(base_url="http://h:3000", token="t", repo="o/r")
+        with patch.object(client, "_post", return_value={"number": 99}) as mock_post:
+            result = client.open_issue("Bug found", "It broke")
+        mock_post.assert_called_once_with(
+            "/repos/o/r/issues",
+            {"title": "Bug found", "body": "It broke"},
+        )
+        assert result["number"] == 99
+
+    def test_open_issue_empty_body(self):
+        client = GiteaClient(base_url="http://h:3000", token="t", repo="o/r")
+        with patch.object(client, "_post", return_value={"number": 1}) as mock_post:
+            client.open_issue("Title only")
+        mock_post.assert_called_once_with(
+            "/repos/o/r/issues",
+            {"title": "Title only", "body": ""},
+        )
+
+
+# ---------------------------------------------------------------------------
+# GiteaWatchdog
+# ---------------------------------------------------------------------------
+
+from timmy.watchdog import GiteaWatchdog, WATCHDOG_ISSUE_TITLE
+
+
+def _watchdog(dry_run: bool = False) -> GiteaWatchdog:
+    return GiteaWatchdog(
+        gitea_url="http://h:3000",
+        gitea_token="tok",
+        repo="o/r",
+        dry_run=dry_run,
+    )
+
+
+class TestGiteaWatchdogHealthProbe:
+    def test_reachable_when_200(self):
+        wdog = _watchdog()
+        mock_resp = MagicMock()
+        mock_resp.__enter__ = lambda s: s
+        mock_resp.__exit__ = MagicMock(return_value=False)
+        with patch("urllib.request.urlopen", return_value=mock_resp):
+            assert wdog.is_gitea_reachable() is True
+
+    def test_unreachable_on_exception(self):
+        wdog = _watchdog()
+        with patch("urllib.request.urlopen", side_effect=OSError("conn refused")):
+            assert wdog.is_gitea_reachable() is False
+
+
+class TestGiteaWatchdogRunOnce:
+    def test_files_issue_when_down_and_no_existing_issue(self):
+        wdog = _watchdog()
+        with patch.object(wdog, "is_gitea_reachable", return_value=False):
+            with patch.object(wdog.gitea, "list_issues", return_value=[]):
+                with patch.object(wdog.gitea, "open_issue") as mock_open:
+                    result = wdog.run_once()
+        assert result is False
+        mock_open.assert_called_once()
+        title, _ = mock_open.call_args[0]
+        assert title == WATCHDOG_ISSUE_TITLE
+
+    def test_no_duplicate_issue_when_already_open(self):
+        wdog = _watchdog()
+        existing = {"number": 5, "title": WATCHDOG_ISSUE_TITLE}
+        with patch.object(wdog, "is_gitea_reachable", return_value=False):
+            with patch.object(wdog.gitea, "list_issues", return_value=[existing]):
+                with patch.object(wdog.gitea, "open_issue") as mock_open:
+                    wdog.run_once()
+        mock_open.assert_not_called()
+
+    def test_closes_issue_when_back_up(self):
+        wdog = _watchdog()
+        existing = {"number": 7, "title": WATCHDOG_ISSUE_TITLE}
+        with patch.object(wdog, "is_gitea_reachable", return_value=True):
+            with patch.object(wdog.gitea, "list_issues", return_value=[existing]):
+                with patch.object(wdog.gitea, "close_issue") as mock_close:
+                    with patch.object(wdog.gitea, "add_issue_comment"):
+                        result = wdog.run_once()
+        assert result is True
+        mock_close.assert_called_once_with(7)
+
+    def test_no_close_when_up_and_no_issues(self):
+        wdog = _watchdog()
+        with patch.object(wdog, "is_gitea_reachable", return_value=True):
+            with patch.object(wdog.gitea, "list_issues", return_value=[]):
+                with patch.object(wdog.gitea, "close_issue") as mock_close:
+                    wdog.run_once()
+        mock_close.assert_not_called()
+
+    def test_dry_run_does_not_file_issue(self):
+        wdog = _watchdog(dry_run=True)
+        with patch.object(wdog, "is_gitea_reachable", return_value=False):
+            with patch.object(wdog.gitea, "list_issues", return_value=[]):
+                with patch.object(wdog.gitea, "open_issue") as mock_open:
+                    wdog.run_once()
+        mock_open.assert_not_called()
+
+    def test_dry_run_does_not_close_issue(self):
+        wdog = _watchdog(dry_run=True)
+        existing = {"number": 9, "title": WATCHDOG_ISSUE_TITLE}
+        with patch.object(wdog, "is_gitea_reachable", return_value=True):
+            with patch.object(wdog.gitea, "list_issues", return_value=[existing]):
+                with patch.object(wdog.gitea, "close_issue") as mock_close:
+                    wdog.run_once()
+        mock_close.assert_not_called()
diff --git a/timmy/__main__.py b/timmy/__main__.py
index 0fcca11f..a9fc2e07 100644
--- a/timmy/__main__.py
+++ b/timmy/__main__.py
@@ -11,6 +11,7 @@ TIMMY_MODEL        Model name                 (default: llama3.3)
 OLLAMA_BASE_URL    Ollama endpoint            (default: http://localhost:11434/v1)
 TIMMY_DRY_RUN      If "1", log only — no writes to Gitea
 TIMMY_INTERVAL     Loop interval in seconds   (default: 0, run once)
+WATCHDOG_TIMEOUT   HTTP probe timeout (s)     (default: 10)
 """

 import argparse
@@ -26,6 +27,7 @@ logging.basicConfig(
 )

 from timmy.orchestrator import TimmyOrchestrator
+from timmy.watchdog import GiteaWatchdog

 def main() -> None:
@@ -43,11 +45,35 @@ def main() -> None:
     parser.add_argument("--gitea-url", default=None, help="Override GITEA_URL")
     parser.add_argument("--token", default=None, help="Override GITEA_TOKEN")
     parser.add_argument("--repo", default=None, help="Override GITEA_REPO (owner/repo)")
+    parser.add_argument(
+        "--watchdog",
+        action="store_true",
+        help="Run in watchdog mode: check Gitea health and manage outage issues",
+    )
     args = parser.parse_args()

     dry_run = args.dry_run or os.getenv("TIMMY_DRY_RUN", "0") == "1"
     interval = args.interval if args.interval > 0 else 300

+    if dry_run:
+        logging.getLogger().warning("DRY RUN — no changes will be written to Gitea")
+
+    if args.watchdog:
+        watchdog = GiteaWatchdog(
+            gitea_url=args.gitea_url,
+            gitea_token=args.token,
+            repo=args.repo,
+            dry_run=dry_run,
+        )
+        if args.loop:
+            logging.getLogger().info("Watchdog loop mode (interval: %ds)", interval)
+            while True:
+                watchdog.run_once()
+                time.sleep(interval)
+        else:
+            watchdog.run_once()
+        return
+
     orchestrator = TimmyOrchestrator(
         gitea_url=args.gitea_url,
         gitea_token=args.token,
@@ -55,9 +81,6 @@ def main() -> None:
         dry_run=dry_run,
     )

-    if dry_run:
-        logging.getLogger().warning("DRY RUN — no changes will be written to Gitea")
-
     if args.loop:
         logging.getLogger().info("Running in loop mode (interval: %ds)", interval)
         while True:
diff --git a/timmy/gitea_client.py b/timmy/gitea_client.py
index d9429661..d181dc3e 100644
--- a/timmy/gitea_client.py
+++ b/timmy/gitea_client.py
@@ -120,6 +120,13 @@ class GiteaClient:
         """List all labels in the repo."""
         return self._get(f"/repos/{self.repo}/labels") or []

+    def open_issue(self, title: str, body: str = "") -> dict:
+        """Create a new issue and return the created issue dict."""
+        return self._post(
+            f"/repos/{self.repo}/issues",
+            {"title": title, "body": body},
+        )
+
     def close_issue(self, issue_number: int) -> dict:
         return self._patch(
             f"/repos/{self.repo}/issues/{issue_number}",
diff --git a/timmy/watchdog.py b/timmy/watchdog.py
new file mode 100644
index 00000000..a4f818af
--- /dev/null
+++ b/timmy/watchdog.py
@@ -0,0 +1,128 @@
+"""
+Gitea health watchdog for Timmy.
+
+Periodically checks whether the Gitea instance is reachable.  When it detects
+an outage it files a ``[watchdog]`` issue so that humans (and agents) are
+notified.  When Gitea comes back it closes any open watchdog issues.
+
+Environment variables
+---------------------
+GITEA_URL          Gitea base URL          (default: http://143.198.27.163:3000)
+GITEA_TOKEN        Gitea API token         (required)
+GITEA_REPO         owner/repo slug         (default: rockachopa/hermes-agent)
+WATCHDOG_TIMEOUT   HTTP timeout in seconds (default: 10)
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import urllib.error
+import urllib.request
+from typing import Optional
+
+from timmy.gitea_client import GiteaClient
+
+logger = logging.getLogger(__name__)
+
+WATCHDOG_ISSUE_TITLE = "[watchdog] Gitea unreachable"
+WATCHDOG_TIMEOUT = int(os.getenv("WATCHDOG_TIMEOUT", "10"))
+
+
+class GiteaWatchdog:
+    """Check Gitea health and manage watchdog issues."""
+
+    def __init__(
+        self,
+        gitea_url: Optional[str] = None,
+        gitea_token: Optional[str] = None,
+        repo: Optional[str] = None,
+        dry_run: bool = False,
+    ) -> None:
+        self.gitea = GiteaClient(base_url=gitea_url, token=gitea_token, repo=repo)
+        self.dry_run = dry_run
+
+    # ------------------------------------------------------------------
+    # Health probe
+    # ------------------------------------------------------------------
+
+    def is_gitea_reachable(self) -> bool:
+        """Return True if the Gitea HTTP endpoint responds within the timeout."""
+        health_url = f"{self.gitea.base_url}/api/v1/settings/api"
+        try:
+            req = urllib.request.Request(health_url, method="GET")
+            with urllib.request.urlopen(req, timeout=WATCHDOG_TIMEOUT):
+                return True
+        except Exception as exc:
+            logger.warning("[watchdog] Gitea probe failed: %s", exc)
+            return False
+
+    # ------------------------------------------------------------------
+    # Issue helpers
+    # ------------------------------------------------------------------
+
+    def _find_open_watchdog_issues(self) -> list[dict]:
+        """Return open issues whose title matches the watchdog sentinel."""
+        try:
+            issues = self.gitea.list_issues(state="open", limit=50)
+        except Exception as exc:
+            logger.error("[watchdog] Could not list issues: %s", exc)
+            return []
+        return [i for i in issues if i.get("title") == WATCHDOG_ISSUE_TITLE]
+
+    def _file_outage_issue(self) -> None:
+        body = (
+            f"Gitea at {self.gitea.base_url} not responding. "
+            f"Check VPS {self.gitea.base_url.split('://')[-1].split(':')[0]}.\n\n"
+            "---\n*Auto-filed by loop-watchdog*"
+        )
+        logger.warning("[watchdog] Filing outage issue: %s", WATCHDOG_ISSUE_TITLE)
+        if not self.dry_run:
+            try:
+                self.gitea.open_issue(WATCHDOG_ISSUE_TITLE, body)
+            except Exception as exc:
+                logger.error("[watchdog] Failed to file issue: %s", exc)
+
+    def _close_watchdog_issues(self, issues: list[dict]) -> None:
+        for issue in issues:
+            number = issue["number"]
+            logger.info("[watchdog] Gitea is back — closing issue #%d", number)
+            if not self.dry_run:
+                try:
+                    self.gitea.add_issue_comment(
+                        number,
+                        "**[Timmy watchdog]** Gitea is reachable again. Closing.",
+                    )
+                    self.gitea.close_issue(number)
+                except Exception as exc:
+                    logger.error("[watchdog] Failed to close issue #%d: %s", number, exc)
+
+    # ------------------------------------------------------------------
+    # Main entry point
+    # ------------------------------------------------------------------
+
+    def run_once(self) -> bool:
+        """
+        Perform one health-check cycle.
+
+        Returns True if Gitea is reachable, False otherwise.
+        """
+        reachable = self.is_gitea_reachable()
+
+        if reachable:
+            open_issues = self._find_open_watchdog_issues()
+            if open_issues:
+                self._close_watchdog_issues(open_issues)
+            else:
+                logger.info("[watchdog] Gitea is reachable — no open watchdog issues")
+        else:
+            open_issues = self._find_open_watchdog_issues()
+            if open_issues:
+                logger.info(
+                    "[watchdog] Gitea unreachable — outage issue already open (#%s)",
+                    ", #".join(str(i["number"]) for i in open_issues),
+                )
+            else:
+                self._file_outage_issue()
+
+        return reachable

Rejected response:
diff --git a/agent/live_compressor.py b/agent/live_compressor.py
new file mode 100644
index 00000000..33d0b192
--- /dev/null
+++ b/agent/live_compressor.py
@@ -0,0 +1,277 @@
+"""AutoLoRA live trajectory compressor — post-session hook.
+
+After each session, compresses the trajectory to a training-ready JSONL file
+in ~/autolora/data/live/ without any external API calls.
+
+Compression strategy (mirrors the batch TrajectoryCompressor):
+  - Protect: system prompt, first human turn, first assistant turn, first tool call
+  - Protect: last N turns (default 4)
+  - Protect: all tool_call / tool turns (preserve executable context)
+  - Middle turns: replaced with a single human summary message
+  - Token budget: 15,250 tokens (character-approximated at 4 chars/token)
+
+The summary is derived from the actual conversation content — no LLM call needed.
+Timmy has full context at compression time: tool names, actions, and outcomes are
+extracted from the turns he already wrote.
+"""
+
+import json
+import logging
+import os
+import re
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Tuple
+
+logger = logging.getLogger(__name__)
+
+# Characters-per-token approximation (fast, no tokenizer dependency)
+_CHARS_PER_TOKEN = 4
+
+
+@dataclass
+class LiveCompressConfig:
+    """Configuration for post-session live compression."""
+    enabled: bool = False
+    output_dir: str = "~/autolora/data/live"
+    target_max_tokens: int = 15_250
+    protect_last_n_turns: int = 4
+    # Protected roles/types — always kept verbatim
+    protect_system: bool = True
+    protect_first_human: bool = True
+    protect_first_assistant: bool = True
+    protect_first_tool: bool = True
+    # Whether to skip compression when already under budget
+    skip_under_target: bool = True
+
+    @classmethod
+    def from_config_dict(cls, cfg: Dict[str, Any]) -> "LiveCompressConfig":
+        """Build from the ``autolora`` section of config.yaml."""
+        obj = cls()
+        obj.enabled = bool(cfg.get("enabled", obj.enabled))
+        obj.output_dir = str(cfg.get("output_dir", obj.output_dir))
+        obj.target_max_tokens = int(cfg.get("target_max_tokens", obj.target_max_tokens))
+        obj.protect_last_n_turns = int(cfg.get("protect_last_n_turns", obj.protect_last_n_turns))
+        obj.skip_under_target = bool(cfg.get("skip_under_target", obj.skip_under_target))
+        return obj
+
+
+def _estimate_tokens(text: str) -> int:
+    """Cheap character-based token estimate (1 token ≈ 4 chars)."""
+    return max(1, len(text) // _CHARS_PER_TOKEN)
+
+
+def _turn_tokens(turn: Dict[str, Any]) -> int:
+    """Estimate tokens for a single ShareGPT-format turn."""
+    content = turn.get("value", "")
+    if isinstance(content, list):
+        # Some turns carry structured content lists
+        content = " ".join(
+            part.get("text", "") if isinstance(part, dict) else str(part)
+            for part in content
+        )
+    return _estimate_tokens(str(content))
+
+
+def _trajectory_tokens(turns: List[Dict[str, Any]]) -> int:
+    return sum(_turn_tokens(t) for t in turns)
+
+
+def _is_tool_turn(turn: Dict[str, Any]) -> bool:
+    """Return True for tool_call or tool result turns."""
+    role = turn.get("from", "")
+    return role in ("tool_call", "tool", "function_call", "function")
+
+
+def _extract_summary(middle_turns: List[Dict[str, Any]], session_id: str) -> str:
+    """Build a factual summary paragraph from the middle turns.
+
+    No LLM call is made.  The summary is derived by:
+      1. Enumerating tool calls made (what actions were taken)
+      2. Counting assistant / human exchanges
+      3. Noting the volume of tool responses processed
+    """
+    tool_names: List[str] = []
+    assistant_turns = 0
+    human_turns = 0
+    tool_result_turns = 0
+
+    for turn in middle_turns:
+        role = turn.get("from", "")
+        content = str(turn.get("value", ""))
+
+        if role == "gpt":
+            assistant_turns += 1
+            # Extract tool names from structured tool calls in content
+            # Handles both JSON-encoded calls and plain text references
+            for match in re.finditer(r'"name"\s*:\s*"([^"]+)"', content):
+                tool_names.append(match.group(1))
+            # Also capture <tool_call> XML-style patterns
+            for match in re.finditer(r'<tool_call[^>]*>.*?"name"\s*:\s*"([^"]+)"', content, re.DOTALL):
+                tool_names.append(match.group(1))
+        elif role == "human":
+            human_turns += 1
+        elif _is_tool_turn(turn):
+            tool_result_turns += 1
+
+    unique_tools = list(dict.fromkeys(tool_names))  # deduplicate, preserve order
+
+    parts = [
+        f"[AutoLoRA compression — session {session_id}]",
+        f"The following {len(middle_turns)} middle turns have been compressed to save tokens.",
+        f"This region contained {assistant_turns} assistant turn(s) and {human_turns} human turn(s).",
+    ]
+
+    if tool_result_turns:
+        parts.append(f"{tool_result_turns} tool response(s) were processed.")
+
+    if unique_tools:
+        tools_str = ", ".join(unique_tools[:20])
+        if len(unique_tools) > 20:
+            tools_str += f", … ({len(unique_tools)} total)"
+        parts.append(f"Tools invoked: {tools_str}.")
+
+    parts.append(
+        "Some of your previous tool responses may be summarized to preserve context."
+    )
+
+    return " ".join(parts)
+
+
+def _identify_protected_indices(
+    turns: List[Dict[str, Any]],
+    cfg: LiveCompressConfig,
+) -> Tuple[set, set]:
+    """Return (head_protected, tail_protected) index sets.
+
+    head_protected — indices of turns that must be kept at the start
+    tail_protected — indices of the last N turns
+    """
+    head_protected: set = set()
+    seen_system = seen_human = seen_assistant = seen_tool = False
+
+    for i, turn in enumerate(turns):
+        role = turn.get("from", "")
+
+        if cfg.protect_system and role == "system" and not seen_system:
+            head_protected.add(i)
+            seen_system = True
+        elif cfg.protect_first_human and role == "human" and not seen_human:
+            head_protected.add(i)
+            seen_human = True
+        elif cfg.protect_first_assistant and role == "gpt" and not seen_assistant:
+            head_protected.add(i)
+            seen_assistant = True
+        elif cfg.protect_first_tool and _is_tool_turn(turn) and not seen_tool:
+            head_protected.add(i)
+            seen_tool = True
+
+    n = len(turns)
+    tail_protected: set = set(range(max(0, n - cfg.protect_last_n_turns), n))
+
+    return head_protected, tail_protected
+
+
+def compress_trajectory(
+    trajectory: List[Dict[str, Any]],
+    session_id: str,
+    cfg: LiveCompressConfig,
+) -> Tuple[List[Dict[str, Any]], bool]:
+    """Compress a ShareGPT-format trajectory list.
+
+    Returns (compressed_turns, was_compressed).
+    """
+    total_tokens = _trajectory_tokens(trajectory)
+    if cfg.skip_under_target and total_tokens <= cfg.target_max_tokens:
+        logger.debug(
+            "Session %s: trajectory already within budget (%d tokens ≤ %d), skipping compression.",
+            session_id, total_tokens, cfg.target_max_tokens,
+        )
+        return trajectory, False
+
+    head_protected, tail_protected = _identify_protected_indices(trajectory, cfg)
+    all_protected = head_protected | tail_protected
+
+    # Identify the compressible middle region
+    middle_indices = [i for i in range(len(trajectory)) if i not in all_protected]
+
+    if not middle_indices:
+        logger.debug("Session %s: no compressible middle turns found.", session_id)
+        return trajectory, False
+
+    # Build summary turn
+    middle_turns = [trajectory[i] for i in middle_indices]
+    summary_text = _extract_summary(middle_turns, session_id)
+    summary_turn = {"from": "human", "value": summary_text}
+
+    # Build compressed trajectory
+    compressed: List[Dict[str, Any]] = []
+    summary_inserted = False
+
+    for i, turn in enumerate(trajectory):
+        if i in middle_indices:
+            if not summary_inserted:
+                compressed.append(summary_turn)
+                summary_inserted = True
+            # skip the original middle turn
+        else:
+            compressed.append(turn)
+
+    compressed_tokens = _trajectory_tokens(compressed)
+    logger.info(
+        "Session %s: compressed %d → %d turns, ~%d → ~%d tokens.",
+        session_id,
+        len(trajectory), len(compressed),
+        total_tokens, compressed_tokens,
+    )
+
+    return compressed, True
+
+
+def run_post_session_hook(
+    trajectory: List[Dict[str, Any]],
+    session_id: str,
+    model: str,
+    completed: bool,
+    cfg: LiveCompressConfig,
+) -> Optional[Path]:
+    """Compress trajectory and write to ~/autolora/data/live/.
+
+    Returns the output Path on success, None on skip/failure.
+    This function never raises — errors are logged as warnings.
+    """
+    if not cfg.enabled:
+        return None
+
+    try:
+        compressed, was_compressed = compress_trajectory(trajectory, session_id, cfg)
+
+        output_dir = Path(cfg.output_dir).expanduser()
+        output_dir.mkdir(parents=True, exist_ok=True)
+
+        output_path = output_dir / f"{session_id}_compressed.jsonl"
+
+        entry = {
+            "conversations": compressed,
+            "timestamp": datetime.now().isoformat(),
+            "model": model,
+            "completed": completed,
+            "session_id": session_id,
+            "autolora": {
+                "source": "live_hook",
+                "was_compressed": was_compressed,
+                "original_turns": len(trajectory),
+                "compressed_turns": len(compressed),
+            },
+        }
+
+        with open(output_path, "w", encoding="utf-8") as f:
+            f.write(json.dumps(entry, ensure_ascii=False) + "\n")
+
+        logger.info("AutoLoRA live: saved compressed trajectory → %s", output_path)
+        return output_path
+
+    except Exception as exc:
+        logger.warning("AutoLoRA live compression failed (non-fatal): %s", exc)
+        return None
diff --git a/cli-config.yaml.example b/cli-config.yaml.example
index 104398c2..07dee9f6 100644
--- a/cli-config.yaml.example
+++ b/cli-config.yaml.example
@@ -734,3 +734,30 @@ display:
   #   tool_prefix: "╎"                       # Tool output line prefix (default: ┊)
   #
   skin: default
+
+
+# =============================================================================
+# AutoLoRA Live Compression
+# =============================================================================
+# Post-session hook: after each session, compress the trajectory and save it
+# to output_dir for the training pipeline.  No external API calls are made —
+# middle turns are replaced with a structured summary derived from the
+# conversation content.
+#
+# Protected turns (always kept verbatim):
+#   - System prompt
+#   - First human turn
+#   - First assistant turn
+#   - First tool call/response
+#   - Last N turns (protect_last_n_turns)
+#
+# Middle turns are replaced with a single summary message.
+#
+# Output: {output_dir}/{session_id}_compressed.jsonl
+#
+# autolora:
+#   enabled: false
+#   output_dir: ~/autolora/data/live
+#   target_max_tokens: 15250
+#   protect_last_n_turns: 4
+#   skip_under_target: true   # skip compression when already within budget
diff --git a/cli.py b/cli.py
index 253cdd08..93771174 100755
--- a/cli.py
+++ b/cli.py
@@ -221,6 +221,13 @@ def load_cli_config() -> Dict[str, Any]:
             "model": "",       # Subagent model override (empty = inherit parent model)
             "provider": "",    # Subagent provider override (empty = inherit parent provider)
         },
+        "autolora": {
+            "enabled": False,              # Enable post-session trajectory compression
+            "output_dir": "~/autolora/data/live",
+            "target_max_tokens": 15250,
+            "protect_last_n_turns": 4,
+            "skip_under_target": True,
+        },
     }

     # Track whether the config file explicitly set terminal config.
@@ -1255,6 +1262,14 @@ class HermesCLI:
         fb = CLI_CONFIG.get("fallback_model") or {}
         self._fallback_model = fb if fb.get("provider") and fb.get("model") else None

+        # AutoLoRA live compression config — post-session hook (optional, disabled by default)
+        _autolora_cfg = CLI_CONFIG.get("autolora", {})
+        if _autolora_cfg.get("enabled", False):
+            from agent.live_compressor import LiveCompressConfig
+            self._autolora_live_cfg = LiveCompressConfig.from_config_dict(_autolora_cfg)
+        else:
+            self._autolora_live_cfg = None
+
         # Agent will be initialized on first use
         self.agent: Optional[AIAgent] = None
         self._app = None  # prompt_toolkit Application (set in run())
@@ -1548,6 +1563,7 @@ class HermesCLI:
                 checkpoints_enabled=self.checkpoints_enabled,
                 checkpoint_max_snapshots=self.checkpoint_max_snapshots,
                 pass_session_id=self.pass_session_id,
+                autolora_live_compress=self._autolora_live_cfg,
             )
             # Apply any pending title now that the session exists in the DB
             if self._pending_title and self._session_db:
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index 02edad1f..07adbe55 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -264,6 +264,18 @@ DEFAULT_CONFIG = {
         "tirith_fail_open": True,
     },

+    # AutoLoRA live compression — post-session trajectory hook.
+    # When enabled, compresses each session's trajectory and writes it to
+    # output_dir for inclusion in the training pipeline.  Disabled by default
+    # to keep sessions fast; enable per-machine in ~/.hermes/config.yaml.
+    "autolora": {
+        "enabled": False,
+        "output_dir": "~/autolora/data/live",
+        "target_max_tokens": 15250,
+        "protect_last_n_turns": 4,
+        "skip_under_target": True,
+    },
+
     # Config schema version - bump this when adding new required fields
     "_config_version": 7,
 }
diff --git a/run_agent.py b/run_agent.py
index ba214b71..905dcaca 100644
--- a/run_agent.py
+++ b/run_agent.py
@@ -288,6 +288,7 @@ class AIAgent:
         checkpoints_enabled: bool = False,
         checkpoint_max_snapshots: int = 50,
         pass_session_id: bool = False,
+        autolora_live_compress: "LiveCompressConfig | None" = None,
     ):
         """
         Initialize the AI Agent.
@@ -347,6 +348,7 @@ class AIAgent:
         self.platform = platform  # "cli", "telegram", "discord", "whatsapp", etc.
         self.skip_context_files = skip_context_files
         self.pass_session_id = pass_session_id
+        self._autolora_live_compress = autolora_live_compress
         self.log_prefix_chars = log_prefix_chars
         self.log_prefix = f"{log_prefix} " if log_prefix else ""
         # Store effective base URL for feature detection (prompt caching, reasoning, etc.)
@@ -1261,7 +1263,35 @@ class AIAgent:

         trajectory = self._convert_to_trajectory_format(messages, user_query, completed)
         _save_trajectory_to_file(trajectory, self.model, completed)
-
+
+    def _run_autolora_live_hook(
+        self,
+        messages: List[Dict[str, Any]],
+        user_query: str,
+        completed: bool,
+    ):
+        """Post-session AutoLoRA hook: compress trajectory and write to ~/autolora/data/live/.
+
+        Fires after every session when autolora_live_compress is enabled.
+        Never raises — errors are logged as warnings and the session continues normally.
+        """
+        cfg = self._autolora_live_compress
+        if cfg is None or not cfg.enabled:
+            return
+
+        try:
+            from agent.live_compressor import run_post_session_hook
+            trajectory = self._convert_to_trajectory_format(messages, user_query, completed)
+            run_post_session_hook(
+                trajectory=trajectory,
+                session_id=self.session_id,
+                model=self.model,
+                completed=completed,
+                cfg=cfg,
+            )
+        except Exception as exc:
+            logger.warning("AutoLoRA live hook failed (non-fatal): %s", exc)
+
     def _mask_api_key_for_logs(self, key: Optional[str]) -> Optional[str]:
         if not key:
             return None
@@ -5340,6 +5370,9 @@ class AIAgent:
         # Save trajectory if enabled
         self._save_trajectory(messages, user_message, completed)

+        # AutoLoRA live compression hook — runs post-session, no external API calls
+        self._run_autolora_live_hook(messages, user_message, completed)
+
         # Clean up VM and browser for this task after conversation completes
         self._cleanup_task_resources(effective_task_id)

diff --git a/tests/test_live_compressor.py b/tests/test_live_compressor.py
new file mode 100644
index 00000000..4578f7cf
--- /dev/null
+++ b/tests/test_live_compressor.py
@@ -0,0 +1,333 @@
+"""Tests for agent/live_compressor.py — AutoLoRA post-session hook."""
+
+import json
+from pathlib import Path
+
+import pytest
+
+from agent.live_compressor import (
+    LiveCompressConfig,
+    _estimate_tokens,
+    _turn_tokens,
+    _trajectory_tokens,
+    _is_tool_turn,
+    _extract_summary,
+    _identify_protected_indices,
+    compress_trajectory,
+    run_post_session_hook,
+)
+
+
+# ---------------------------------------------------------------------------
+# Helpers / fixtures
+# ---------------------------------------------------------------------------
+
+def _make_turn(role: str, value: str) -> dict:
+    return {"from": role, "value": value}
+
+
+def _make_simple_trajectory(num_middle: int = 6) -> list:
+    """Build a minimal trajectory: system + first_human + first_gpt + N middle + last4."""
+    turns = [
+        _make_turn("system", "You are a helpful assistant."),
+        _make_turn("human", "Hello, can you help me?"),
+        _make_turn("gpt", "Of course! What do you need?"),
+    ]
+    for i in range(num_middle):
+        turns.append(_make_turn("human", f"Middle human turn {i}."))
+        turns.append(_make_turn("gpt", f"Middle gpt turn {i}. " + "x" * 50))
+    turns += [
+        _make_turn("human", "Last user question."),
+        _make_turn("gpt", "Last answer."),
+        _make_turn("human", "Follow-up."),
+        _make_turn("gpt", "Done."),
+    ]
+    return turns
+
+
+# ---------------------------------------------------------------------------
+# LiveCompressConfig
+# ---------------------------------------------------------------------------
+
+class TestLiveCompressConfig:
+    def test_defaults(self):
+        cfg = LiveCompressConfig()
+        assert cfg.enabled is False
+        assert cfg.target_max_tokens == 15_250
+        assert cfg.protect_last_n_turns == 4
+        assert cfg.skip_under_target is True
+        assert "autolora/data/live" in cfg.output_dir
+
+    def test_from_config_dict(self):
+        d = {
+            "enabled": True,
+            "output_dir": "/tmp/autolora",
+            "target_max_tokens": 8000,
+            "protect_last_n_turns": 3,
+            "skip_under_target": False,
+        }
+        cfg = LiveCompressConfig.from_config_dict(d)
+        assert cfg.enabled is True
+        assert cfg.output_dir == "/tmp/autolora"
+        assert cfg.target_max_tokens == 8000
+        assert cfg.protect_last_n_turns == 3
+        assert cfg.skip_under_target is False
+
+    def test_from_config_dict_partial(self):
+        cfg = LiveCompressConfig.from_config_dict({"enabled": True})
+        assert cfg.enabled is True
+        assert cfg.target_max_tokens == 15_250  # default preserved
+
+
+# ---------------------------------------------------------------------------
+# Token estimation
+# ---------------------------------------------------------------------------
+
+class TestTokenEstimation:
+    def test_estimate_tokens_basic(self):
+        # 4 chars → 1 token
+        assert _estimate_tokens("abcd") == 1
+        assert _estimate_tokens("abcdefgh") == 2
+
+    def test_estimate_tokens_minimum(self):
+        assert _estimate_tokens("") == 1  # minimum 1
+
+    def test_turn_tokens(self):
+        turn = _make_turn("gpt", "a" * 400)
+        assert _turn_tokens(turn) == 100
+
+    def test_trajectory_tokens(self):
+        turns = [_make_turn("human", "a" * 40), _make_turn("gpt", "b" * 80)]
+        assert _trajectory_tokens(turns) == 30  # 10 + 20
+
+
+# ---------------------------------------------------------------------------
+# Tool turn detection
+# ---------------------------------------------------------------------------
+
+class TestIsToolTurn:
+    def test_tool_roles(self):
+        for role in ("tool_call", "tool", "function_call", "function"):
+            assert _is_tool_turn(_make_turn(role, "data")) is True
+
+    def test_non_tool_roles(self):
+        for role in ("human", "gpt", "system"):
+            assert _is_tool_turn(_make_turn(role, "data")) is False
+
+
+# ---------------------------------------------------------------------------
+# Summary extraction
+# ---------------------------------------------------------------------------
+
+class TestExtractSummary:
+    def test_basic_summary(self):
+        turns = [
+            _make_turn("gpt", "I will call a tool."),
+            _make_turn("human", "Please do."),
+            _make_turn("tool", "tool result"),
+        ]
+        summary = _extract_summary(turns, "sess123")
+        assert "sess123" in summary
+        assert "3 middle turns" in summary
+        assert "1 tool response" in summary
+
+    def test_tool_names_extracted(self):
+        turns = [
+            _make_turn("gpt", '{"name": "read_file", "args": {}}'),
+            _make_turn("gpt", '{"name": "write_file", "args": {}}'),
+        ]
+        summary = _extract_summary(turns, "s1")
+        assert "read_file" in summary
+        assert "write_file" in summary
+
+    def test_empty_turns(self):
+        summary = _extract_summary([], "s2")
+        assert "0 middle turns" in summary
+
+    def test_summary_notice_appended(self):
+        turns = [_make_turn("gpt", "hi")]
+        summary = _extract_summary(turns, "s3")
+        assert "summarized" in summary.lower()
+
+
+# ---------------------------------------------------------------------------
+# Protected indices
+# ---------------------------------------------------------------------------
+
+class TestIdentifyProtectedIndices:
+    def test_head_protected(self):
+        trajectory = [
+            _make_turn("system", "sys"),
+            _make_turn("human", "hi"),
+            _make_turn("gpt", "hello"),
+            _make_turn("tool", "result"),
+            _make_turn("human", "next"),
+        ]
+        cfg = LiveCompressConfig()
+        head, tail = _identify_protected_indices(trajectory, cfg)
+        assert 0 in head  # system
+        assert 1 in head  # first human
+        assert 2 in head  # first gpt
+        assert 3 in head  # first tool
+
+    def test_tail_protected(self):
+        trajectory = [_make_turn("human", f"msg {i}") for i in range(10)]
+        cfg = LiveCompressConfig()
+        _, tail = _identify_protected_indices(trajectory, cfg)
+        assert tail == {6, 7, 8, 9}
+
+    def test_custom_last_n(self):
+        trajectory = [_make_turn("human", f"msg {i}") for i in range(10)]
+        cfg = LiveCompressConfig(protect_last_n_turns=2)
+        _, tail = _identify_protected_indices(trajectory, cfg)
+        assert tail == {8, 9}
+
+
+# ---------------------------------------------------------------------------
+# compress_trajectory
+# ---------------------------------------------------------------------------
+
+class TestCompressTrajectory:
+    def test_skips_when_under_budget(self):
+        cfg = LiveCompressConfig(target_max_tokens=99999, skip_under_target=True)
+        trajectory = _make_simple_trajectory(2)
+        compressed, was_compressed = compress_trajectory(trajectory, "s1", cfg)
+        assert was_compressed is False
+        assert compressed == trajectory
+
+    def test_compresses_middle_turns(self):
+        # Build trajectory large enough to exceed a tiny budget
+        trajectory = _make_simple_trajectory(num_middle=10)
+        cfg = LiveCompressConfig(
+            target_max_tokens=10,  # tiny budget to force compression
+            protect_last_n_turns=4,
+            skip_under_target=True,
+        )
+        compressed, was_compressed = compress_trajectory(trajectory, "sess_test", cfg)
+        assert was_compressed is True
+        # Compressed should be shorter
+        assert len(compressed) < len(trajectory)
+
+    def test_protected_turns_preserved(self):
+        trajectory = [
+            _make_turn("system", "sys prompt"),
+            _make_turn("human", "first user msg"),
+            _make_turn("gpt", "first reply"),
+            _make_turn("human", "middle 1"),
+            _make_turn("gpt", "middle reply"),
+            _make_turn("human", "last-4 user"),
+            _make_turn("gpt", "last-4 reply"),
+            _make_turn("human", "last-3 user"),
+            _make_turn("gpt", "last-3 reply"),
+        ]
+        cfg = LiveCompressConfig(
+            target_max_tokens=5,  # force compression
+            protect_last_n_turns=4,
+        )
+        compressed, was_compressed = compress_trajectory(trajectory, "s1", cfg)
+        assert was_compressed is True
+
+        roles = [t["from"] for t in compressed]
+        # system, first human, first gpt always at head
+        assert roles[0] == "system"
+        assert roles[1] == "human"
+        assert roles[2] == "gpt"
+        # last 4 preserved at tail
+        assert roles[-1] == "gpt"
+
+    def test_summary_turn_is_human_role(self):
+        trajectory = _make_simple_trajectory(num_middle=10)
+        cfg = LiveCompressConfig(target_max_tokens=10, protect_last_n_turns=4)
+        compressed, _ = compress_trajectory(trajectory, "s1", cfg)
+        # The summary turn replaces middle turns and should be "human" role
+        summary_turns = [t for t in compressed if "AutoLoRA compression" in t.get("value", "")]
+        assert len(summary_turns) == 1
+        assert summary_turns[0]["from"] == "human"
+
+    def test_no_compressible_middle(self):
+        """If everything is protected, return unchanged."""
+        trajectory = [
+            _make_turn("system", "s"),
+            _make_turn("human", "h"),
+            _make_turn("gpt", "g"),
+            _make_turn("human", "l1"),
+        ]
+        cfg = LiveCompressConfig(target_max_tokens=1, protect_last_n_turns=4)
+        compressed, was_compressed = compress_trajectory(trajectory, "s1", cfg)
+        assert was_compressed is False
+
+
+# ---------------------------------------------------------------------------
+# run_post_session_hook
+# ---------------------------------------------------------------------------
+
+class TestRunPostSessionHook:
+    def test_disabled_returns_none(self, tmp_path):
+        cfg = LiveCompressConfig(enabled=False, output_dir=str(tmp_path))
+        result = run_post_session_hook(
+            trajectory=[_make_turn("human", "hi")],
+            session_id="sid1",
+            model="test-model",
+            completed=True,
+            cfg=cfg,
+        )
+        assert result is None
+        # No JSONL file should have been written
+        assert not any(f.suffix == ".jsonl" for f in tmp_path.rglob("*"))
+
+    def test_writes_jsonl_file(self, tmp_path):
+        trajectory = _make_simple_trajectory(num_middle=8)
+        cfg = LiveCompressConfig(
+            enabled=True,
+            output_dir=str(tmp_path),
+            target_max_tokens=10,
+        )
+        result = run_post_session_hook(
+            trajectory=trajectory,
+            session_id="session_abc",
+            model="my-model",
+            completed=True,
+            cfg=cfg,
+        )
+        assert result is not None
+        assert result.exists()
+        assert result.name == "session_abc_compressed.jsonl"
+
+        with open(result) as f:
+            entry = json.loads(f.read())
+
+        assert entry["session_id"] == "session_abc"
+        assert entry["model"] == "my-model"
+        assert entry["completed"] is True
+        assert "conversations" in entry
+        assert entry["autolora"]["source"] == "live_hook"
+
+    def test_creates_output_dir(self, tmp_path):
+        output_dir = tmp_path / "deep" / "nested" / "dir"
+        cfg = LiveCompressConfig(
+            enabled=True,
+            output_dir=str(output_dir),
+            target_max_tokens=10,
+        )
+        trajectory = _make_simple_trajectory(num_middle=5)
+        result = run_post_session_hook(
+            trajectory=trajectory,
+            session_id="sid2",
+            model="m",
+            completed=False,
+            cfg=cfg,
+        )
+        assert result is not None
+        assert output_dir.exists()
+
+    def test_never_raises_on_bad_path(self):
+        """Hook must be non-fatal."""
+        cfg = LiveCompressConfig(enabled=True, output_dir="/proc/invalid_______path")
+        result = run_post_session_hook(
+            trajectory=[_make_turn("human", "hi")],
+            session_id="s",
+            model="m",
+            completed=True,
+            cfg=cfg,
+        )
+        assert result is None  # error swallowed, None returned


Prompt:
## Summary
Timmy (the orchestrator) went down and needs to come back online. The previous backend is gone. We need a new backend — **Ollama** is the primary choice so Timmy can run locally and orchestrate the Council of Wizards (Claude, Gemini, Kimi agents).

## Requirements

### Phase 1: Ollama Backend
- [ ] Set up Ollama as the LLM backend for hermes-agent
- [ ] Ensure Timmy can boot and respond to basic prompts
- [ ] Connect Timmy to Gitea API for issue/PR awareness

### Phase 2: Orchestration Role
- [ ] Timmy should be able to triage and assign issues to agents (claude, gemini, kimi)
- [ ] Timmy should review PRs from agents before merge (quality gate)
- [ ] Timmy should prioritize the backlog and make assignment decisions
- [ ] Timmy should monitor agent health and reassign stuck issues

### Phase 3: Alternative Backend (stretch)
- [ ] Investigate Google AI Ultra (Gemini) as alternative backend since user has paid subscription
- [ ] Allow hot-swapping backends (Ollama ↔ Gemini API)

## Context
- Gitea: http://143.198.27.163:3000
- Agent loops: claude-loop.sh, gemini-loop.sh, kimi-loop.sh in hermes/hermes-config
- The agent loops currently self-assign from the backlog. Timmy should take over assignment decisions.
- Ollama should run locally on the Mac

## Priority
URGENT — Timmy is the brain of the operation and is currently offline.

Chosen response:
diff --git a/pyproject.toml b/pyproject.toml
index 7e419772..ebc4ec5d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -84,12 +84,13 @@ all = [
 hermes = "hermes_cli.main:main"
 hermes-agent = "run_agent:main"
 hermes-acp = "acp_adapter.entry:main"
+timmy = "timmy.__main__:main"

 [tool.setuptools]
 py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_constants", "hermes_state", "hermes_time", "mini_swe_runner", "rl_cli", "utils"]

 [tool.setuptools.packages.find]
-include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "cron", "honcho_integration", "acp_adapter"]
+include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "cron", "honcho_integration", "acp_adapter", "timmy"]

 [tool.pytest.ini_options]
 testpaths = ["tests"]
diff --git a/tests/test_timmy.py b/tests/test_timmy.py
new file mode 100644
index 00000000..2284fbde
--- /dev/null
+++ b/tests/test_timmy.py
@@ -0,0 +1,388 @@
+"""
+Tests for the timmy orchestrator — Ollama-backed multi-agent coordinator.
+
+All external I/O (Gitea API, LLM) is mocked so tests run offline.
+"""
+
+from __future__ import annotations
+
+import json
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from timmy.gitea_client import GiteaClient
+from timmy.orchestrator import (
+    AGENT_ROSTER,
+    TimmyOrchestrator,
+    review_pr,
+    triage_issue,
+)
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _issue(number: int, title: str = "Fix bug", body: str = "", assignees=None) -> dict:
+    return {
+        "number": number,
+        "title": title,
+        "body": body,
+        "assignees": assignees or [],
+        "pull_request": None,
+        "updated_at": "2026-01-01T00:00:00Z",
+        "created_at": "2026-01-01T00:00:00Z",
+    }
+
+
+def _pr(number: int, author: str = "claude", title: str = "feat: something") -> dict:
+    return {
+        "number": number,
+        "title": title,
+        "body": "Fixes #1",
+        "user": {"login": author},
+    }
+
+
+# ---------------------------------------------------------------------------
+# GiteaClient unit tests
+# ---------------------------------------------------------------------------
+
+class TestGiteaClient:
+    def test_defaults(self):
+        client = GiteaClient(base_url="http://localhost:3000", token="tok", repo="o/r")
+        assert client.base_url == "http://localhost:3000"
+        assert client.token == "tok"
+        assert client.repo == "o/r"
+        assert client._api == "http://localhost:3000/api/v1"
+
+    def test_trailing_slash_stripped(self):
+        client = GiteaClient(base_url="http://localhost:3000/", token="tok", repo="o/r")
+        assert not client.base_url.endswith("/")
+
+    def test_env_fallbacks(self, monkeypatch):
+        monkeypatch.setenv("GITEA_URL", "http://env-host:3000")
+        monkeypatch.setenv("GITEA_TOKEN", "env-token")
+        monkeypatch.setenv("GITEA_REPO", "env/repo")
+        client = GiteaClient()
+        assert client.base_url == "http://env-host:3000"
+        assert client.token == "env-token"
+        assert client.repo == "env/repo"
+
+    def test_list_issues_calls_correct_path(self):
+        client = GiteaClient(base_url="http://h:3000", token="t", repo="o/r")
+        with patch.object(client, "_get", return_value=[]) as mock_get:
+            result = client.list_issues(state="open", limit=10)
+        mock_get.assert_called_once_with(
+            "/repos/o/r/issues",
+            params={"state": "open", "page": 1, "limit": 10},
+        )
+        assert result == []
+
+    def test_assign_issue_patch(self):
+        client = GiteaClient(base_url="http://h:3000", token="t", repo="o/r")
+        with patch.object(client, "_patch", return_value={"number": 5}) as mock_patch:
+            client.assign_issue(5, ["claude"])
+        mock_patch.assert_called_once_with(
+            "/repos/o/r/issues/5",
+            {"assignees": ["claude"]},
+        )
+
+    def test_add_issue_comment(self):
+        client = GiteaClient(base_url="http://h:3000", token="t", repo="o/r")
+        with patch.object(client, "_post", return_value={"id": 99}) as mock_post:
+            client.add_issue_comment(3, "hello")
+        mock_post.assert_called_once_with(
+            "/repos/o/r/issues/3/comments",
+            {"body": "hello"},
+        )
+
+    def test_add_pr_review(self):
+        client = GiteaClient(base_url="http://h:3000", token="t", repo="o/r")
+        with patch.object(client, "_post", return_value={}) as mock_post:
+            client.add_pr_review(7, "Looks good", event="APPROVE")
+        mock_post.assert_called_once_with(
+            "/repos/o/r/pulls/7/reviews",
+            {"body": "Looks good", "event": "APPROVE"},
+        )
+
+    def test_get_pr_files(self):
+        client = GiteaClient(base_url="http://h:3000", token="t", repo="o/r")
+        files = [{"filename": "foo.py"}, {"filename": "bar.py"}]
+        with patch.object(client, "_get", return_value=files) as mock_get:
+            result = client.get_pr_files(42)
+        mock_get.assert_called_once_with("/repos/o/r/pulls/42/files")
+        assert result == files
+
+
+# ---------------------------------------------------------------------------
+# triage_issue
+# ---------------------------------------------------------------------------
+
+class TestTriageIssue:
+    def _ask_returns(self, response: str):
+        return patch("timmy.orchestrator._ask", return_value=response)
+
+    def test_parses_valid_json(self):
+        payload = '{"agent": "gemini", "priority": 2, "reason": "needs docs"}'
+        with self._ask_returns(payload):
+            result = triage_issue(_issue(1, title="Write docs"))
+        assert result["agent"] == "gemini"
+        assert result["priority"] == 2
+
+    def test_parses_json_embedded_in_markdown(self):
+        payload = "Sure thing!\n```json\n{\"agent\": \"kimi\", \"priority\": 4, \"reason\": \"tiny fix\"}\n```"
+        with self._ask_returns(payload):
+            result = triage_issue(_issue(2))
+        assert result["agent"] == "kimi"
+
+    def test_defaults_on_bad_json(self):
+        with self._ask_returns("I cannot decide"):
+            result = triage_issue(_issue(3))
+        assert result["agent"] == "claude"
+        assert result["priority"] == 3
+
+    def test_defaults_on_empty_response(self):
+        with self._ask_returns(""):
+            result = triage_issue(_issue(4))
+        assert result["agent"] == "claude"
+
+    def test_body_truncated_to_2000_chars(self):
+        long_body = "x" * 5000
+        issue = _issue(5, body=long_body)
+        calls = []
+        def fake_ask(prompt, system="", max_tokens=512):
+            calls.append(prompt)
+            return '{"agent": "claude", "priority": 3, "reason": "ok"}'
+        with patch("timmy.orchestrator._ask", side_effect=fake_ask):
+            triage_issue(issue)
+        # body in prompt should be <= 2000 chars
+        assert len(calls[0]) <= 2200  # some slack for title/prefix
+
+
+# ---------------------------------------------------------------------------
+# review_pr
+# ---------------------------------------------------------------------------
+
+class TestReviewPr:
+    def _ask_returns(self, response: str):
+        return patch("timmy.orchestrator._ask", return_value=response)
+
+    def test_parses_approve(self):
+        payload = '{"verdict": "APPROVE", "summary": "All good"}'
+        with self._ask_returns(payload):
+            result = review_pr(_pr(10), [{"filename": "main.py"}])
+        assert result["verdict"] == "APPROVE"
+        assert "All good" in result["summary"]
+
+    def test_parses_request_changes(self):
+        payload = '{"verdict": "REQUEST_CHANGES", "summary": "Missing tests"}'
+        with self._ask_returns(payload):
+            result = review_pr(_pr(11), [])
+        assert result["verdict"] == "REQUEST_CHANGES"
+
+    def test_defaults_on_parse_error(self):
+        with self._ask_returns("???"):
+            result = review_pr(_pr(12), [])
+        assert result["verdict"] == "COMMENT"
+        assert "manual review" in result["summary"]
+
+    def test_file_list_capped_at_50(self):
+        files = [{"filename": f"file{i}.py"} for i in range(100)]
+        calls = []
+        def fake_ask(prompt, system="", max_tokens=512):
+            calls.append(prompt)
+            return '{"verdict": "APPROVE", "summary": "ok"}'
+        with patch("timmy.orchestrator._ask", side_effect=fake_ask):
+            review_pr(_pr(13), files)
+        # Only first 50 filenames should appear in the prompt
+        prompt = calls[0]
+        assert "file49.py" in prompt
+        assert "file50.py" not in prompt
+
+
+# ---------------------------------------------------------------------------
+# TimmyOrchestrator — triage phase
+# ---------------------------------------------------------------------------
+
+class TestOrchestratorTriage:
+    def _make_orchestrator(self, dry_run=False):
+        orch = TimmyOrchestrator(
+            gitea_url="http://h:3000",
+            gitea_token="tok",
+            repo="o/r",
+            dry_run=dry_run,
+        )
+        return orch
+
+    def test_skips_already_assigned(self):
+        orch = self._make_orchestrator()
+        issue_with_assignee = _issue(1, assignees=[{"login": "claude"}])
+        with patch.object(orch.gitea, "list_issues", return_value=[issue_with_assignee]):
+            with patch("timmy.orchestrator.triage_issue") as mock_triage:
+                orch.run_triage()
+        mock_triage.assert_not_called()
+
+    def test_skips_pull_requests(self):
+        orch = self._make_orchestrator()
+        pr_issue = _issue(2)
+        pr_issue["pull_request"] = {"html_url": "http://..."}
+        with patch.object(orch.gitea, "list_issues", return_value=[pr_issue]):
+            with patch("timmy.orchestrator.triage_issue") as mock_triage:
+                orch.run_triage()
+        mock_triage.assert_not_called()
+
+    def test_assigns_unassigned_issue(self):
+        orch = self._make_orchestrator(dry_run=False)
+        issue = _issue(3)
+        decision = {"agent": "kimi", "priority": 2, "reason": "fast fix"}
+        with patch.object(orch.gitea, "list_issues", return_value=[issue]):
+            with patch("timmy.orchestrator.triage_issue", return_value=decision):
+                with patch.object(orch.gitea, "assign_issue") as mock_assign:
+                    with patch.object(orch.gitea, "add_issue_comment") as mock_comment:
+                        orch.run_triage()
+        mock_assign.assert_called_once_with(3, ["kimi"])
+        mock_comment.assert_called_once()
+        body = mock_comment.call_args[0][1]
+        assert "@kimi" in body
+        assert "priority 2/5" in body
+
+    def test_dry_run_skips_writes(self):
+        orch = self._make_orchestrator(dry_run=True)
+        issue = _issue(4)
+        decision = {"agent": "gemini", "priority": 1, "reason": "urgent"}
+        with patch.object(orch.gitea, "list_issues", return_value=[issue]):
+            with patch("timmy.orchestrator.triage_issue", return_value=decision):
+                with patch.object(orch.gitea, "assign_issue") as mock_assign:
+                    orch.run_triage()
+        mock_assign.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# TimmyOrchestrator — PR review phase
+# ---------------------------------------------------------------------------
+
+class TestOrchestratorPrReview:
+    def _make_orchestrator(self, dry_run=False):
+        return TimmyOrchestrator(
+            gitea_url="http://h:3000",
+            gitea_token="tok",
+            repo="o/r",
+            dry_run=dry_run,
+        )
+
+    def test_reviews_agent_authored_prs(self):
+        orch = self._make_orchestrator()
+        pr = _pr(20, author="claude")
+        decision = {"verdict": "APPROVE", "summary": "Good work"}
+        with patch.object(orch.gitea, "list_prs", return_value=[pr]):
+            with patch.object(orch.gitea, "get_pr_files", return_value=[]):
+                with patch("timmy.orchestrator.review_pr", return_value=decision):
+                    with patch.object(orch.gitea, "add_pr_review") as mock_review:
+                        orch.run_pr_reviews()
+        mock_review.assert_called_once_with(20, "**[Timmy review]** Good work", event="APPROVE")
+
+    def test_skips_non_agent_prs(self):
+        orch = self._make_orchestrator()
+        pr = _pr(21, author="rockachopa")  # not an agent
+        with patch.object(orch.gitea, "list_prs", return_value=[pr]):
+            with patch("timmy.orchestrator.review_pr") as mock_review:
+                orch.run_pr_reviews()
+        mock_review.assert_not_called()
+
+    def test_dry_run_skips_review_post(self):
+        orch = self._make_orchestrator(dry_run=True)
+        pr = _pr(22, author="gemini")
+        decision = {"verdict": "REQUEST_CHANGES", "summary": "Needs work"}
+        with patch.object(orch.gitea, "list_prs", return_value=[pr]):
+            with patch.object(orch.gitea, "get_pr_files", return_value=[]):
+                with patch("timmy.orchestrator.review_pr", return_value=decision):
+                    with patch.object(orch.gitea, "add_pr_review") as mock_review:
+                        orch.run_pr_reviews()
+        mock_review.assert_not_called()
+
+    def test_all_agents_in_roster_are_reviewed(self):
+        orch = self._make_orchestrator()
+        prs = [_pr(30 + i, author=name) for i, name in enumerate(AGENT_ROSTER.values())]
+        decision = {"verdict": "COMMENT", "summary": "Fine"}
+        with patch.object(orch.gitea, "list_prs", return_value=prs):
+            with patch.object(orch.gitea, "get_pr_files", return_value=[]):
+                with patch("timmy.orchestrator.review_pr", return_value=decision):
+                    with patch.object(orch.gitea, "add_pr_review") as mock_review:
+                        orch.run_pr_reviews()
+        assert mock_review.call_count == len(AGENT_ROSTER)
+
+
+# ---------------------------------------------------------------------------
+# TimmyOrchestrator — stuck detection
+# ---------------------------------------------------------------------------
+
+class TestOrchestratorStuckDetection:
+    def _make_orchestrator(self, dry_run=False):
+        return TimmyOrchestrator(
+            gitea_url="http://h:3000",
+            gitea_token="tok",
+            repo="o/r",
+            dry_run=dry_run,
+        )
+
+    def test_pings_stuck_issue(self):
+        orch = self._make_orchestrator()
+        issue = _issue(50, assignees=[{"login": "kimi"}])
+        issue["updated_at"] = "2020-01-01T00:00:00Z"  # very old
+        with patch.object(orch.gitea, "list_issues", return_value=[issue]):
+            with patch.object(orch.gitea, "add_issue_comment") as mock_comment:
+                orch.run_stuck_detection()
+        mock_comment.assert_called_once()
+        body = mock_comment.call_args[0][1]
+        assert "@kimi" in body
+
+    def test_does_not_ping_recent_issue(self):
+        orch = self._make_orchestrator()
+        issue = _issue(51, assignees=[{"login": "claude"}])
+        # updated_at is now — not stuck
+        from datetime import datetime, timezone
+        issue["updated_at"] = datetime.now(timezone.utc).isoformat()
+        with patch.object(orch.gitea, "list_issues", return_value=[issue]):
+            with patch.object(orch.gitea, "add_issue_comment") as mock_comment:
+                orch.run_stuck_detection()
+        mock_comment.assert_not_called()
+
+    def test_skips_unassigned_issues(self):
+        orch = self._make_orchestrator()
+        issue = _issue(52, assignees=[])
+        issue["updated_at"] = "2020-01-01T00:00:00Z"
+        with patch.object(orch.gitea, "list_issues", return_value=[issue]):
+            with patch.object(orch.gitea, "add_issue_comment") as mock_comment:
+                orch.run_stuck_detection()
+        mock_comment.assert_not_called()
+
+    def test_dry_run_skips_ping(self):
+        orch = self._make_orchestrator(dry_run=True)
+        issue = _issue(53, assignees=[{"login": "gemini"}])
+        issue["updated_at"] = "2020-01-01T00:00:00Z"
+        with patch.object(orch.gitea, "list_issues", return_value=[issue]):
+            with patch.object(orch.gitea, "add_issue_comment") as mock_comment:
+                orch.run_stuck_detection()
+        mock_comment.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# TimmyOrchestrator — run_cycle error isolation
+# ---------------------------------------------------------------------------
+
+class TestOrchestratorRunCycle:
+    def test_phases_isolated_from_each_other(self):
+        """A failure in triage should not prevent PR reviews from running."""
+        orch = TimmyOrchestrator(
+            gitea_url="http://h:3000",
+            gitea_token="tok",
+            repo="o/r",
+        )
+        with patch.object(orch, "run_triage", side_effect=RuntimeError("triage boom")):
+            with patch.object(orch, "run_pr_reviews") as mock_reviews:
+                with patch.object(orch, "run_stuck_detection") as mock_stuck:
+                    orch.run_cycle()
+        mock_reviews.assert_called_once()
+        mock_stuck.assert_called_once()

Rejected response:
diff --git a/agent/live_compressor.py b/agent/live_compressor.py
new file mode 100644
index 00000000..33d0b192
--- /dev/null
+++ b/agent/live_compressor.py
@@ -0,0 +1,277 @@
+"""AutoLoRA live trajectory compressor — post-session hook.
+
+After each session, compresses the trajectory to a training-ready JSONL file
+in ~/autolora/data/live/ without any external API calls.
+
+Compression strategy (mirrors the batch TrajectoryCompressor):
+  - Protect: system prompt, first human turn, first assistant turn, first tool call
+  - Protect: last N turns (default 4)
+  - Protect: all tool_call / tool turns (preserve executable context)
+  - Middle turns: replaced with a single human summary message
+  - Token budget: 15,250 tokens (character-approximated at 4 chars/token)
+
+The summary is derived from the actual conversation content — no LLM call needed.
+Timmy has full context at compression time: tool names, actions, and outcomes are
+extracted from the turns he already wrote.
+"""
+
+import json
+import logging
+import os
+import re
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Tuple
+
+logger = logging.getLogger(__name__)
+
+# Characters-per-token approximation (fast, no tokenizer dependency)
+_CHARS_PER_TOKEN = 4
+
+
+@dataclass
+class LiveCompressConfig:
+    """Configuration for post-session live compression."""
+    enabled: bool = False
+    output_dir: str = "~/autolora/data/live"
+    target_max_tokens: int = 15_250
+    protect_last_n_turns: int = 4
+    # Protected roles/types — always kept verbatim
+    protect_system: bool = True
+    protect_first_human: bool = True
+    protect_first_assistant: bool = True
+    protect_first_tool: bool = True
+    # Whether to skip compression when already under budget
+    skip_under_target: bool = True
+
+    @classmethod
+    def from_config_dict(cls, cfg: Dict[str, Any]) -> "LiveCompressConfig":
+        """Build from the ``autolora`` section of config.yaml."""
+        obj = cls()
+        obj.enabled = bool(cfg.get("enabled", obj.enabled))
+        obj.output_dir = str(cfg.get("output_dir", obj.output_dir))
+        obj.target_max_tokens = int(cfg.get("target_max_tokens", obj.target_max_tokens))
+        obj.protect_last_n_turns = int(cfg.get("protect_last_n_turns", obj.protect_last_n_turns))
+        obj.skip_under_target = bool(cfg.get("skip_under_target", obj.skip_under_target))
+        return obj
+
+
+def _estimate_tokens(text: str) -> int:
+    """Cheap character-based token estimate (1 token ≈ 4 chars)."""
+    return max(1, len(text) // _CHARS_PER_TOKEN)
+
+
+def _turn_tokens(turn: Dict[str, Any]) -> int:
+    """Estimate tokens for a single ShareGPT-format turn."""
+    content = turn.get("value", "")
+    if isinstance(content, list):
+        # Some turns carry structured content lists
+        content = " ".join(
+            part.get("text", "") if isinstance(part, dict) else str(part)
+            for part in content
+        )
+    return _estimate_tokens(str(content))
+
+
+def _trajectory_tokens(turns: List[Dict[str, Any]]) -> int:
+    return sum(_turn_tokens(t) for t in turns)
+
+
+def _is_tool_turn(turn: Dict[str, Any]) -> bool:
+    """Return True for tool_call or tool result turns."""
+    role = turn.get("from", "")
+    return role in ("tool_call", "tool", "function_call", "function")
+
+
+def _extract_summary(middle_turns: List[Dict[str, Any]], session_id: str) -> str:
+    """Build a factual summary paragraph from the middle turns.
+
+    No LLM call is made.  The summary is derived by:
+      1. Enumerating tool calls made (what actions were taken)
+      2. Counting assistant / human exchanges
+      3. Noting the volume of tool responses processed
+    """
+    tool_names: List[str] = []
+    assistant_turns = 0
+    human_turns = 0
+    tool_result_turns = 0
+
+    for turn in middle_turns:
+        role = turn.get("from", "")
+        content = str(turn.get("value", ""))
+
+        if role == "gpt":
+            assistant_turns += 1
+            # Extract tool names from structured tool calls in content
+            # Handles both JSON-encoded calls and plain text references
+            for match in re.finditer(r'"name"\s*:\s*"([^"]+)"', content):
+                tool_names.append(match.group(1))
+            # Also capture <tool_call> XML-style patterns
+            for match in re.finditer(r'<tool_call[^>]*>.*?"name"\s*:\s*"([^"]+)"', content, re.DOTALL):
+                tool_names.append(match.group(1))
+        elif role == "human":
+            human_turns += 1
+        elif _is_tool_turn(turn):
+            tool_result_turns += 1
+
+    unique_tools = list(dict.fromkeys(tool_names))  # deduplicate, preserve order
+
+    parts = [
+        f"[AutoLoRA compression — session {session_id}]",
+        f"The following {len(middle_turns)} middle turns have been compressed to save tokens.",
+        f"This region contained {assistant_turns} assistant turn(s) and {human_turns} human turn(s).",
+    ]
+
+    if tool_result_turns:
+        parts.append(f"{tool_result_turns} tool response(s) were processed.")
+
+    if unique_tools:
+        tools_str = ", ".join(unique_tools[:20])
+        if len(unique_tools) > 20:
+            tools_str += f", … ({len(unique_tools)} total)"
+        parts.append(f"Tools invoked: {tools_str}.")
+
+    parts.append(
+        "Some of your previous tool responses may be summarized to preserve context."
+    )
+
+    return " ".join(parts)
+
+
+def _identify_protected_indices(
+    turns: List[Dict[str, Any]],
+    cfg: LiveCompressConfig,
+) -> Tuple[set, set]:
+    """Return (head_protected, tail_protected) index sets.
+
+    head_protected — indices of turns that must be kept at the start
+    tail_protected — indices of the last N turns
+    """
+    head_protected: set = set()
+    seen_system = seen_human = seen_assistant = seen_tool = False
+
+    for i, turn in enumerate(turns):
+        role = turn.get("from", "")
+
+        if cfg.protect_system and role == "system" and not seen_system:
+            head_protected.add(i)
+            seen_system = True
+        elif cfg.protect_first_human and role == "human" and not seen_human:
+            head_protected.add(i)
+            seen_human = True
+        elif cfg.protect_first_assistant and role == "gpt" and not seen_assistant:
+            head_protected.add(i)
+            seen_assistant = True
+        elif cfg.protect_first_tool and _is_tool_turn(turn) and not seen_tool:
+            head_protected.add(i)
+            seen_tool = True
+
+    n = len(turns)
+    tail_protected: set = set(range(max(0, n - cfg.protect_last_n_turns), n))
+
+    return head_protected, tail_protected
+
+
+def compress_trajectory(
+    trajectory: List[Dict[str, Any]],
+    session_id: str,
+    cfg: LiveCompressConfig,
+) -> Tuple[List[Dict[str, Any]], bool]:
+    """Compress a ShareGPT-format trajectory list.
+
+    Returns (compressed_turns, was_compressed).
+    """
+    total_tokens = _trajectory_tokens(trajectory)
+    if cfg.skip_under_target and total_tokens <= cfg.target_max_tokens:
+        logger.debug(
+            "Session %s: trajectory already within budget (%d tokens ≤ %d), skipping compression.",
+            session_id, total_tokens, cfg.target_max_tokens,
+        )
+        return trajectory, False
+
+    head_protected, tail_protected = _identify_protected_indices(trajectory, cfg)
+    all_protected = head_protected | tail_protected
+
+    # Identify the compressible middle region
+    middle_indices = [i for i in range(len(trajectory)) if i not in all_protected]
+
+    if not middle_indices:
+        logger.debug("Session %s: no compressible middle turns found.", session_id)
+        return trajectory, False
+
+    # Build summary turn
+    middle_turns = [trajectory[i] for i in middle_indices]
+    summary_text = _extract_summary(middle_turns, session_id)
+    summary_turn = {"from": "human", "value": summary_text}
+
+    # Build compressed trajectory
+    compressed: List[Dict[str, Any]] = []
+    summary_inserted = False
+
+    for i, turn in enumerate(trajectory):
+        if i in middle_indices:
+            if not summary_inserted:
+                compressed.append(summary_turn)
+                summary_inserted = True
+            # skip the original middle turn
+        else:
+            compressed.append(turn)
+
+    compressed_tokens = _trajectory_tokens(compressed)
+    logger.info(
+        "Session %s: compressed %d → %d turns, ~%d → ~%d tokens.",
+        session_id,
+        len(trajectory), len(compressed),
+        total_tokens, compressed_tokens,
+    )
+
+    return compressed, True
+
+
+def run_post_session_hook(
+    trajectory: List[Dict[str, Any]],
+    session_id: str,
+    model: str,
+    completed: bool,
+    cfg: LiveCompressConfig,
+) -> Optional[Path]:
+    """Compress trajectory and write to ~/autolora/data/live/.
+
+    Returns the output Path on success, None on skip/failure.
+    This function never raises — errors are logged as warnings.
+    """
+    if not cfg.enabled:
+        return None
+
+    try:
+        compressed, was_compressed = compress_trajectory(trajectory, session_id, cfg)
+
+        output_dir = Path(cfg.output_dir).expanduser()
+        output_dir.mkdir(parents=True, exist_ok=True)
+
+        output_path = output_dir / f"{session_id}_compressed.jsonl"
+
+        entry = {
+            "conversations": compressed,
+            "timestamp": datetime.now().isoformat(),
+            "model": model,
+            "completed": completed,
+            "session_id": session_id,
+            "autolora": {
+                "source": "live_hook",
+                "was_compressed": was_compressed,
+                "original_turns": len(trajectory),
+                "compressed_turns": len(compressed),
+            },
+        }
+
+        with open(output_path, "w", encoding="utf-8") as f:
+            f.write(json.dumps(entry, ensure_ascii=False) + "\n")
+
+        logger.info("AutoLoRA live: saved compressed trajectory → %s", output_path)
+        return output_path
+
+    except Exception as exc:
+        logger.warning("AutoLoRA live compression failed (non-fatal): %s", exc)
+        return None
diff --git a/cli-config.yaml.example b/cli-config.yaml.example
index 104398c2..07dee9f6 100644
--- a/cli-config.yaml.example
+++ b/cli-config.yaml.example
@@ -734,3 +734,30 @@ display:
   #   tool_prefix: "╎"                       # Tool output line prefix (default: ┊)
   #
   skin: default
+
+
+# =============================================================================
+# AutoLoRA Live Compression
+# =============================================================================
+# Post-session hook: after each session, compress the trajectory and save it
+# to output_dir for the training pipeline.  No external API calls are made —
+# middle turns are replaced with a structured summary derived from the
+# conversation content.
+#
+# Protected turns (always kept verbatim):
+#   - System prompt
+#   - First human turn
+#   - First assistant turn
+#   - First tool call/response
+#   - Last N turns (protect_last_n_turns)
+#
+# Middle turns are replaced with a single summary message.
+#
+# Output: {output_dir}/{session_id}_compressed.jsonl
+#
+# autolora:
+#   enabled: false
+#   output_dir: ~/autolora/data/live
+#   target_max_tokens: 15250
+#   protect_last_n_turns: 4
+#   skip_under_target: true   # skip compression when already within budget
diff --git a/cli.py b/cli.py
index 253cdd08..93771174 100755
--- a/cli.py
+++ b/cli.py
@@ -221,6 +221,13 @@ def load_cli_config() -> Dict[str, Any]:
             "model": "",       # Subagent model override (empty = inherit parent model)
             "provider": "",    # Subagent provider override (empty = inherit parent provider)
         },
+        "autolora": {
+            "enabled": False,              # Enable post-session trajectory compression
+            "output_dir": "~/autolora/data/live",
+            "target_max_tokens": 15250,
+            "protect_last_n_turns": 4,
+            "skip_under_target": True,
+        },
     }

     # Track whether the config file explicitly set terminal config.
@@ -1255,6 +1262,14 @@ class HermesCLI:
         fb = CLI_CONFIG.get("fallback_model") or {}
         self._fallback_model = fb if fb.get("provider") and fb.get("model") else None

+        # AutoLoRA live compression config — post-session hook (optional, disabled by default)
+        _autolora_cfg = CLI_CONFIG.get("autolora", {})
+        if _autolora_cfg.get("enabled", False):
+            from agent.live_compressor import LiveCompressConfig
+            self._autolora_live_cfg = LiveCompressConfig.from_config_dict(_autolora_cfg)
+        else:
+            self._autolora_live_cfg = None
+
         # Agent will be initialized on first use
         self.agent: Optional[AIAgent] = None
         self._app = None  # prompt_toolkit Application (set in run())
@@ -1548,6 +1563,7 @@ class HermesCLI:
                 checkpoints_enabled=self.checkpoints_enabled,
                 checkpoint_max_snapshots=self.checkpoint_max_snapshots,
                 pass_session_id=self.pass_session_id,
+                autolora_live_compress=self._autolora_live_cfg,
             )
             # Apply any pending title now that the session exists in the DB
             if self._pending_title and self._session_db:
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index 02edad1f..07adbe55 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -264,6 +264,18 @@ DEFAULT_CONFIG = {
         "tirith_fail_open": True,
     },

+    # AutoLoRA live compression — post-session trajectory hook.
+    # When enabled, compresses each session's trajectory and writes it to
+    # output_dir for inclusion in the training pipeline.  Disabled by default
+    # to keep sessions fast; enable per-machine in ~/.hermes/config.yaml.
+    "autolora": {
+        "enabled": False,
+        "output_dir": "~/autolora/data/live",
+        "target_max_tokens": 15250,
+        "protect_last_n_turns": 4,
+        "skip_under_target": True,
+    },
+
     # Config schema version - bump this when adding new required fields
     "_config_version": 7,
 }
diff --git a/run_agent.py b/run_agent.py
index ba214b71..905dcaca 100644
--- a/run_agent.py
+++ b/run_agent.py
@@ -288,6 +288,7 @@ class AIAgent:
         checkpoints_enabled: bool = False,
         checkpoint_max_snapshots: int = 50,
         pass_session_id: bool = False,
+        autolora_live_compress: "LiveCompressConfig | None" = None,
     ):
         """
         Initialize the AI Agent.
@@ -347,6 +348,7 @@ class AIAgent:
         self.platform = platform  # "cli", "telegram", "discord", "whatsapp", etc.
         self.skip_context_files = skip_context_files
         self.pass_session_id = pass_session_id
+        self._autolora_live_compress = autolora_live_compress
         self.log_prefix_chars = log_prefix_chars
         self.log_prefix = f"{log_prefix} " if log_prefix else ""
         # Store effective base URL for feature detection (prompt caching, reasoning, etc.)
@@ -1261,7 +1263,35 @@ class AIAgent:

         trajectory = self._convert_to_trajectory_format(messages, user_query, completed)
         _save_trajectory_to_file(trajectory, self.model, completed)
-
+
+    def _run_autolora_live_hook(
+        self,
+        messages: List[Dict[str, Any]],
+        user_query: str,
+        completed: bool,
+    ):
+        """Post-session AutoLoRA hook: compress trajectory and write to ~/autolora/data/live/.
+
+        Fires after every session when autolora_live_compress is enabled.
+        Never raises — errors are logged as warnings and the session continues normally.
+        """
+        cfg = self._autolora_live_compress
+        if cfg is None or not cfg.enabled:
+            return
+
+        try:
+            from agent.live_compressor import run_post_session_hook
+            trajectory = self._convert_to_trajectory_format(messages, user_query, completed)
+            run_post_session_hook(
+                trajectory=trajectory,
+                session_id=self.session_id,
+                model=self.model,
+                completed=completed,
+                cfg=cfg,
+            )
+        except Exception as exc:
+            logger.warning("AutoLoRA live hook failed (non-fatal): %s", exc)
+
     def _mask_api_key_for_logs(self, key: Optional[str]) -> Optional[str]:
         if not key:
             return None
@@ -5340,6 +5370,9 @@ class AIAgent:
         # Save trajectory if enabled
         self._save_trajectory(messages, user_message, completed)

+        # AutoLoRA live compression hook — runs post-session, no external API calls
+        self._run_autolora_live_hook(messages, user_message, completed)
+
         # Clean up VM and browser for this task after conversation completes
         self._cleanup_task_resources(effective_task_id)

diff --git a/tests/test_live_compressor.py b/tests/test_live_compressor.py
new file mode 100644
index 00000000..4578f7cf
--- /dev/null
+++ b/tests/test_live_compressor.py
@@ -0,0 +1,333 @@
+"""Tests for agent/live_compressor.py — AutoLoRA post-session hook."""
+
+import json
+from pathlib import Path
+
+import pytest
+
+from agent.live_compressor import (
+    LiveCompressConfig,
+    _estimate_tokens,
+    _turn_tokens,
+    _trajectory_tokens,
+    _is_tool_turn,
+    _extract_summary,
+    _identify_protected_indices,
+    compress_trajectory,
+    run_post_session_hook,
+)
+
+
+# ---------------------------------------------------------------------------
+# Helpers / fixtures
+# ---------------------------------------------------------------------------
+
+def _make_turn(role: str, value: str) -> dict:
+    return {"from": role, "value": value}
+
+
+def _make_simple_trajectory(num_middle: int = 6) -> list:
+    """Build a minimal trajectory: system + first_human + first_gpt + N middle + last4."""
+    turns = [
+        _make_turn("system", "You are a helpful assistant."),
+        _make_turn("human", "Hello, can you help me?"),
+        _make_turn("gpt", "Of course! What do you need?"),
+    ]
+    for i in range(num_middle):
+        turns.append(_make_turn("human", f"Middle human turn {i}."))
+        turns.append(_make_turn("gpt", f"Middle gpt turn {i}. " + "x" * 50))
+    turns += [
+        _make_turn("human", "Last user question."),
+        _make_turn("gpt", "Last answer."),
+        _make_turn("human", "Follow-up."),
+        _make_turn("gpt", "Done."),
+    ]
+    return turns
+
+
+# ---------------------------------------------------------------------------
+# LiveCompressConfig
+# ---------------------------------------------------------------------------
+
+class TestLiveCompressConfig:
+    def test_defaults(self):
+        cfg = LiveCompressConfig()
+        assert cfg.enabled is False
+        assert cfg.target_max_tokens == 15_250
+        assert cfg.protect_last_n_turns == 4
+        assert cfg.skip_under_target is True
+        assert "autolora/data/live" in cfg.output_dir
+
+    def test_from_config_dict(self):
+        d = {
+            "enabled": True,
+            "output_dir": "/tmp/autolora",
+            "target_max_tokens": 8000,
+            "protect_last_n_turns": 3,
+            "skip_under_target": False,
+        }
+        cfg = LiveCompressConfig.from_config_dict(d)
+        assert cfg.enabled is True
+        assert cfg.output_dir == "/tmp/autolora"
+        assert cfg.target_max_tokens == 8000
+        assert cfg.protect_last_n_turns == 3
+        assert cfg.skip_under_target is False
+
+    def test_from_config_dict_partial(self):
+        cfg = LiveCompressConfig.from_config_dict({"enabled": True})
+        assert cfg.enabled is True
+        assert cfg.target_max_tokens == 15_250  # default preserved
+
+
+# ---------------------------------------------------------------------------
+# Token estimation
+# ---------------------------------------------------------------------------
+
+class TestTokenEstimation:
+    def test_estimate_tokens_basic(self):
+        # 4 chars → 1 token
+        assert _estimate_tokens("abcd") == 1
+        assert _estimate_tokens("abcdefgh") == 2
+
+    def test_estimate_tokens_minimum(self):
+        assert _estimate_tokens("") == 1  # minimum 1
+
+    def test_turn_tokens(self):
+        turn = _make_turn("gpt", "a" * 400)
+        assert _turn_tokens(turn) == 100
+
+    def test_trajectory_tokens(self):
+        turns = [_make_turn("human", "a" * 40), _make_turn("gpt", "b" * 80)]
+        assert _trajectory_tokens(turns) == 30  # 10 + 20
+
+
+# ---------------------------------------------------------------------------
+# Tool turn detection
+# ---------------------------------------------------------------------------
+
+class TestIsToolTurn:
+    def test_tool_roles(self):
+        for role in ("tool_call", "tool", "function_call", "function"):
+            assert _is_tool_turn(_make_turn(role, "data")) is True
+
+    def test_non_tool_roles(self):
+        for role in ("human", "gpt", "system"):
+            assert _is_tool_turn(_make_turn(role, "data")) is False
+
+
+# ---------------------------------------------------------------------------
+# Summary extraction
+# ---------------------------------------------------------------------------
+
+class TestExtractSummary:
+    def test_basic_summary(self):
+        turns = [
+            _make_turn("gpt", "I will call a tool."),
+            _make_turn("human", "Please do."),
+            _make_turn("tool", "tool result"),
+        ]
+        summary = _extract_summary(turns, "sess123")
+        assert "sess123" in summary
+        assert "3 middle turns" in summary
+        assert "1 tool response" in summary
+
+    def test_tool_names_extracted(self):
+        turns = [
+            _make_turn("gpt", '{"name": "read_file", "args": {}}'),
+            _make_turn("gpt", '{"name": "write_file", "args": {}}'),
+        ]
+        summary = _extract_summary(turns, "s1")
+        assert "read_file" in summary
+        assert "write_file" in summary
+
+    def test_empty_turns(self):
+        summary = _extract_summary([], "s2")
+        assert "0 middle turns" in summary
+
+    def test_summary_notice_appended(self):
+        turns = [_make_turn("gpt", "hi")]
+        summary = _extract_summary(turns, "s3")
+        assert "summarized" in summary.lower()
+
+
+# ---------------------------------------------------------------------------
+# Protected indices
+# ---------------------------------------------------------------------------
+
+class TestIdentifyProtectedIndices:
+    def test_head_protected(self):
+        trajectory = [
+            _make_turn("system", "sys"),
+            _make_turn("human", "hi"),
+            _make_turn("gpt", "hello"),
+            _make_turn("tool", "result"),
+            _make_turn("human", "next"),
+        ]
+        cfg = LiveCompressConfig()
+        head, tail = _identify_protected_indices(trajectory, cfg)
+        assert 0 in head  # system
+        assert 1 in head  # first human
+        assert 2 in head  # first gpt
+        assert 3 in head  # first tool
+
+    def test_tail_protected(self):
+        trajectory = [_make_turn("human", f"msg {i}") for i in range(10)]
+        cfg = LiveCompressConfig()
+        _, tail = _identify_protected_indices(trajectory, cfg)
+        assert tail == {6, 7, 8, 9}
+
+    def test_custom_last_n(self):
+        trajectory = [_make_turn("human", f"msg {i}") for i in range(10)]
+        cfg = LiveCompressConfig(protect_last_n_turns=2)
+        _, tail = _identify_protected_indices(trajectory, cfg)
+        assert tail == {8, 9}
+
+
+# ---------------------------------------------------------------------------
+# compress_trajectory
+# ---------------------------------------------------------------------------
+
+class TestCompressTrajectory:
+    def test_skips_when_under_budget(self):
+        cfg = LiveCompressConfig(target_max_tokens=99999, skip_under_target=True)
+        trajectory = _make_simple_trajectory(2)
+        compressed, was_compressed = compress_trajectory(trajectory, "s1", cfg)
+        assert was_compressed is False
+        assert compressed == trajectory
+
+    def test_compresses_middle_turns(self):
+        # Build trajectory large enough to exceed a tiny budget
+        trajectory = _make_simple_trajectory(num_middle=10)
+        cfg = LiveCompressConfig(
+            target_max_tokens=10,  # tiny budget to force compression
+            protect_last_n_turns=4,
+            skip_under_target=True,
+        )
+        compressed, was_compressed = compress_trajectory(trajectory, "sess_test", cfg)
+        assert was_compressed is True
+        # Compressed should be shorter
+        assert len(compressed) < len(trajectory)
+
+    def test_protected_turns_preserved(self):
+        trajectory = [
+            _make_turn("system", "sys prompt"),
+            _make_turn("human", "first user msg"),
+            _make_turn("gpt", "first reply"),
+            _make_turn("human", "middle 1"),
+            _make_turn("gpt", "middle reply"),
+            _make_turn("human", "last-4 user"),
+            _make_turn("gpt", "last-4 reply"),
+            _make_turn("human", "last-3 user"),
+            _make_turn("gpt", "last-3 reply"),
+        ]
+        cfg = LiveCompressConfig(
+            target_max_tokens=5,  # force compression
+            protect_last_n_turns=4,
+        )
+        compressed, was_compressed = compress_trajectory(trajectory, "s1", cfg)
+        assert was_compressed is True
+
+        roles = [t["from"] for t in compressed]
+        # system, first human, first gpt always at head
+        assert roles[0] == "system"
+        assert roles[1] == "human"
+        assert roles[2] == "gpt"
+        # last 4 preserved at tail
+        assert roles[-1] == "gpt"
+
+    def test_summary_turn_is_human_role(self):
+        trajectory = _make_simple_trajectory(num_middle=10)
+        cfg = LiveCompressConfig(target_max_tokens=10, protect_last_n_turns=4)
+        compressed, _ = compress_trajectory(trajectory, "s1", cfg)
+        # The summary turn replaces middle turns and should be "human" role
+        summary_turns = [t for t in compressed if "AutoLoRA compression" in t.get("value", "")]
+        assert len(summary_turns) == 1
+        assert summary_turns[0]["from"] == "human"
+
+    def test_no_compressible_middle(self):
+        """If everything is protected, return unchanged."""
+        trajectory = [
+            _make_turn("system", "s"),
+            _make_turn("human", "h"),
+            _make_turn("gpt", "g"),
+            _make_turn("human", "l1"),
+        ]
+        cfg = LiveCompressConfig(target_max_tokens=1, protect_last_n_turns=4)
+        compressed, was_compressed = compress_trajectory(trajectory, "s1", cfg)
+        assert was_compressed is False
+
+
+# ---------------------------------------------------------------------------
+# run_post_session_hook
+# ---------------------------------------------------------------------------
+
+class TestRunPostSessionHook:
+    def test_disabled_returns_none(self, tmp_path):
+        cfg = LiveCompressConfig(enabled=False, output_dir=str(tmp_path))
+        result = run_post_session_hook(
+            trajectory=[_make_turn("human", "hi")],
+            session_id="sid1",
+            model="test-model",
+            completed=True,
+            cfg=cfg,
+        )
+        assert result is None
+        # No JSONL file should have been written
+        assert not any(f.suffix == ".jsonl" for f in tmp_path.rglob("*"))
+
+    def test_writes_jsonl_file(self, tmp_path):
+        trajectory = _make_simple_trajectory(num_middle=8)
+        cfg = LiveCompressConfig(
+            enabled=True,
+            output_dir=str(tmp_path),
+            target_max_tokens=10,
+        )
+        result = run_post_session_hook(
+            trajectory=trajectory,
+            session_id="session_abc",
+            model="my-model",
+            completed=True,
+            cfg=cfg,
+        )
+        assert result is not None
+        assert result.exists()
+        assert result.name == "session_abc_compressed.jsonl"
+
+        with open(result) as f:
+            entry = json.loads(f.read())
+
+        assert entry["session_id"] == "session_abc"
+        assert entry["model"] == "my-model"
+        assert entry["completed"] is True
+        assert "conversations" in entry
+        assert entry["autolora"]["source"] == "live_hook"
+
+    def test_creates_output_dir(self, tmp_path):
+        output_dir = tmp_path / "deep" / "nested" / "dir"
+        cfg = LiveCompressConfig(
+            enabled=True,
+            output_dir=str(output_dir),
+            target_max_tokens=10,
+        )
+        trajectory = _make_simple_trajectory(num_middle=5)
+        result = run_post_session_hook(
+            trajectory=trajectory,
+            session_id="sid2",
+            model="m",
+            completed=False,
+            cfg=cfg,
+        )
+        assert result is not None
+        assert output_dir.exists()
+
+    def test_never_raises_on_bad_path(self):
+        """Hook must be non-fatal."""
+        cfg = LiveCompressConfig(enabled=True, output_dir="/proc/invalid_______path")
+        result = run_post_session_hook(
+            trajectory=[_make_turn("human", "hi")],
+            session_id="s",
+            model="m",
+            completed=True,
+            cfg=cfg,
+        )
+        assert result is None  # error swallowed, None returned