Prompt:
## Summary
Export all 3,367+ CLI sessions from state.db to ShareGPT JSONL format, then split into training and held-out test sets.

## Requirements
- Run `hermes sessions export ~/autolora/data/raw.jsonl --source cli`
- Split chronologically: last ~5% (167 sessions) → `test_set.jsonl`, rest → `train_set.jsonl`
- Write a split script (`autolora/scripts/split_data.py`) that:
  - Takes raw export + split ratio as args
  - Splits by chronological order (most recent = test)
  - Records session IDs in a manifest file for reproducibility
  - Outputs: `train_set.jsonl`, `test_set.jsonl`, `manifest.json`
- Manifest tracks: session count, message count, sha256 hash of each file, split date
- Commit configs and scripts to `autolora/` in the repo

## Acceptance Criteria
- [ ] Raw export exists at `~/autolora/data/raw.jsonl`
- [ ] Split script is repeatable and deterministic
- [ ] Test set is NEVER used for training (enforced by manifest)
- [ ] Manifest committed to `autolora/evals/manifest.json`

## Directory Structure
```
~/autolora/
  data/
    raw.jsonl
    train_set.jsonl
    test_set.jsonl
    manifest.json
```

## Notes
- Test set stays frozen after initial split. New sessions accumulate into training only.
- Future re-exports append to training, test set only changes between major versions.

Chosen response:
diff --git a/autolora/evals/.gitkeep b/autolora/evals/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/autolora/scripts/split_data.py b/autolora/scripts/split_data.py
new file mode 100644
index 00000000..a0a45fce
--- /dev/null
+++ b/autolora/scripts/split_data.py
@@ -0,0 +1,194 @@
+#!/usr/bin/env python3
+"""
+AutoLoRA — train/test split utility.
+
+Reads a raw JSONL export (from `hermes sessions export --format sharegpt`)
+and splits it chronologically into training and held-out test sets.
+
+Usage:
+    python split_data.py <input.jsonl> <output_dir> [--test-ratio 0.05]
+
+Outputs (all in <output_dir>):
+    train_set.jsonl  — training sessions (oldest sessions)
+    test_set.jsonl   — held-out test sessions (newest sessions)
+    manifest.json    — reproducibility manifest with session IDs + file hashes
+
+Rules:
+  - Split is always chronological: newest sessions go to the test set.
+  - The test set is frozen after initial split; new sessions add to training only.
+  - Re-running with the same input produces identical output (deterministic).
+"""
+
+import argparse
+import hashlib
+import json
+import pathlib
+import sys
+from datetime import datetime, timezone
+
+
+def _sha256(file_path: pathlib.Path) -> str:
+    """Return the SHA-256 hex digest of a file."""
+    h = hashlib.sha256()
+    with open(file_path, "rb") as f:
+        for chunk in iter(lambda: f.read(65536), b""):
+            h.update(chunk)
+    return h.hexdigest()
+
+
+def _count_messages(sessions: list) -> int:
+    """Count total conversation turns across all sessions."""
+    total = 0
+    for s in sessions:
+        total += len(s.get("conversations", []))
+    return total
+
+
+def split(
+    input_path: pathlib.Path,
+    output_dir: pathlib.Path,
+    test_ratio: float = 0.05,
+) -> dict:
+    """
+    Split sessions into train/test sets.
+
+    Parameters
+    ----------
+    input_path  : path to the source JSONL (ShareGPT or raw format)
+    output_dir  : directory where output files are written
+    test_ratio  : fraction of sessions to reserve as test set (default 0.05)
+
+    Returns
+    -------
+    dict — the manifest written to manifest.json
+    """
+    if not 0 < test_ratio < 1:
+        raise ValueError(f"test_ratio must be between 0 and 1, got {test_ratio}")
+
+    # ── Load ──────────────────────────────────────────────────────────────────
+    sessions = []
+    with open(input_path, encoding="utf-8") as f:
+        for lineno, line in enumerate(f, 1):
+            line = line.strip()
+            if not line:
+                continue
+            try:
+                sessions.append(json.loads(line))
+            except json.JSONDecodeError as exc:
+                print(f"Warning: skipping malformed JSON on line {lineno}: {exc}", file=sys.stderr)
+
+    if not sessions:
+        raise ValueError(f"No sessions found in {input_path}")
+
+    # ── Sort chronologically (oldest first) ───────────────────────────────────
+    def _sort_key(s):
+        ts = s.get("started_at") or s.get("timestamp") or 0
+        return float(ts) if ts else 0.0
+
+    sessions.sort(key=_sort_key)
+
+    # ── Split ─────────────────────────────────────────────────────────────────
+    total = len(sessions)
+    n_test = max(1, round(total * test_ratio))
+    n_train = total - n_test
+
+    train_sessions = sessions[:n_train]
+    test_sessions = sessions[n_train:]
+
+    # ── Write output files ────────────────────────────────────────────────────
+    output_dir.mkdir(parents=True, exist_ok=True)
+
+    train_path = output_dir / "train_set.jsonl"
+    test_path = output_dir / "test_set.jsonl"
+    manifest_path = output_dir / "manifest.json"
+
+    def _write_jsonl(path: pathlib.Path, records: list) -> None:
+        with open(path, "w", encoding="utf-8") as f:
+            for record in records:
+                f.write(json.dumps(record, ensure_ascii=False) + "\n")
+
+    _write_jsonl(train_path, train_sessions)
+    _write_jsonl(test_path, test_sessions)
+
+    # ── Build manifest ────────────────────────────────────────────────────────
+    manifest = {
+        "split_date": datetime.now(timezone.utc).isoformat(),
+        "source_file": str(input_path.resolve()),
+        "test_ratio": test_ratio,
+        "total_sessions": total,
+        "train": {
+            "session_count": n_train,
+            "message_count": _count_messages(train_sessions),
+            "sha256": _sha256(train_path),
+            "path": str(train_path.resolve()),
+        },
+        "test": {
+            "session_count": n_test,
+            "message_count": _count_messages(test_sessions),
+            "sha256": _sha256(test_path),
+            "path": str(test_path.resolve()),
+            # Freeze test session IDs so they can never be added to training
+            "session_ids": [s.get("id", "") for s in test_sessions],
+        },
+        "note": (
+            "Test set is frozen. New sessions accumulate into training only. "
+            "Do not train on any session ID listed in test.session_ids."
+        ),
+    }
+
+    with open(manifest_path, "w", encoding="utf-8") as f:
+        json.dump(manifest, f, indent=2, ensure_ascii=False)
+        f.write("\n")
+
+    return manifest
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="Split a Hermes ShareGPT JSONL export into train/test sets.",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+Examples:
+  # Standard split with default 5% test ratio
+  python split_data.py ~/autolora/data/raw.jsonl ~/autolora/data
+
+  # Custom test ratio
+  python split_data.py ~/autolora/data/raw.jsonl ~/autolora/data --test-ratio 0.10
+""",
+    )
+    parser.add_argument("input", help="Input JSONL file (ShareGPT or raw format)")
+    parser.add_argument("output_dir", help="Directory to write train_set.jsonl, test_set.jsonl, manifest.json")
+    parser.add_argument(
+        "--test-ratio",
+        type=float,
+        default=0.05,
+        metavar="RATIO",
+        help="Fraction of sessions (most recent) to reserve as test set (default: 0.05 = 5%%)",
+    )
+    args = parser.parse_args()
+
+    input_path = pathlib.Path(args.input).expanduser().resolve()
+    output_dir = pathlib.Path(args.output_dir).expanduser().resolve()
+
+    if not input_path.exists():
+        print(f"Error: input file not found: {input_path}", file=sys.stderr)
+        sys.exit(1)
+
+    print(f"Reading sessions from {input_path} ...")
+    manifest = split(input_path, output_dir, test_ratio=args.test_ratio)
+
+    print(f"\nSplit complete:")
+    print(f"  Total sessions : {manifest['total_sessions']}")
+    print(f"  Train          : {manifest['train']['session_count']} sessions  "
+          f"({manifest['train']['message_count']} turns)")
+    print(f"  Test           : {manifest['test']['session_count']} sessions  "
+          f"({manifest['test']['message_count']} turns)")
+    print(f"\nOutput files:")
+    print(f"  {output_dir / 'train_set.jsonl'}")
+    print(f"  {output_dir / 'test_set.jsonl'}")
+    print(f"  {output_dir / 'manifest.json'}")
+    print(f"\nManifest written. Test set IDs are frozen — do not train on them.")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/hermes_cli/main.py b/hermes_cli/main.py
index 98c204e6..516d21e1 100644
--- a/hermes_cli/main.py
+++ b/hermes_cli/main.py
@@ -2788,6 +2788,12 @@ For more help on a command:
     sessions_export.add_argument("output", help="Output JSONL file path")
     sessions_export.add_argument("--source", help="Filter by source")
     sessions_export.add_argument("--session-id", help="Export a specific session")
+    sessions_export.add_argument(
+        "--format",
+        choices=["raw", "sharegpt"],
+        default="raw",
+        help="Output format: raw (full session data) or sharegpt (training format, default: raw)",
+    )

     sessions_delete = sessions_subparsers.add_parser("delete", help="Delete a specific session")
     sessions_delete.add_argument("session_id", help="Session ID to delete")
@@ -2846,20 +2852,73 @@ For more help on a command:
                     print(f"{preview:<50} {last_active:<13} {s['source']:<6} {sid}")

         elif action == "export":
+            export_format = getattr(args, "format", "raw") or "raw"
+
+            def _to_sharegpt(session_data: dict) -> Optional[dict]:
+                """Convert a raw session dict to ShareGPT format.
+
+                Returns None if the session has no usable human/gpt turns.
+                Tool calls and tool results are omitted; only user/assistant
+                text turns are included.
+                """
+                convs = []
+                for msg in session_data.get("messages", []):
+                    role = msg.get("role", "")
+                    content = msg.get("content") or ""
+                    # Skip empty content, tool calls, and tool results
+                    if not content.strip():
+                        continue
+                    if role == "user":
+                        convs.append({"from": "human", "value": content})
+                    elif role == "assistant":
+                        convs.append({"from": "gpt", "value": content})
+                # Require at least one exchange
+                roles_present = {c["from"] for c in convs}
+                if "human" not in roles_present or "gpt" not in roles_present:
+                    return None
+                return {
+                    "id": session_data.get("id", ""),
+                    "source": session_data.get("source", ""),
+                    "started_at": session_data.get("started_at"),
+                    "conversations": convs,
+                }
+
+            # Ensure output directory exists
+            import pathlib as _pathlib
+            _pathlib.Path(args.output).parent.mkdir(parents=True, exist_ok=True)
+
             if args.session_id:
                 data = db.export_session(args.session_id)
                 if not data:
                     print(f"Session '{args.session_id}' not found.")
                     return
+                if export_format == "sharegpt":
+                    data = _to_sharegpt(data)
+                    if not data:
+                        print("Session has no usable conversation turns for ShareGPT export.")
+                        return
                 with open(args.output, "w", encoding="utf-8") as f:
                     f.write(_json.dumps(data, ensure_ascii=False) + "\n")
                 print(f"Exported 1 session to {args.output}")
             else:
                 sessions = db.export_all(source=args.source)
+                written = 0
+                skipped = 0
                 with open(args.output, "w", encoding="utf-8") as f:
                     for s in sessions:
-                        f.write(_json.dumps(s, ensure_ascii=False) + "\n")
-                print(f"Exported {len(sessions)} sessions to {args.output}")
+                        if export_format == "sharegpt":
+                            record = _to_sharegpt(s)
+                            if record is None:
+                                skipped += 1
+                                continue
+                        else:
+                            record = s
+                        f.write(_json.dumps(record, ensure_ascii=False) + "\n")
+                        written += 1
+                if export_format == "sharegpt" and skipped:
+                    print(f"Exported {written} sessions to {args.output} ({skipped} skipped — no usable turns)")
+                else:
+                    print(f"Exported {written} sessions to {args.output}")

         elif action == "delete":
             if not args.yes:

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
Hermes 4.3 36B Q4_K_M GGUF (~20GB) is downloaded on the VPS. Transfer to Mac via Tailscale rsync and import into Ollama.

## Status
- [x] Downloaded to VPS via `huggingface-cli download`
- [ ] Transfer to Mac
- [ ] Import into Ollama

## Steps
1. Find the file on VPS: `find ~/.cache/huggingface -name "*.gguf" 2>/dev/null`
2. rsync over Tailscale: `rsync -avP --partial vps:~/.cache/huggingface/.../hermes-4_3_36b-Q4_K_M.gguf ~/autolora/base/`
3. Create Modelfile (ChatML template, 8192 ctx)
4. `ollama create hermes4.3:base -f Modelfile`
5. Verify: `ollama run hermes4.3:base "Hello, who are you?"`

## Acceptance Criteria
- [ ] GGUF file on Mac at `~/autolora/base/hermes-4_3_36b-Q4_K_M.gguf`
- [ ] `hermes4.3:base` available in `ollama list`
- [ ] Model responds correctly with ChatML format
- [ ] Runs within 36GB unified memory budget

## Notes
- rsync --partial makes this resumable if hotspot drops
- ~20GB over Tailscale should be manageable
- This is the frozen skeleton — never modified, only wrapped with adapters

Chosen response:
diff --git a/autolora/.gitignore b/autolora/.gitignore
new file mode 100644
index 00000000..584f98e6
--- /dev/null
+++ b/autolora/.gitignore
@@ -0,0 +1,9 @@
+# GGUF model files — large binaries, never commit
+base/
+*.gguf
+*.bin
+
+# Training artifacts
+checkpoints/
+adapters/
+runs/
diff --git a/autolora/Modelfile.hermes43 b/autolora/Modelfile.hermes43
new file mode 100644
index 00000000..6364ed17
--- /dev/null
+++ b/autolora/Modelfile.hermes43
@@ -0,0 +1,32 @@
+# Hermes 4.3 36B — Frozen Base Skeleton
+# ChatML format, 8192 context window
+# Used as the base model for AutoLoRA adapter training and inference.
+# DO NOT modify this file — it defines the frozen skeleton.
+# Adapters are wrapped around this model, never baked in.
+
+FROM ~/autolora/base/hermes-4_3_36b-Q4_K_M.gguf
+
+# ChatML prompt template
+TEMPLATE """<|im_start|>system
+{{ .System }}<|im_end|>
+<|im_start|>user
+{{ .Prompt }}<|im_end|>
+<|im_start|>assistant
+"""
+
+# Stop tokens for ChatML
+PARAMETER stop "<|im_start|>"
+PARAMETER stop "<|im_end|>"
+PARAMETER stop "<|endoftext|>"
+
+# Context window — 8192 tokens
+PARAMETER num_ctx 8192
+
+# Inference defaults (conservative for 36B on 36GB unified memory)
+PARAMETER temperature 0.7
+PARAMETER top_p 0.9
+PARAMETER top_k 40
+PARAMETER repeat_penalty 1.1
+
+# System prompt — identity grounding
+SYSTEM """You are Hermes, a helpful, harmless, and honest AI assistant built on the Hermes 4.3 architecture. You engage thoughtfully with users and provide accurate, nuanced responses."""
diff --git a/autolora/README.md b/autolora/README.md
new file mode 100644
index 00000000..5670c6af
--- /dev/null
+++ b/autolora/README.md
@@ -0,0 +1,76 @@
+# AutoLoRA — Local Sovereign Training
+
+Scripts for managing the Hermes 4.3 36B base model and LoRA adapter pipeline on Apple Silicon.
+
+## Directory Structure
+
+```
+autolora/
+├── base/                          # GGUF model files (created at runtime, gitignored)
+│   └── hermes-4_3_36b-Q4_K_M.gguf
+├── transfer-hermes-gguf.sh        # Step 1: VPS → Mac transfer via Tailscale rsync
+├── Modelfile.hermes43             # Ollama model definition (ChatML, 8192 ctx)
+├── import-to-ollama.sh            # Step 2: Import GGUF into Ollama
+└── README.md
+```
+
+## Setup
+
+### Step 1: Transfer GGUF from VPS
+
+```bash
+# Default: uses 'vps' SSH host from ~/.ssh/config
+./autolora/transfer-hermes-gguf.sh
+
+# Or specify VPS hostname/IP
+./autolora/transfer-hermes-gguf.sh my-vps-hostname
+```
+
+Requires:
+- Tailscale up on both machines
+- VPS configured as `vps` in `~/.ssh/config` (or pass hostname as argument)
+- `rsync` installed (`brew install rsync`)
+- ~22GB free disk space at `~/autolora/base/`
+
+The transfer is resumable — safe to re-run if connection drops.
+
+### Step 2: Import into Ollama
+
+```bash
+./autolora/import-to-ollama.sh
+```
+
+Requires:
+- [Ollama](https://ollama.com) installed
+- GGUF at `~/autolora/base/hermes-4_3_36b-Q4_K_M.gguf` (from Step 1)
+
+### Verify
+
+```bash
+ollama list
+# hermes4.3:base   ...
+
+ollama run hermes4.3:base "Hello, who are you?"
+```
+
+## Model Details
+
+| Property | Value |
+|----------|-------|
+| Model | Hermes 4.3 36B |
+| Quantization | Q4_K_M |
+| Size | ~20GB |
+| Context | 8192 tokens |
+| Format | ChatML |
+| Ollama tag | `hermes4.3:base` |
+
+## Memory Budget
+
+Q4_K_M for a 36B model uses approximately 20–22GB of unified memory on Apple Silicon.
+This fits within a 36GB M3/M4 Max budget with room for OS + context.
+
+## Notes
+
+- The GGUF is the **frozen skeleton** — never modified, only wrapped with LoRA adapters
+- `~/autolora/base/` is gitignored (large binary files)
+- Use `--partial` rsync flag ensures resumable transfers over potentially unstable Tailscale hotspot connections
diff --git a/autolora/import-to-ollama.sh b/autolora/import-to-ollama.sh
new file mode 100755
index 00000000..fe2c0854
--- /dev/null
+++ b/autolora/import-to-ollama.sh
@@ -0,0 +1,129 @@
+#!/bin/bash
+# ============================================================================
+# AutoLoRA: Import Hermes 4.3 36B GGUF into Ollama
+# ============================================================================
+# Creates the hermes4.3:base model in Ollama from the local GGUF file.
+# Run this after transfer-hermes-gguf.sh has completed.
+#
+# Usage:
+#   ./autolora/import-to-ollama.sh
+# ============================================================================
+
+set -euo pipefail
+
+# Colors
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[0;33m'
+CYAN='\033[0;36m'
+NC='\033[0m'
+BOLD='\033[1m'
+
+# Configuration
+GGUF_PATH="$HOME/autolora/base/hermes-4_3_36b-Q4_K_M.gguf"
+MODELFILE="$(dirname "$0")/Modelfile.hermes43"
+MODEL_NAME="hermes4.3:base"
+EXPECTED_MIN_SIZE_GB=18
+
+log()  { echo -e "${CYAN}[ollama]${NC} $*"; }
+ok()   { echo -e "${GREEN}[ok]${NC} $*"; }
+warn() { echo -e "${YELLOW}[warn]${NC} $*"; }
+die()  { echo -e "${RED}[error]${NC} $*" >&2; exit 1; }
+
+# ── Pre-flight checks ────────────────────────────────────────────────────────
+
+log "Pre-flight checks..."
+
+# Verify ollama installed
+command -v ollama >/dev/null 2>&1 || die "ollama not found. Install from https://ollama.com"
+ok "ollama found: $(ollama --version 2>/dev/null || echo 'unknown version')"
+
+# Verify GGUF file exists
+if [[ ! -f "$GGUF_PATH" ]]; then
+    die "GGUF not found at $GGUF_PATH
+Run transfer first: ./autolora/transfer-hermes-gguf.sh"
+fi
+
+GGUF_SIZE_BYTES=$(stat -f%z "$GGUF_PATH" 2>/dev/null || stat -c%s "$GGUF_PATH")
+GGUF_SIZE_GB=$(echo "scale=2; $GGUF_SIZE_BYTES / 1073741824" | bc)
+ok "GGUF found: $GGUF_PATH (${GGUF_SIZE_GB}GB)"
+
+if (( $(echo "$GGUF_SIZE_GB < $EXPECTED_MIN_SIZE_GB" | bc -l) )); then
+    warn "GGUF smaller than expected (${GGUF_SIZE_GB}GB < ${EXPECTED_MIN_SIZE_GB}GB). File may be incomplete."
+    read -rp "Continue anyway? [y/N] " confirm
+    [[ "$confirm" =~ ^[Yy]$ ]] || { log "Aborted."; exit 0; }
+fi
+
+# Verify Modelfile exists
+if [[ ! -f "$MODELFILE" ]]; then
+    die "Modelfile not found at $MODELFILE"
+fi
+ok "Modelfile: $MODELFILE"
+
+# Check if model already exists in Ollama
+if ollama list 2>/dev/null | grep -q "^${MODEL_NAME}"; then
+    warn "Model '$MODEL_NAME' already exists in Ollama."
+    read -rp "Re-create it? This will overwrite the existing model. [y/N] " confirm
+    [[ "$confirm" =~ ^[Yy]$ ]] || { log "Skipping import — model already exists."; exit 0; }
+fi
+
+# ── Verify Ollama is running ─────────────────────────────────────────────────
+
+log "Checking Ollama service..."
+if ! ollama list >/dev/null 2>&1; then
+    log "Starting Ollama service..."
+    ollama serve &>/dev/null &
+    sleep 3
+    if ! ollama list >/dev/null 2>&1; then
+        die "Could not start Ollama service. Run 'ollama serve' manually in another terminal."
+    fi
+fi
+ok "Ollama service is running"
+
+# ── Import ───────────────────────────────────────────────────────────────────
+
+log "Creating model '$MODEL_NAME' from $MODELFILE..."
+log "This may take a few minutes while Ollama indexes the GGUF..."
+echo ""
+
+ollama create "$MODEL_NAME" -f "$MODELFILE"
+
+echo ""
+
+# ── Verify ───────────────────────────────────────────────────────────────────
+
+if ! ollama list 2>/dev/null | grep -q "^${MODEL_NAME/:/\:}"; then
+    # More lenient check
+    if ! ollama list 2>/dev/null | grep -q "hermes4.3"; then
+        die "Model import appeared to succeed but '$MODEL_NAME' not found in 'ollama list'"
+    fi
+fi
+ok "Model '$MODEL_NAME' available in Ollama"
+
+echo ""
+log "Running smoke test..."
+echo ""
+
+RESPONSE=$(ollama run "$MODEL_NAME" "Hello, who are you? Reply in one sentence." 2>&1)
+echo "Response: $RESPONSE"
+echo ""
+
+if [[ -z "$RESPONSE" ]]; then
+    warn "Empty response from model — may need more time to load. Try: ollama run $MODEL_NAME"
+else
+    ok "Smoke test passed"
+fi
+
+echo ""
+echo -e "${BOLD}${GREEN}Import complete!${NC}"
+echo ""
+echo "  Model:    $MODEL_NAME"
+echo "  GGUF:     $GGUF_PATH (${GGUF_SIZE_GB}GB)"
+echo "  Context:  8192 tokens"
+echo "  Format:   ChatML"
+echo ""
+echo "Usage:"
+echo "  ollama run $MODEL_NAME"
+echo "  ollama list"
+echo ""
+echo -e "${BOLD}Note:${NC} This is the frozen skeleton — never modify, only wrap with LoRA adapters."
diff --git a/autolora/transfer-hermes-gguf.sh b/autolora/transfer-hermes-gguf.sh
new file mode 100755
index 00000000..eff3f3df
--- /dev/null
+++ b/autolora/transfer-hermes-gguf.sh
@@ -0,0 +1,122 @@
+#!/bin/bash
+# ============================================================================
+# AutoLoRA: Transfer Hermes 4.3 36B GGUF from VPS to Mac
+# ============================================================================
+# Transfers hermes-4_3_36b-Q4_K_M.gguf from VPS via Tailscale rsync.
+# Resumable via --partial flag — safe to re-run if hotspot drops.
+#
+# Usage:
+#   ./autolora/transfer-hermes-gguf.sh [VPS_TAILSCALE_HOST]
+#
+# Default VPS host: vps (configure in ~/.ssh/config or pass as argument)
+# ============================================================================
+
+set -euo pipefail
+
+# Colors
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[0;33m'
+CYAN='\033[0;36m'
+NC='\033[0m'
+BOLD='\033[1m'
+
+# Configuration
+VPS_HOST="${1:-vps}"
+GGUF_FILENAME="hermes-4_3_36b-Q4_K_M.gguf"
+LOCAL_BASE_DIR="$HOME/autolora/base"
+LOCAL_GGUF_PATH="$LOCAL_BASE_DIR/$GGUF_FILENAME"
+EXPECTED_MIN_SIZE_GB=18  # ~20GB, warn if smaller
+
+log()  { echo -e "${CYAN}[transfer]${NC} $*"; }
+ok()   { echo -e "${GREEN}[ok]${NC} $*"; }
+warn() { echo -e "${YELLOW}[warn]${NC} $*"; }
+die()  { echo -e "${RED}[error]${NC} $*" >&2; exit 1; }
+
+# ── Pre-flight checks ────────────────────────────────────────────────────────
+
+log "Pre-flight checks..."
+
+# Verify rsync available
+command -v rsync >/dev/null 2>&1 || die "rsync not found. Install with: brew install rsync"
+
+# Verify SSH connectivity to VPS (quick check, 5s timeout)
+if ! ssh -o ConnectTimeout=5 -o BatchMode=yes "$VPS_HOST" true 2>/dev/null; then
+    die "Cannot reach VPS host '$VPS_HOST' via SSH. Check Tailscale is up and SSH key is configured."
+fi
+ok "VPS '$VPS_HOST' reachable via SSH"
+
+# Find GGUF on VPS
+log "Locating $GGUF_FILENAME on VPS..."
+VPS_GGUF_PATH=$(ssh "$VPS_HOST" "find ~/.cache/huggingface -name '$GGUF_FILENAME' 2>/dev/null | head -1")
+
+if [[ -z "$VPS_GGUF_PATH" ]]; then
+    # Try broader search
+    VPS_GGUF_PATH=$(ssh "$VPS_HOST" "find ~ -name '$GGUF_FILENAME' 2>/dev/null | head -1")
+fi
+
+if [[ -z "$VPS_GGUF_PATH" ]]; then
+    die "Could not find $GGUF_FILENAME on VPS '$VPS_HOST'. Download it first with:
+  ssh $VPS_HOST
+  huggingface-cli download NousResearch/Hermes-3-Llama-3.1-70B-GGUF --include '$GGUF_FILENAME' --local-dir ~/.cache/huggingface/hermes43"
+fi
+ok "Found on VPS: $VPS_GGUF_PATH"
+
+# Check VPS file size
+VPS_SIZE_BYTES=$(ssh "$VPS_HOST" "stat -c%s '$VPS_GGUF_PATH' 2>/dev/null || stat -f%z '$VPS_GGUF_PATH' 2>/dev/null")
+VPS_SIZE_GB=$(echo "scale=1; $VPS_SIZE_BYTES / 1073741824" | bc)
+log "VPS file size: ${VPS_SIZE_GB}GB"
+
+if (( $(echo "$VPS_SIZE_GB < $EXPECTED_MIN_SIZE_GB" | bc -l) )); then
+    warn "File is smaller than expected (${VPS_SIZE_GB}GB < ${EXPECTED_MIN_SIZE_GB}GB). It may be incomplete on VPS."
+fi
+
+# ── Local setup ──────────────────────────────────────────────────────────────
+
+log "Creating local directory: $LOCAL_BASE_DIR"
+mkdir -p "$LOCAL_BASE_DIR"
+
+# Check available disk space (need ~22GB free)
+AVAILABLE_KB=$(df -k "$LOCAL_BASE_DIR" | awk 'NR==2 {print $4}')
+AVAILABLE_GB=$(echo "scale=1; $AVAILABLE_KB / 1048576" | bc)
+log "Available disk space: ${AVAILABLE_GB}GB"
+
+NEEDED_GB=$(echo "scale=0; ($VPS_SIZE_BYTES / 1073741824) + 2" | bc)
+if (( $(echo "$AVAILABLE_GB < $NEEDED_GB" | bc -l) )); then
+    die "Insufficient disk space. Need ~${NEEDED_GB}GB, have ${AVAILABLE_GB}GB free."
+fi
+
+# ── Transfer ─────────────────────────────────────────────────────────────────
+
+log "Starting transfer: $VPS_HOST:$VPS_GGUF_PATH → $LOCAL_GGUF_PATH"
+log "Using --partial for resumable transfer over Tailscale"
+echo ""
+
+rsync \
+    --archive \
+    --verbose \
+    --progress \
+    --partial \
+    --human-readable \
+    "$VPS_HOST:$VPS_GGUF_PATH" \
+    "$LOCAL_GGUF_PATH"
+
+echo ""
+
+# ── Verify ───────────────────────────────────────────────────────────────────
+
+if [[ ! -f "$LOCAL_GGUF_PATH" ]]; then
+    die "Transfer completed but file not found at $LOCAL_GGUF_PATH"
+fi
+
+LOCAL_SIZE_BYTES=$(stat -f%z "$LOCAL_GGUF_PATH" 2>/dev/null || stat -c%s "$LOCAL_GGUF_PATH")
+LOCAL_SIZE_GB=$(echo "scale=2; $LOCAL_SIZE_BYTES / 1073741824" | bc)
+
+if [[ "$LOCAL_SIZE_BYTES" != "$VPS_SIZE_BYTES" ]]; then
+    die "Size mismatch! VPS: ${VPS_SIZE_BYTES}B, Local: ${LOCAL_SIZE_BYTES}B. Re-run to resume transfer."
+fi
+
+ok "Transfer complete: $LOCAL_GGUF_PATH (${LOCAL_SIZE_GB}GB)"
+echo ""
+echo -e "${BOLD}Next step:${NC} Import into Ollama:"
+echo "  ./autolora/import-to-ollama.sh"

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