Prompt:
Fixes #24

Wires a post-session live compression hook into the hermes agent for AutoLoRA training data collection.

## What changed

- **`agent/live_compressor.py`** (new): `LiveCompressConfig` dataclass + `compress_trajectory` / `run_post_session_hook` functions. Mirrors the batch compressor protection rules (system prompt, first human/gpt/tool turn, last 4 turns always kept verbatim). Middle turns are replaced with a single factual summary paragraph derived from conversation content — no external API calls, no LLM needed. Token budget enforced via a char-based approximation (4 chars/token).

- **`run_agent.py`**: adds `autolora_live_compress` param to `AIAgent.__init__` and `_run_autolora_live_hook()` method called after `_save_trajectory()`. The hook is always non-fatal — any errors are logged as warnings and the session continues normally.

- **`cli.py`**: reads the `autolora` section from `CLI_CONFIG`, builds a `LiveCompressConfig`, passes it to `AIAgent`. Disabled by default.

- **`hermes_cli/config.py`**: adds `autolora` section to `DEFAULT_CONFIG` (enabled: false).

- **`cli-config.yaml.example`**: documents the autolora configuration block with all options.

- **`tests/test_live_compressor.py`** (new): 25 tests covering config, token estimation, compression logic, protected-turn handling, and JSONL file I/O.

## Enabling

Add to `~/.hermes/config.yaml`:
```yaml
autolora:
  enabled: true
  output_dir: ~/autolora/data/live
  target_max_tokens: 15250
  protect_last_n_turns: 4
```

Output: `~/autolora/data/live/<session_id>_compressed.jsonl` — format matches batch compressor (ShareGPT JSONL).

Chosen 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:
Fixes #23

## What this does

- **Patches `trajectory_compressor.py`** to detect local Ollama endpoints (`localhost:11434` or `ollama` in base URL), routing them through the existing `ollama` provider in the auth registry. This is the patch described in #22.

- **Adds `scripts/batch_compress_autolora.py`** — a resumable batch runner that:
  - Reads `~/autolora/data/train_set.jsonl` (364 sessions; 273 need compression)
  - Writes to `~/autolora/data/compressed_train.jsonl`
  - Targets 15,250 tokens/session via local `hermes3:8b`
  - Tracks progress in `compressed_train.state.json` — safe to interrupt and restart
  - Logs stats (sessions processed, compression ratio, failures) to `~/autolora/logs/compression_log.json`
  - Prints per-session ETA + running totals (tmux-friendly)

## Config

The local Ollama config lives at `~/autolora/configs/compress_local.yaml` (created outside the repo since it contains a machine-local path). To run:

```bash
OLLAMA_API_KEY=ollama python3 scripts/batch_compress_autolora.py
```

## Dry-run verified

```
  Loaded 364 sessions
  Sessions over 15,250-token budget: 273 / 364
  Token stats: min=559  avg=28,396  max=409,354
```

Chosen response:
diff --git a/scripts/batch_compress_autolora.py b/scripts/batch_compress_autolora.py
new file mode 100644
index 00000000..b194f92b
--- /dev/null
+++ b/scripts/batch_compress_autolora.py
@@ -0,0 +1,351 @@
+#!/usr/bin/env python3
+"""
+Batch Compress AutoLoRA Training Set (Resumable)
+
+Compresses ~/autolora/data/train_set.jsonl → ~/autolora/data/compressed_train.jsonl
+using local Ollama (hermes3:8b).  Tracks progress so it can be stopped and restarted
+without reprocessing already-completed sessions.
+
+Usage:
+    # Run from the repo root
+    OLLAMA_API_KEY=ollama python scripts/batch_compress_autolora.py
+
+    # Custom paths
+    OLLAMA_API_KEY=ollama python scripts/batch_compress_autolora.py \\
+        --input=~/autolora/data/train_set.jsonl \\
+        --output=~/autolora/data/compressed_train.jsonl \\
+        --config=~/autolora/configs/compress_local.yaml
+
+    # Dry-run: show token stats without compressing
+    OLLAMA_API_KEY=ollama python scripts/batch_compress_autolora.py --dry_run
+
+    # Start fresh (ignore existing progress)
+    OLLAMA_API_KEY=ollama python scripts/batch_compress_autolora.py --reset
+
+Resumability:
+    Progress is tracked in <output>.state.json — lists IDs of completed sessions.
+    Interrupt at any time (Ctrl+C); re-run to continue from where it left off.
+    Safe to run in tmux so it survives terminal disconnects.
+"""
+
+import asyncio
+import json
+import os
+import sys
+import time
+from datetime import datetime
+from pathlib import Path
+
+import fire
+
+
+DEFAULT_INPUT = Path.home() / "autolora" / "data" / "train_set.jsonl"
+DEFAULT_OUTPUT = Path.home() / "autolora" / "data" / "compressed_train.jsonl"
+DEFAULT_CONFIG = Path.home() / "autolora" / "configs" / "compress_local.yaml"
+DEFAULT_LOG = Path.home() / "autolora" / "logs" / "compression_log.json"
+
+
+def _load_state(state_path: Path) -> dict:
+    """Load progress state; returns empty state if not found."""
+    if state_path.exists():
+        with open(state_path) as f:
+            return json.load(f)
+    return {"completed_ids": [], "stats": {}}
+
+
+def _save_state(state_path: Path, state: dict):
+    """Atomically save progress state."""
+    tmp = state_path.with_suffix(".tmp")
+    with open(tmp, "w") as f:
+        json.dump(state, f, indent=2)
+    tmp.replace(state_path)
+
+
+def _count_tokens_estimate(text: str) -> int:
+    """Rough token estimate (chars / 4) when tokenizer unavailable."""
+    return len(text) // 4
+
+
+def _session_tokens(session: dict) -> int:
+    """Count tokens for a session using char estimate."""
+    convs = session.get("conversations", [])
+    return sum(len(t.get("value", "")) // 4 for t in convs)
+
+
+def main(
+    input: str = str(DEFAULT_INPUT),
+    output: str = str(DEFAULT_OUTPUT),
+    config: str = str(DEFAULT_CONFIG),
+    log: str = str(DEFAULT_LOG),
+    dry_run: bool = False,
+    reset: bool = False,
+    batch_log_every: int = 10,
+):
+    """
+    Compress the AutoLoRA training set via local Ollama.
+
+    Args:
+        input: Path to train_set.jsonl (default: ~/autolora/data/train_set.jsonl)
+        output: Path for compressed output (default: ~/autolora/data/compressed_train.jsonl)
+        config: Compression config YAML (default: ~/autolora/configs/compress_local.yaml)
+        log: Path for compression stats JSON log (default: ~/autolora/logs/compression_log.json)
+        dry_run: Scan and report token stats without compressing
+        reset: Ignore existing progress and restart from scratch
+        batch_log_every: Log running stats after every N sessions
+    """
+    # Verify OLLAMA_API_KEY is set (Ollama needs a non-empty key even though it ignores it)
+    if not os.getenv("OLLAMA_API_KEY") and not dry_run:
+        print("⚠  OLLAMA_API_KEY not set — defaulting to 'ollama'")
+        os.environ["OLLAMA_API_KEY"] = "ollama"
+
+    input_path = Path(input).expanduser()
+    output_path = Path(output).expanduser()
+    config_path = Path(config).expanduser()
+    log_path = Path(log).expanduser()
+    state_path = output_path.with_suffix(".state.json")
+
+    print("=" * 64)
+    print("  AutoLoRA Batch Compressor (Resumable)")
+    print("=" * 64)
+    print(f"  Input:   {input_path}")
+    print(f"  Output:  {output_path}")
+    print(f"  Config:  {config_path}")
+    print(f"  State:   {state_path}")
+    print(f"  Log:     {log_path}")
+    print(f"  Mode:    {'DRY RUN' if dry_run else 'COMPRESS'}")
+    print("=" * 64)
+
+    # Validate paths
+    if not input_path.exists():
+        print(f"❌ Input not found: {input_path}")
+        sys.exit(1)
+    if not config_path.exists():
+        print(f"❌ Config not found: {config_path}")
+        sys.exit(1)
+
+    # Load all sessions
+    print(f"\n📂 Loading sessions from {input_path.name}...")
+    sessions = []
+    with open(input_path, encoding="utf-8") as f:
+        for i, line in enumerate(f):
+            line = line.strip()
+            if line:
+                try:
+                    sessions.append(json.loads(line))
+                except json.JSONDecodeError as e:
+                    print(f"  ⚠  Skipping invalid JSON at line {i + 1}: {e}")
+
+    total = len(sessions)
+    print(f"  ✅ Loaded {total:,} sessions")
+
+    # Token stats overview
+    token_counts = [_session_tokens(s) for s in sessions]
+    over_budget = sum(1 for t in token_counts if t > 15250)
+    print(f"  📊 Sessions over 15,250-token budget: {over_budget:,} / {total:,}")
+    if token_counts:
+        avg = sum(token_counts) / len(token_counts)
+        print(f"  📊 Token stats: min={min(token_counts):,}  avg={avg:,.0f}  max={max(token_counts):,}")
+
+    if dry_run:
+        print("\n✅ Dry-run complete — no files written.")
+        return
+
+    # Load progress state
+    state = {"completed_ids": [], "stats": {}} if reset else _load_state(state_path)
+    completed_ids = set(state.get("completed_ids", []))
+
+    if completed_ids and not reset:
+        print(f"\n♻  Resuming: {len(completed_ids):,} sessions already done, "
+              f"{total - len(completed_ids):,} remaining")
+    elif reset and state_path.exists():
+        print("\n🔄 Reset requested — ignoring previous progress")
+        # Clear output file too
+        if output_path.exists():
+            output_path.unlink()
+
+    # Build session lookup keyed by 'id' (or fallback to line index as string)
+    sessions_by_key = {}
+    for i, s in enumerate(sessions):
+        key = s.get("id", str(i))
+        sessions_by_key[key] = (i, s)
+
+    # Determine which sessions need processing
+    pending = [
+        (key, idx, sess)
+        for key, (idx, sess) in sessions_by_key.items()
+        if key not in completed_ids
+    ]
+    # Sort by original order
+    pending.sort(key=lambda x: x[1])
+
+    print(f"\n🗜  Sessions to compress: {len(pending):,}")
+
+    if not pending:
+        print("✅ All sessions already compressed!")
+        _write_final_log(log_path, state, total, completed_ids)
+        return
+
+    # Initialise compressor
+    sys.path.insert(0, str(Path(__file__).parent.parent))
+    from trajectory_compressor import TrajectoryCompressor, CompressionConfig
+
+    print(f"\n🔧 Loading config from {config_path.name}...")
+    compression_config = CompressionConfig.from_yaml(str(config_path))
+
+    print(f"  Model:  {compression_config.summarization_model}")
+    print(f"  URL:    {compression_config.base_url}")
+    print(f"  Target: {compression_config.target_max_tokens:,} tokens")
+    print(f"  Concurrency: {compression_config.max_concurrent_requests}")
+    print()
+
+    compressor = TrajectoryCompressor(compression_config)
+
+    # Open output file for appending
+    output_path.parent.mkdir(parents=True, exist_ok=True)
+    log_path.parent.mkdir(parents=True, exist_ok=True)
+
+    # Running stats
+    stats = state.get("stats", {
+        "compressed": 0,
+        "skipped_under_budget": 0,
+        "still_over_limit": 0,
+        "failed": 0,
+        "total_tokens_before": 0,
+        "total_tokens_after": 0,
+    })
+
+    start_time = time.time()
+    processed_this_run = 0
+
+    async def run():
+        nonlocal processed_this_run
+
+        with open(output_path, "a", encoding="utf-8") as out_f:
+            for key, idx, sess in pending:
+                session_start = time.time()
+                try:
+                    processed_entry, metrics = await compressor.process_entry_async(sess)
+                    out_f.write(json.dumps(processed_entry, ensure_ascii=False) + "\n")
+                    out_f.flush()
+
+                    # Update stats
+                    stats["total_tokens_before"] += metrics.original_tokens
+                    stats["total_tokens_after"] += metrics.compressed_tokens
+                    if metrics.was_compressed:
+                        stats["compressed"] += 1
+                    elif metrics.skipped_under_target:
+                        stats["skipped_under_budget"] += 1
+                    if metrics.still_over_limit:
+                        stats["still_over_limit"] += 1
+
+                except Exception as e:
+                    print(f"\n  ⚠  Error on session {key}: {e}")
+                    stats["failed"] += 1
+                    # Write original session on failure so output is complete
+                    out_f.write(json.dumps(sess, ensure_ascii=False) + "\n")
+                    out_f.flush()
+
+                # Mark done
+                completed_ids.add(key)
+                processed_this_run += 1
+                total_done = len(completed_ids)
+
+                # Elapsed / ETA
+                elapsed = time.time() - start_time
+                rate = processed_this_run / max(elapsed, 1)
+                remaining = len(pending) - processed_this_run
+                eta_sec = remaining / rate if rate > 0 else 0
+                eta_str = _fmt_duration(eta_sec)
+                session_sec = time.time() - session_start
+
+                print(
+                    f"  [{total_done:>4}/{total:>4}] {key}  "
+                    f"({session_sec:.1f}s)  "
+                    f"ETA: {eta_str}  "
+                    f"compressed={stats['compressed']}  "
+                    f"skipped={stats['skipped_under_budget']}  "
+                    f"failed={stats['failed']}"
+                )
+
+                # Save state periodically
+                if processed_this_run % batch_log_every == 0 or remaining == 0:
+                    _checkpoint(state_path, log_path, completed_ids, stats,
+                                total, start_time)
+
+    asyncio.run(run())
+
+    # Final checkpoint
+    _checkpoint(state_path, log_path, completed_ids, stats, total, start_time)
+
+    elapsed = time.time() - start_time
+    print()
+    print("=" * 64)
+    print("  COMPRESSION COMPLETE")
+    print("=" * 64)
+    print(f"  Total sessions:      {total:,}")
+    print(f"  Compressed:          {stats['compressed']:,}")
+    print(f"  Skipped (≤ budget):  {stats['skipped_under_budget']:,}")
+    print(f"  Still over limit:    {stats['still_over_limit']:,}")
+    print(f"  Failed:              {stats['failed']:,}")
+    if stats["total_tokens_before"] > 0:
+        ratio = stats["total_tokens_after"] / stats["total_tokens_before"]
+        saved = stats["total_tokens_before"] - stats["total_tokens_after"]
+        print(f"  Tokens before:       {stats['total_tokens_before']:,}")
+        print(f"  Tokens after:        {stats['total_tokens_after']:,}")
+        print(f"  Tokens saved:        {saved:,}  ({1 - ratio:.1%})")
+    print(f"  Duration:            {_fmt_duration(elapsed)}")
+    print(f"  Output:              {output_path}")
+    print(f"  Log:                 {log_path}")
+    print("=" * 64)
+
+
+def _checkpoint(state_path, log_path, completed_ids, stats, total, start_time):
+    """Save progress state and write log."""
+    state = {
+        "completed_ids": sorted(completed_ids),
+        "stats": stats,
+        "last_updated": datetime.now().isoformat(),
+        "total_sessions": total,
+    }
+    _save_state(state_path, state)
+    _write_final_log(log_path, state, total, completed_ids)
+
+
+def _write_final_log(log_path, state, total, completed_ids):
+    """Write a human-readable JSON log with compression stats."""
+    stats = state.get("stats", {})
+    tb = stats.get("total_tokens_before", 0)
+    ta = stats.get("total_tokens_after", 0)
+    log = {
+        "generated_at": datetime.now().isoformat(),
+        "sessions": {
+            "total": total,
+            "completed": len(completed_ids),
+            "remaining": total - len(completed_ids),
+        },
+        "compression": {
+            "sessions_compressed": stats.get("compressed", 0),
+            "sessions_skipped_under_budget": stats.get("skipped_under_budget", 0),
+            "sessions_still_over_limit": stats.get("still_over_limit", 0),
+            "sessions_failed": stats.get("failed", 0),
+            "tokens_before": tb,
+            "tokens_after": ta,
+            "tokens_saved": tb - ta,
+            "compression_ratio": round(ta / tb, 4) if tb > 0 else None,
+            "space_savings_pct": round((1 - ta / tb) * 100, 1) if tb > 0 else None,
+        },
+    }
+    with open(log_path, "w") as f:
+        json.dump(log, f, indent=2)
+
+
+def _fmt_duration(seconds: float) -> str:
+    if seconds < 60:
+        return f"{seconds:.0f}s"
+    if seconds < 3600:
+        return f"{seconds / 60:.1f}m"
+    return f"{seconds / 3600:.1f}h"
+
+
+if __name__ == "__main__":
+    fire.Fire(main)
diff --git a/trajectory_compressor.py b/trajectory_compressor.py
index ef81d6e2..6baa7cc5 100644
--- a/trajectory_compressor.py
+++ b/trajectory_compressor.py
@@ -401,6 +401,8 @@ class TrajectoryCompressor:
             return "minimax-cn"
         if "minimax.io" in url:
             return "minimax"
+        if "localhost:11434" in url or "ollama" in url:
+            return "ollama"
         # Unknown base_url — not a known provider
         return ""


Prompt:
Fixes #22

## What changed

- **Default provider**: OpenRouter → Ollama (`localhost:11434`)
- **Default model**: `google/gemini-3-flash-preview` → `hermes3:8b`
- **No API key required** for default operation — Ollama accepts any dummy key via the OpenAI-compat API
- Added explicit `provider` field to `CompressionConfig` (default: `"ollama"`)
- `_detect_provider()` now checks `config.provider` first, then falls back to URL-based detection (with new `localhost:11434` pattern)
- `_init_summarizer()` handles Ollama directly without going through `resolve_provider_client` (which requires an env var)
- Added `--provider` and `--model` CLI overrides to `main()`
- Updated `datagen-config-examples/trajectory_compression.yaml` with Ollama defaults
- OpenRouter remains fully supported: set `provider: openrouter` + `api_key_env: OPENROUTER_API_KEY`

## Usage

```bash
# Default — local Ollama, no API key needed
python trajectory_compressor.py --input=data/my_run

# Explicit local model
python trajectory_compressor.py --input=data/my_run --provider=ollama --model=hermes3:8b

# Still works with OpenRouter if you want cloud
python trajectory_compressor.py --input=data/my_run --provider=openrouter --model=google/gemini-flash-1.5
```

All 25 existing tests pass.

Chosen response:
diff --git a/datagen-config-examples/trajectory_compression.yaml b/datagen-config-examples/trajectory_compression.yaml
index c5b92a97..48a395e6 100644
--- a/datagen-config-examples/trajectory_compression.yaml
+++ b/datagen-config-examples/trajectory_compression.yaml
@@ -38,24 +38,30 @@ protected_turns:
   # This ensures the model's final actions and conclusions are preserved
   last_n_turns: 4

-# LLM settings for generating summaries (OpenRouter only)
+# LLM settings for generating summaries
+# Default: local Ollama (no API key required, no data leaves the machine)
+# To use OpenRouter instead: set provider: openrouter, model: google/gemini-flash-1.5, api_key_env: OPENROUTER_API_KEY
 summarization:
-  # Model to use for summarization (should be fast and cheap)
-  # Using OpenRouter model path format
-  model: "google/gemini-3-flash-preview"
-
-  # OpenRouter API settings
-  base_url: "https://openrouter.ai/api/v1"
-
-  # Environment variable containing OpenRouter API key
-  api_key_env: "OPENROUTER_API_KEY"
-
+  # Provider: ollama (local, default) or openrouter (cloud)
+  provider: "ollama"
+
+  # Model to use for summarization
+  # For Ollama: any locally-pulled model (hermes3:8b runs at ~57 t/s on Apple Silicon)
+  # For OpenRouter: e.g. "google/gemini-flash-1.5"
+  model: "hermes3:8b"
+
+  # Ollama base URL — override via OLLAMA_BASE_URL env var if needed
+  base_url: "http://localhost:11434/v1"
+
+  # API key env var (not required for Ollama; set to OPENROUTER_API_KEY for cloud)
+  # api_key_env: "OPENROUTER_API_KEY"
+
   # Temperature for summarization (lower = more deterministic)
   temperature: 0.3
-
+
   # Max retries for API failures
   max_retries: 3
-
+
   # Delay between retries (seconds)
   retry_delay: 2

diff --git a/trajectory_compressor.py b/trajectory_compressor.py
index ef81d6e2..2434a448 100644
--- a/trajectory_compressor.py
+++ b/trajectory_compressor.py
@@ -44,7 +44,7 @@ from datetime import datetime
 import fire
 from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn, TimeElapsedColumn, TimeRemainingColumn
 from rich.console import Console
-from hermes_constants import OPENROUTER_BASE_URL
+OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1")

 # Load environment variables
 from dotenv import load_dotenv
@@ -69,10 +69,11 @@ class CompressionConfig:
     protect_first_tool: bool = True
     protect_last_n_turns: int = 4

-    # Summarization (OpenRouter)
-    summarization_model: str = "google/gemini-3-flash-preview"
-    base_url: str = OPENROUTER_BASE_URL
-    api_key_env: str = "OPENROUTER_API_KEY"
+    # Summarization (default: local Ollama)
+    provider: str = "ollama"
+    summarization_model: str = "hermes3:8b"
+    base_url: str = OLLAMA_BASE_URL
+    api_key_env: str = ""  # Not required for Ollama; set for cloud providers
     temperature: float = 0.3
     max_retries: int = 3
     retry_delay: int = 2
@@ -122,6 +123,7 @@ class CompressionConfig:

         # Summarization
         if 'summarization' in data:
+            config.provider = data['summarization'].get('provider', config.provider)
             config.summarization_model = data['summarization'].get('model', config.summarization_model)
             config.base_url = data['summarization'].get('base_url', config.base_url)
             config.api_key_env = data['summarization'].get('api_key_env', config.api_key_env)
@@ -346,19 +348,25 @@ class TrajectoryCompressor:
     def _init_summarizer(self):
         """Initialize LLM routing for summarization (sync and async).

-        Uses call_llm/async_call_llm from the centralized provider router
-        which handles auth, headers, and provider detection internally.
-        For custom endpoints, falls back to raw client construction.
+        Supports local Ollama (default) and cloud providers (OpenRouter, etc.).
+        For Ollama no API key is required — uses direct client construction.
+        For known cloud providers uses the centralized provider router.
         """
-        from agent.auxiliary_client import call_llm, async_call_llm
-
         provider = self._detect_provider()
-        if provider:
-            # Store provider for use in _generate_summary calls
+
+        if provider == "ollama":
+            # Local Ollama — no API key needed; use direct client construction
+            self._use_call_llm = False
+            base_url = os.getenv("OLLAMA_BASE_URL", self.config.base_url)
+            from openai import OpenAI, AsyncOpenAI
+            self.client = OpenAI(api_key="ollama", base_url=base_url)
+            self.async_client = AsyncOpenAI(api_key="ollama", base_url=base_url)
+            print(f"   Provider: Ollama @ {base_url}")
+        elif provider:
+            # Known cloud provider — use centralized router
+            from agent.auxiliary_client import resolve_provider_client
             self._llm_provider = provider
             self._use_call_llm = True
-            # Verify the provider is available
-            from agent.auxiliary_client import resolve_provider_client
             client, _ = resolve_provider_client(
                 provider, model=self.config.summarization_model)
             if client is None:
@@ -367,14 +375,15 @@ class TrajectoryCompressor:
                     f"Check your API key or run: hermes setup")
             self.client = None  # Not used directly
             self.async_client = None  # Not used directly
+            print(f"   Provider: {provider}")
         else:
             # Custom endpoint — use config's raw base_url + api_key_env
             self._use_call_llm = False
-            api_key = os.getenv(self.config.api_key_env)
+            api_key = os.getenv(self.config.api_key_env) if self.config.api_key_env else None
             if not api_key:
                 raise RuntimeError(
                     f"Missing API key. Set {self.config.api_key_env} "
-                    f"environment variable.")
+                    f"environment variable, or use --provider=ollama for local inference.")
             from openai import OpenAI, AsyncOpenAI
             self.client = OpenAI(
                 api_key=api_key, base_url=self.config.base_url)
@@ -385,8 +394,14 @@ class TrajectoryCompressor:
         print(f"   Max concurrent requests: {self.config.max_concurrent_requests}")

     def _detect_provider(self) -> str:
-        """Detect the provider name from the configured base_url."""
+        """Detect the provider name from config.provider, then base_url."""
+        # Explicit provider config takes precedence
+        if self.config.provider:
+            return self.config.provider.lower()
+        # Fall back to URL-based detection
         url = self.config.base_url.lower()
+        if "localhost:11434" in url or "127.0.0.1:11434" in url:
+            return "ollama"
         if "openrouter" in url:
             return "openrouter"
         if "nousresearch.com" in url:
@@ -1282,13 +1297,15 @@ def main(
     sample_percent: float = None,
     seed: int = 42,
     dry_run: bool = False,
+    provider: str = None,
+    model: str = None,
 ):
     """
     Compress agent trajectories to fit within a target token budget.
-
+
     Supports both single JSONL files and directories containing multiple JSONL files.
     Optionally sample a percentage of trajectories before compression.
-
+
     Args:
         input: Path to JSONL file or directory containing JSONL files
         output: Output path (file for file input, directory for dir input)
@@ -1299,17 +1316,22 @@ def main(
         sample_percent: Sample this percentage of trajectories (1-100) before compression
         seed: Random seed for sampling reproducibility (default: 42)
         dry_run: Analyze without compressing (just show what would happen)
-
+        provider: LLM provider for summarization (default: ollama). Options: ollama, openrouter
+        model: Model name for summarization (default: hermes3:8b for ollama)
+
     Examples:
-        # Compress a directory (original behavior)
+        # Compress using local Ollama (default, no API key needed)
         python trajectory_compressor.py --input=data/my_run
-
+
+        # Compress with a specific local model
+        python trajectory_compressor.py --input=data/my_run --provider=ollama --model=hermes3:8b
+
         # Compress a single file
         python trajectory_compressor.py --input=data/trajectories.jsonl
-
+
         # Compress 15% sample of a file
         python trajectory_compressor.py --input=data/trajectories.jsonl --sample_percent=15
-
+
         # Compress 10% sample with custom output
         python trajectory_compressor.py --input=data/trajectories.jsonl --sample_percent=10 --output=data/sampled_compressed.jsonl
     """
@@ -1334,6 +1356,10 @@ def main(
         compression_config.target_max_tokens = target_max_tokens
     if tokenizer:
         compression_config.tokenizer_name = tokenizer
+    if provider:
+        compression_config.provider = provider
+    if model:
+        compression_config.summarization_model = model

     # Validate sample_percent
     if sample_percent is not None:


Prompt:
Fixes #26

## Summary

Full MLX QLoRA training pipeline for Apple Silicon (hermes4.3:timmy-v1).

## What was built

- **`autolora/train_mlx.py`** — main training launcher; reads `config/v1.yaml`, builds the `mlx_lm.lora` CLI command with all hyperparams, runs training as a subprocess with live output tee to `training_logs/v1/`, scaffolds post-training Ollama Modelfile on success
- **`autolora/scripts/convert_data.py`** — converts compressed Hermes trajectory JSONL (ShareGPT `conversations` format) to MLX chat format (`{"messages": [...]}`), with configurable train/valid split
- **`autolora/scripts/fetch_base_model.py`** — downloads safetensors models from mlx-community (required — MLX LoRA does not support GGUF directly)
- **`autolora/scripts/fuse_and_convert.sh`** — fuses LoRA adapters into base model via `mlx_lm.fuse`, then converts to GGUF via llama.cpp for Ollama ingestion
- **`autolora/scripts/create_ollama_model.sh`** — builds `hermes4.3:timmy-v1` from GGUF + Modelfile
- **`autolora/config/v1.yaml`** — training config: r=16, lr=2e-4, 1000 iters, grad_checkpoint, q_proj/v_proj targets, memory notes
- **`autolora/training_logs/v1/`** — log dir tracked in repo (logs written here at runtime)
- **`autolora/README.md`** — end-to-end runbook

## Notes on GGUF vs safetensors

MLX LoRA training requires safetensors format — the GGUF stored in Ollama cannot be used directly as the training base. `fetch_base_model.py` pulls a pre-quantized MLX model from mlx-community. The Ollama GGUF remains available for inference; post-training we fuse → convert → re-import.

Chosen response:
diff --git a/autolora/README.md b/autolora/README.md
new file mode 100644
index 00000000..fc955a58
--- /dev/null
+++ b/autolora/README.md
@@ -0,0 +1,130 @@
+# AutoLoRA — Local LoRA Training on Apple Silicon
+
+MLX QLoRA fine-tuning pipeline for Hermes 4.3 on Apple Silicon unified memory.
+
+## Pipeline Overview
+
+```
+compressed_train.jsonl
+        │
+        ▼
+scripts/convert_data.py          → ~/autolora/data/{train,valid}.jsonl
+        │
+        ▼
+scripts/fetch_base_model.py      → ~/autolora/models/hermes-base/
+        │
+        ▼
+train_mlx.py                     → ~/autolora/adapters/v1/   (LoRA weights)
+        │                           autolora/training_logs/v1/
+        ▼
+scripts/fuse_and_convert.sh      → ~/autolora/fused/v1/model.gguf
+        │
+        ▼
+scripts/create_ollama_model.sh   → ollama model hermes4.3:timmy-v1
+```
+
+## Quick Start
+
+### 1. Install MLX-LM
+
+```bash
+pip install mlx-lm
+```
+
+### 2. Fetch base model (safetensors required — not GGUF)
+
+```bash
+python autolora/scripts/fetch_base_model.py
+# Downloads mlx-community/Hermes-3-Llama-3.1-8B-4bit to ~/autolora/models/hermes-base
+```
+
+For the full 36B model (requires ~70GB+ free disk):
+```bash
+python autolora/scripts/fetch_base_model.py \
+    --model mlx-community/Hermes-3-Llama-3.1-70B-4bit
+```
+
+### 3. Convert training data
+
+Assumes `~/autolora/data/compressed_train.jsonl` exists (from issue #23):
+
+```bash
+python autolora/scripts/convert_data.py \
+    --input ~/autolora/data/compressed_train.jsonl \
+    --output_dir ~/autolora/data/
+```
+
+### 4. Train
+
+```bash
+python autolora/train_mlx.py
+
+# Dry run (print command, don't execute):
+python autolora/train_mlx.py --dry_run
+
+# Memory-constrained (reduce rank and sequence length):
+python autolora/train_mlx.py --lora_rank 8 --max_seq_length 4096 --batch_size 2
+```
+
+Training logs → `autolora/training_logs/v1/run_<timestamp>.log`
+Adapter weights → `~/autolora/adapters/v1/`
+
+### 5. Fuse adapters + convert to GGUF
+
+```bash
+# Requires llama.cpp cloned at ~/llama.cpp
+bash autolora/scripts/fuse_and_convert.sh
+```
+
+### 6. Create Ollama model
+
+```bash
+bash autolora/scripts/create_ollama_model.sh
+ollama run hermes4.3:timmy-v1 "Hello, who are you?"
+```
+
+## Configuration
+
+Training hyperparameters live in `autolora/config/v1.yaml`.
+All CLI flags override config file values.
+
+Key parameters:
+| Parameter       | Default | Notes                                          |
+|-----------------|---------|------------------------------------------------|
+| `lora_rank`     | 16      | Reduce to 8 if OOM                             |
+| `lora_layers`   | 16      | Layers to apply LoRA (from top)                |
+| `iters`         | 1000    | Training iterations (~2-3 epochs typical)      |
+| `batch_size`    | 4       | Reduce to 2 if OOM                             |
+| `learning_rate` | 2e-4    | Standard QLoRA rate                            |
+| `max_seq_length`| 8192    | Reduce to 4096 if OOM                          |
+
+## Memory Budget (36GB Unified)
+
+| Component              | Approx.  |
+|------------------------|----------|
+| Q4 base model (8B)     | ~5 GB    |
+| Q4 base model (36B)    | ~20 GB   |
+| LoRA adapters (r=16)   | ~1 GB    |
+| Activation / gradients | ~8-12 GB |
+| OS + other             | ~4 GB    |
+
+For 36B: expect ~32-35 GB peak. Use `--grad_checkpoint` (enabled by default).
+
+## Troubleshooting
+
+**OOM during training**
+```bash
+python autolora/train_mlx.py --lora_rank 8 --batch_size 2 --max_seq_length 4096
+```
+
+**mlx-lm not found**
+```bash
+pip install mlx-lm
+```
+
+**`model` path errors**
+Update `autolora/config/v1.yaml` `model:` field to point to your local safetensors directory.
+
+**GGUF base in Ollama — can't use directly for MLX training**
+MLX LoRA requires safetensors format. Use `fetch_base_model.py` to download a
+safetensors version from mlx-community. The GGUF in Ollama can remain for inference.
diff --git a/autolora/config/v1.yaml b/autolora/config/v1.yaml
new file mode 100644
index 00000000..789a6da0
--- /dev/null
+++ b/autolora/config/v1.yaml
@@ -0,0 +1,46 @@
+# AutoLoRA v1 — MLX QLoRA Training Config
+# Base: Hermes 4.3 36B (NousResearch/Hermes-3-Llama-3.1-70B or equivalent safetensors)
+# Method: QLoRA (4-bit quantized base + LoRA adapters)
+
+model: "NousResearch/Hermes-3-Llama-3.1-8B"  # adjust to available safetensors model
+train: true
+fine_tune_type: lora
+
+# LoRA hyperparameters
+lora_layers: 16        # number of layers to apply LoRA to (from top)
+lora_rank: 16          # r=16 (conservative start; drop to 8 if OOM)
+lora_alpha: 32         # alpha = 2 * rank is a standard starting point
+lora_dropout: 0.05
+target_modules:
+  - q_proj
+  - v_proj
+
+# Training hyperparameters
+batch_size: 4
+iters: 1000            # ~2-3 epochs over typical dataset; adjust to dataset size
+learning_rate: 2.0e-4
+weight_decay: 0.01
+grad_checkpoint: true  # gradient checkpointing — essential for memory budget
+
+# Warmup & scheduling
+lr_schedule: cosine_decay
+warmup_steps: 50
+
+# Evaluation
+steps_per_eval: 100
+val_batches: 25
+
+# Saving
+adapter_path: "~/autolora/adapters/v1"
+save_every: 200        # checkpoint every N iters
+
+# Data (paths set at runtime by train_mlx.py)
+# train: set via --train flag
+# valid: set via --valid flag
+
+# Sequence length
+max_seq_length: 8192
+
+# Memory notes:
+#   36GB unified memory, Q4 base ~20GB, LoRA overhead ~4-6GB
+#   If OOM: reduce lora_rank to 8, batch_size to 2, or max_seq_length to 4096
diff --git a/autolora/scripts/convert_data.py b/autolora/scripts/convert_data.py
new file mode 100644
index 00000000..630bd452
--- /dev/null
+++ b/autolora/scripts/convert_data.py
@@ -0,0 +1,190 @@
+#!/usr/bin/env python3
+"""
+AutoLoRA Data Converter — JSONL → MLX training format
+
+Converts compressed Hermes trajectory JSONL files into the format expected
+by mlx_lm.lora for supervised fine-tuning.
+
+MLX expects JSONL with either:
+  - {"text": "<full conversation as string>"}   (completion format)
+  - {"messages": [...]}                          (chat format, preferred)
+
+Usage:
+    python autolora/scripts/convert_data.py \
+        --input ~/autolora/data/compressed_train.jsonl \
+        --output ~/autolora/data/mlx_train.jsonl \
+        --val_split 0.05
+
+    # Also convert a test/validation split:
+    python autolora/scripts/convert_data.py \
+        --input ~/autolora/data/compressed_train.jsonl \
+        --output_dir ~/autolora/data/
+"""
+
+import json
+import random
+import sys
+from pathlib import Path
+from typing import Any
+
+import fire
+
+
+# Roles that map from Hermes trajectory format to OpenAI chat roles
+_ROLE_MAP = {
+    "system": "system",
+    "human": "user",
+    "user": "user",
+    "gpt": "assistant",
+    "assistant": "assistant",
+    "tool": "tool",
+    "function": "tool",
+}
+
+
+def _normalize_message(msg: dict[str, Any]) -> dict[str, Any] | None:
+    """Normalize a single trajectory message to OpenAI chat format."""
+    role = msg.get("role", msg.get("from", "")).lower()
+    content = msg.get("content", msg.get("value", ""))
+
+    mapped_role = _ROLE_MAP.get(role)
+    if mapped_role is None:
+        return None  # skip unknown roles
+
+    if not content:
+        return None  # skip empty messages
+
+    return {"role": mapped_role, "content": str(content)}
+
+
+def _trajectory_to_mlx(entry: dict[str, Any]) -> dict[str, Any] | None:
+    """
+    Convert one Hermes trajectory entry to MLX chat format.
+
+    Handles both:
+      - {"conversations": [...]}   (ShareGPT / Hermes format)
+      - {"messages": [...]}        (OpenAI format)
+    """
+    messages_raw = entry.get("conversations") or entry.get("messages") or []
+
+    if not messages_raw:
+        return None
+
+    messages = []
+    for m in messages_raw:
+        norm = _normalize_message(m)
+        if norm:
+            messages.append(norm)
+
+    # Need at least one user turn and one assistant turn
+    roles = {m["role"] for m in messages}
+    if "user" not in roles and "system" not in roles:
+        return None
+    if "assistant" not in roles:
+        return None
+
+    return {"messages": messages}
+
+
+def convert(
+    input: str,
+    output: str | None = None,
+    output_dir: str | None = None,
+    val_split: float = 0.05,
+    seed: int = 42,
+    max_samples: int | None = None,
+) -> None:
+    """
+    Convert compressed JSONL to MLX training format.
+
+    Args:
+        input:       Path to compressed JSONL file.
+        output:      Path for output JSONL (train split).
+                     If omitted, derived from output_dir.
+        output_dir:  Directory to write train.jsonl and valid.jsonl.
+                     Used when --output is not given.
+        val_split:   Fraction of data to use for validation (default 0.05).
+        seed:        Random seed for reproducible split.
+        max_samples: Cap total samples (useful for quick tests).
+    """
+    input_path = Path(input).expanduser()
+    if not input_path.exists():
+        print(f"ERROR: input file not found: {input_path}", file=sys.stderr)
+        sys.exit(1)
+
+    # Resolve output paths
+    if output:
+        train_path = Path(output).expanduser()
+        valid_path = train_path.parent / (train_path.stem + "_valid" + train_path.suffix)
+    elif output_dir:
+        out_dir = Path(output_dir).expanduser()
+        out_dir.mkdir(parents=True, exist_ok=True)
+        train_path = out_dir / "train.jsonl"
+        valid_path = out_dir / "valid.jsonl"
+    else:
+        train_path = input_path.parent / "mlx_train.jsonl"
+        valid_path = input_path.parent / "mlx_valid.jsonl"
+
+    # Load raw entries
+    print(f"Loading {input_path} …")
+    raw_entries: list[dict] = []
+    with input_path.open() as f:
+        for line in f:
+            line = line.strip()
+            if not line:
+                continue
+            try:
+                raw_entries.append(json.loads(line))
+            except json.JSONDecodeError as e:
+                print(f"  WARN: skipping malformed line: {e}", file=sys.stderr)
+
+    print(f"  Loaded {len(raw_entries)} raw entries")
+
+    if max_samples and len(raw_entries) > max_samples:
+        random.seed(seed)
+        raw_entries = random.sample(raw_entries, max_samples)
+        print(f"  Sampled down to {max_samples}")
+
+    # Convert
+    converted: list[dict] = []
+    skipped = 0
+    for entry in raw_entries:
+        mlx_entry = _trajectory_to_mlx(entry)
+        if mlx_entry:
+            converted.append(mlx_entry)
+        else:
+            skipped += 1
+
+    print(f"  Converted: {len(converted)}  |  Skipped: {skipped}")
+
+    if not converted:
+        print("ERROR: no usable entries after conversion", file=sys.stderr)
+        sys.exit(1)
+
+    # Train / validation split
+    random.seed(seed)
+    random.shuffle(converted)
+
+    n_val = max(1, int(len(converted) * val_split))
+    val_data = converted[:n_val]
+    train_data = converted[n_val:]
+
+    print(f"  Train: {len(train_data)}  |  Valid: {len(val_data)}")
+
+    # Write output
+    train_path.parent.mkdir(parents=True, exist_ok=True)
+
+    with train_path.open("w") as f:
+        for entry in train_data:
+            f.write(json.dumps(entry, ensure_ascii=False) + "\n")
+
+    with valid_path.open("w") as f:
+        for entry in val_data:
+            f.write(json.dumps(entry, ensure_ascii=False) + "\n")
+
+    print(f"\nWrote train → {train_path}")
+    print(f"Wrote valid → {valid_path}")
+
+
+if __name__ == "__main__":
+    fire.Fire(convert)
diff --git a/autolora/scripts/create_ollama_model.sh b/autolora/scripts/create_ollama_model.sh
new file mode 100644
index 00000000..921a1908
--- /dev/null
+++ b/autolora/scripts/create_ollama_model.sh
@@ -0,0 +1,69 @@
+#!/usr/bin/env bash
+# AutoLoRA v1 — Create Ollama model: hermes4.3:timmy-v1
+#
+# Prerequisites:
+#   - Fused model weights at ~/autolora/fused/v1/ (see train_mlx.py output)
+#   - GGUF file at ~/autolora/fused/v1/model.gguf
+#   - Ollama installed and running
+#
+# Usage:
+#   bash autolora/scripts/create_ollama_model.sh
+#   bash autolora/scripts/create_ollama_model.sh --gguf ~/autolora/fused/v1/model.gguf
+
+set -euo pipefail
+
+GGUF_PATH="${1:-$HOME/autolora/fused/v1/model.gguf}"
+ADAPTER_DIR="$HOME/autolora/adapters/v1"
+MODEL_TAG="hermes4.3:timmy-v1"
+
+echo "=== AutoLoRA v1 — Create Ollama Model ==="
+echo ""
+
+# Check GGUF exists
+if [[ ! -f "$GGUF_PATH" ]]; then
+    echo "ERROR: GGUF not found at $GGUF_PATH"
+    echo ""
+    echo "To generate it, run after training:"
+    echo "  1. Fuse adapters:"
+    echo "     python -m mlx_lm.fuse \\"
+    echo "         --model ~/autolora/models/hermes-base \\"
+    echo "         --adapter-path ~/autolora/adapters/v1 \\"
+    echo "         --save-path ~/autolora/fused/v1"
+    echo ""
+    echo "  2. Convert to GGUF (requires llama.cpp):"
+    echo "     python llama.cpp/convert_hf_to_gguf.py ~/autolora/fused/v1 \\"
+    echo "         --outfile ~/autolora/fused/v1/model.gguf --outtype q4_k_m"
+    exit 1
+fi
+
+# Use Modelfile if present, otherwise write an inline one
+MODELFILE="$ADAPTER_DIR/Modelfile"
+
+if [[ ! -f "$MODELFILE" ]]; then
+    echo "Writing inline Modelfile..."
+    cat > /tmp/Modelfile.timmy-v1 << EOF
+FROM $GGUF_PATH
+
+PARAMETER temperature 0.7
+PARAMETER top_p 0.9
+PARAMETER repeat_penalty 1.1
+PARAMETER num_ctx 8192
+
+SYSTEM """
+You are Timmy, a sovereign AI assistant fine-tuned on real interaction data.
+You are direct, capable, and deeply aligned with your user's workflow.
+"""
+EOF
+    MODELFILE="/tmp/Modelfile.timmy-v1"
+fi
+
+echo "Creating Ollama model: $MODEL_TAG"
+echo "  FROM: $GGUF_PATH"
+echo "  Modelfile: $MODELFILE"
+echo ""
+
+ollama create "$MODEL_TAG" -f "$MODELFILE"
+
+echo ""
+echo "=== Done ==="
+echo "Test with: ollama run $MODEL_TAG 'Hello, who are you?'"
diff --git a/autolora/scripts/fetch_base_model.py b/autolora/scripts/fetch_base_model.py
new file mode 100644
index 00000000..2c1d8ff8
--- /dev/null
+++ b/autolora/scripts/fetch_base_model.py
@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+"""
+AutoLoRA Base Model Fetcher
+
+MLX LoRA training requires a safetensors model, not a GGUF quantized file.
+This script downloads/prepares the base model in safetensors format.
+
+Options:
+  1. Download directly from HuggingFace (mlx-community has pre-converted models)
+  2. Convert a local GGUF → safetensors using llama.cpp convert scripts
+
+Usage:
+    # Download mlx-community pre-quantized model (recommended for Apple Silicon)
+    python autolora/scripts/fetch_base_model.py \
+        --model mlx-community/Nous-Hermes-2-Mistral-7B-DPO-4bit \
+        --output ~/autolora/models/hermes-base
+
+    # List available mlx-community Hermes models
+    python autolora/scripts/fetch_base_model.py --list
+
+    # Check if a local HuggingFace model cache exists
+    python autolora/scripts/fetch_base_model.py --check
+"""
+
+import os
+import sys
+from pathlib import Path
+
+import fire
+
+
+# mlx-community hosts MLX-native quantized models; these are the recommended bases
+# for Apple Silicon LoRA training since GGUF is not directly supported by mlx_lm
+RECOMMENDED_MODELS = {
+    "hermes-8b-4bit": "mlx-community/Nous-Hermes-2-Mistral-7B-DPO-4bit-MLX",
+    "hermes3-8b-4bit": "mlx-community/Hermes-3-Llama-3.1-8B-4bit",
+    "hermes3-8b-8bit": "mlx-community/Hermes-3-Llama-3.1-8B-8bit",
+    # 70B / 36B class — requires ~70GB+ disk and substantial memory
+    # "hermes3-70b-4bit": "mlx-community/Hermes-3-Llama-3.1-70B-4bit",
+}
+
+DEFAULT_MODEL = "mlx-community/Hermes-3-Llama-3.1-8B-4bit"
+DEFAULT_OUTPUT = Path("~/autolora/models/hermes-base").expanduser()
+
+
+def list_models() -> None:
+    """Print recommended MLX model aliases and their HuggingFace IDs."""
+    print("Recommended MLX-native models for LoRA training:\n")
+    for alias, hf_id in RECOMMENDED_MODELS.items():
+        print(f"  {alias:<25}  {hf_id}")
+    print()
+    print("Usage: python fetch_base_model.py --model <hf_id> --output <path>")
+
+
+def check() -> None:
+    """Check HuggingFace cache for known Hermes models."""
+    cache_root = Path(os.getenv("HF_HOME", Path.home() / ".cache" / "huggingface" / "hub"))
+    print(f"Checking HF cache at: {cache_root}\n")
+
+    found = False
+    for alias, hf_id in RECOMMENDED_MODELS.items():
+        # HF hub stores models as models--<owner>--<name>
+        cache_dir = cache_root / ("models--" + hf_id.replace("/", "--"))
+        status = "FOUND" if cache_dir.exists() else "not cached"
+        print(f"  [{status}]  {hf_id}")
+        if cache_dir.exists():
+            found = True
+
+    print()
+    local_path = DEFAULT_OUTPUT
+    if local_path.exists():
+        print(f"  [FOUND]  Local model at {local_path}")
+    else:
+        print(f"  [missing] {local_path}")
+
+    if not found:
+        print("\nNo cached models found. Run with --model to download.")
+
+
+def fetch(
+    model: str = DEFAULT_MODEL,
+    output: str | None = None,
+    trust_remote_code: bool = False,
+) -> None:
+    """
+    Download a safetensors model suitable for MLX LoRA training.
+
+    Uses mlx_lm.convert if needed, or snapshot_download for pre-converted models.
+
+    Args:
+        model:              HuggingFace model ID (default: Hermes-3-Llama-3.1-8B-4bit).
+        output:             Local path to save / link the model.
+        trust_remote_code:  Pass --trust-remote-code to mlx_lm.convert if needed.
+    """
+    try:
+        from huggingface_hub import snapshot_download
+    except ImportError:
+        print("ERROR: huggingface_hub not installed. Run: pip install huggingface_hub", file=sys.stderr)
+        sys.exit(1)
+
+    out_path = Path(output).expanduser() if output else DEFAULT_OUTPUT
+    out_path.mkdir(parents=True, exist_ok=True)
+
+    print(f"Downloading {model} → {out_path}")
+    print("(This may take a while for large models)")
+    print()
+
+    local_dir = snapshot_download(
+        repo_id=model,
+        local_dir=str(out_path),
+        ignore_patterns=["*.bin", "*.pt"],  # prefer safetensors
+    )
+
+    print(f"\nModel downloaded to: {local_dir}")
+    print("\nVerifying safetensors files …")
+
+    st_files = list(Path(local_dir).glob("*.safetensors"))
+    if st_files:
+        print(f"  OK — found {len(st_files)} safetensors file(s)")
+    else:
+        print("  WARN: no .safetensors files found. The model may be in a different format.")
+        print("  MLX LoRA training requires safetensors. Check mlx-community for a converted version.")
+
+    print("\nNext: update autolora/config/v1.yaml `model:` field to:")
+    print(f"  model: {local_dir}")
+
+
+def main(
+    model: str = DEFAULT_MODEL,
+    output: str | None = None,
+    list: bool = False,
+    check: bool = False,
+    trust_remote_code: bool = False,
+) -> None:
+    if list:
+        list_models()
+    elif check:
+        check()
+    else:
+        fetch(model=model, output=output, trust_remote_code=trust_remote_code)
+
+
+if __name__ == "__main__":
+    fire.Fire(main)
diff --git a/autolora/scripts/fuse_and_convert.sh b/autolora/scripts/fuse_and_convert.sh
new file mode 100644
index 00000000..f26946cd
--- /dev/null
+++ b/autolora/scripts/fuse_and_convert.sh
@@ -0,0 +1,87 @@
+#!/usr/bin/env bash
+# AutoLoRA v1 — Fuse LoRA adapters + convert to GGUF for Ollama
+#
+# This bridges the gap between MLX adapter weights and an Ollama-loadable GGUF.
+#
+# Prerequisites:
+#   - Training complete: ~/autolora/adapters/v1/ exists with adapter weights
+#   - Base model at ~/autolora/models/hermes-base (safetensors)
+#   - mlx-lm installed: pip install mlx-lm
+#   - llama.cpp cloned somewhere (for GGUF conversion)
+#
+# Usage:
+#   bash autolora/scripts/fuse_and_convert.sh
+#   bash autolora/scripts/fuse_and_convert.sh --llamacpp /path/to/llama.cpp
+
+set -euo pipefail
+
+BASE_MODEL="${BASE_MODEL:-$HOME/autolora/models/hermes-base}"
+ADAPTER_PATH="${ADAPTER_PATH:-$HOME/autolora/adapters/v1}"
+FUSED_PATH="${FUSED_PATH:-$HOME/autolora/fused/v1}"
+GGUF_QUANT="${GGUF_QUANT:-q4_k_m}"
+LLAMACPP="${1:-${LLAMACPP:-$HOME/llama.cpp}}"
+
+echo "=== AutoLoRA v1 — Fuse + Convert ==="
+echo ""
+echo "  Base model:   $BASE_MODEL"
+echo "  Adapter path: $ADAPTER_PATH"
+echo "  Fused output: $FUSED_PATH"
+echo "  GGUF quant:   $GGUF_QUANT"
+echo ""
+
+# Step 1: Fuse with mlx_lm.fuse
+echo "--- Step 1: Fuse LoRA adapters into base model ---"
+if [[ ! -d "$BASE_MODEL" ]]; then
+    echo "ERROR: Base model not found at $BASE_MODEL"
+    echo "Run: python autolora/scripts/fetch_base_model.py"
+    exit 1
+fi
+
+if [[ ! -d "$ADAPTER_PATH" ]]; then
+    echo "ERROR: Adapter path not found at $ADAPTER_PATH"
+    echo "Run: python autolora/train_mlx.py"
+    exit 1
+fi
+
+mkdir -p "$FUSED_PATH"
+
+python -m mlx_lm.fuse \
+    --model "$BASE_MODEL" \
+    --adapter-path "$ADAPTER_PATH" \
+    --save-path "$FUSED_PATH"
+
+echo "Fused model saved to: $FUSED_PATH"
+echo ""
+
+# Step 2: Convert fused safetensors → GGUF
+echo "--- Step 2: Convert fused model to GGUF ---"
+GGUF_OUT="$FUSED_PATH/model.gguf"
+
+if [[ ! -d "$LLAMACPP" ]]; then
+    echo "ERROR: llama.cpp not found at $LLAMACPP"
+    echo ""
+    echo "To install llama.cpp:"
+    echo "  git clone https://github.com/ggml-org/llama.cpp.git ~/llama.cpp"
+    echo "  cd ~/llama.cpp && pip install -r requirements.txt"
+    echo ""
+    echo "Then re-run with: LLAMACPP=~/llama.cpp bash autolora/scripts/fuse_and_convert.sh"
+    exit 1
+fi
+
+CONVERT_SCRIPT="$LLAMACPP/convert_hf_to_gguf.py"
+if [[ ! -f "$CONVERT_SCRIPT" ]]; then
+    # older llama.cpp used a different name
+    CONVERT_SCRIPT="$LLAMACPP/convert.py"
+fi
+
+python "$CONVERT_SCRIPT" "$FUSED_PATH" \
+    --outfile "$GGUF_OUT" \
+    --outtype "$GGUF_QUANT"
+
+echo ""
+echo "GGUF saved to: $GGUF_OUT"
+echo ""
+echo "=== Conversion complete ==="
+echo ""
+echo "Next: create the Ollama model:"
+echo "  bash autolora/scripts/create_ollama_model.sh $GGUF_OUT"
diff --git a/autolora/train_mlx.py b/autolora/train_mlx.py
new file mode 100644
index 00000000..b8aedb09
--- /dev/null
+++ b/autolora/train_mlx.py
@@ -0,0 +1,352 @@
+#!/usr/bin/env python3
+"""
+AutoLoRA v1 — MLX QLoRA Training Launcher
+
+Orchestrates the full MLX LoRA training pipeline:
+  1. Validate prerequisites (mlx-lm installed, data exists, model available)
+  2. Build the mlx_lm.lora CLI command from config/v1.yaml
+  3. Launch training as a subprocess, tee output to training_logs/v1/
+  4. On completion, fuse adapters and scaffold the Ollama Modelfile
+
+Usage:
+    # Full training run (data + model must already exist)
+    python autolora/train_mlx.py
+
+    # Override config values on the CLI
+    python autolora/train_mlx.py --lora_rank 8 --iters 500
+
+    # Dry run — print the mlx_lm.lora command without executing
+    python autolora/train_mlx.py --dry_run
+
+    # Skip preflight checks
+    python autolora/train_mlx.py --no_check
+"""
+
+import json
+import os
+import shutil
+import subprocess
+import sys
+import time
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+import fire
+import yaml
+
+# ---------------------------------------------------------------------------
+# Paths
+# ---------------------------------------------------------------------------
+
+REPO_ROOT = Path(__file__).parent.parent
+AUTOLORA_DIR = Path(__file__).parent
+CONFIG_PATH = AUTOLORA_DIR / "config" / "v1.yaml"
+LOG_DIR = AUTOLORA_DIR / "training_logs" / "v1"
+
+# Default data / model / adapter locations (all under ~/autolora/)
+AUTOLORA_HOME = Path("~/autolora").expanduser()
+DATA_DIR = AUTOLORA_HOME / "data"
+MODEL_DIR = AUTOLORA_HOME / "models" / "hermes-base"
+ADAPTER_DIR = AUTOLORA_HOME / "adapters" / "v1"
+FUSED_DIR = AUTOLORA_HOME / "fused" / "v1"
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _load_config(path: Path = CONFIG_PATH) -> dict[str, Any]:
+    with path.open() as f:
+        return yaml.safe_load(f)
+
+
+def _check_mlx_lm() -> bool:
+    """Return True if mlx-lm is importable."""
+    try:
+        import mlx_lm  # noqa: F401
+        return True
+    except ImportError:
+        return False
+
+
+def _check_data(data_dir: Path) -> tuple[Path | None, Path | None]:
+    """Return (train_path, valid_path) if MLX-format data exists."""
+    train = data_dir / "train.jsonl"
+    valid = data_dir / "valid.jsonl"
+    return (train if train.exists() else None, valid if valid.exists() else None)
+
+
+def _check_model(model_dir: Path, config_model: str) -> Path | None:
+    """
+    Resolve the model path:
+      1. If config says a local path and it exists, use it.
+      2. If ~/autolora/models/hermes-base exists, use it.
+      3. Fall through — caller will warn.
+    """
+    if model_dir.exists():
+        return model_dir
+    config_path = Path(config_model).expanduser()
+    if config_path.exists():
+        return config_path
+    return None
+
+
+def _timestamp() -> str:
+    return datetime.now().strftime("%Y%m%d_%H%M%S")
+
+
+def _write_log_header(log_path: Path, cmd: list[str], cfg: dict) -> None:
+    with log_path.open("w") as f:
+        f.write(f"AutoLoRA v1 Training Log\n")
+        f.write(f"Started: {datetime.now().isoformat()}\n")
+        f.write(f"Command: {' '.join(cmd)}\n")
+        f.write(f"Config:\n{yaml.dump(cfg, default_flow_style=False)}\n")
+        f.write("=" * 80 + "\n\n")
+
+
+def _scaffold_modelfile(adapter_path: Path, base_model_tag: str = "hermes4.3") -> Path:
+    """Write an Ollama Modelfile for hermes4.3:timmy-v1."""
+    modelfile_path = adapter_path / "Modelfile"
+    content = f"""# Ollama Modelfile — hermes4.3:timmy-v1
+# Generated by autolora/train_mlx.py
+#
+# To build:
+#   ollama create hermes4.3:timmy-v1 -f {modelfile_path}
+#
+# To run:
+#   ollama run hermes4.3:timmy-v1
+
+FROM {base_model_tag}
+
+# LoRA adapter (fused weights path)
+# Note: Ollama currently loads GGUF models. To use this adapter:
+#   1. Fuse the LoRA into the base model:
+#        python -m mlx_lm.fuse --model <base> --adapter-path {adapter_path} --save-path {FUSED_DIR}
+#   2. Convert fused model to GGUF:
+#        python llama.cpp/convert_hf_to_gguf.py {FUSED_DIR} --outfile {FUSED_DIR}/model.gguf --outtype q4_k_m
+#   3. Update FROM below to the GGUF path and rebuild:
+#        FROM {FUSED_DIR}/model.gguf
+
+PARAMETER temperature 0.7
+PARAMETER top_p 0.9
+PARAMETER repeat_penalty 1.1
+PARAMETER num_ctx 8192
+
+SYSTEM \"""
+You are Timmy, a sovereign AI assistant fine-tuned on real interaction data.
+You are direct, capable, and deeply aligned with your user's workflow.
+\"""
+"""
+    modelfile_path.write_text(content)
+    return modelfile_path
+
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+
+
+def train(
+    config: str = str(CONFIG_PATH),
+    train_data: str | None = None,
+    valid_data: str | None = None,
+    model: str | None = None,
+    adapter_path: str | None = None,
+    lora_rank: int | None = None,
+    lora_layers: int | None = None,
+    iters: int | None = None,
+    batch_size: int | None = None,
+    learning_rate: float | None = None,
+    max_seq_length: int | None = None,
+    dry_run: bool = False,
+    no_check: bool = False,
+) -> None:
+    """
+    Launch MLX QLoRA training.
+
+    CLI overrides take precedence over config/v1.yaml values.
+    """
+    # Load config
+    cfg_path = Path(config).expanduser()
+    cfg = _load_config(cfg_path)
+
+    # Apply CLI overrides
+    if lora_rank is not None:
+        cfg["lora_rank"] = lora_rank
+    if lora_layers is not None:
+        cfg["lora_layers"] = lora_layers
+    if iters is not None:
+        cfg["iters"] = iters
+    if batch_size is not None:
+        cfg["batch_size"] = batch_size
+    if learning_rate is not None:
+        cfg["learning_rate"] = learning_rate
+    if max_seq_length is not None:
+        cfg["max_seq_length"] = max_seq_length
+
+    # Resolve paths
+    resolved_adapter = Path(adapter_path).expanduser() if adapter_path else ADAPTER_DIR
+    resolved_model = Path(model).expanduser() if model else _check_model(MODEL_DIR, cfg.get("model", ""))
+    resolved_train = Path(train_data).expanduser() if train_data else DATA_DIR / "train.jsonl"
+    resolved_valid = Path(valid_data).expanduser() if valid_data else DATA_DIR / "valid.jsonl"
+
+    # ---------------------------------------------------------------------------
+    # Preflight checks
+    # ---------------------------------------------------------------------------
+    if not no_check:
+        print("=== AutoLoRA v1 Preflight ===\n")
+        ok = True
+
+        # mlx-lm
+        has_mlx = _check_mlx_lm()
+        print(f"  mlx-lm installed:    {'OK' if has_mlx else 'MISSING — run: pip install mlx-lm'}")
+        if not has_mlx:
+            ok = False
+
+        # data
+        train_ok = resolved_train.exists()
+        valid_ok = resolved_valid.exists()
+        print(f"  train.jsonl:         {'OK' if train_ok else f'MISSING — {resolved_train}'}")
+        print(f"  valid.jsonl:         {'OK' if valid_ok else f'MISSING — {resolved_valid}'}")
+        if not train_ok:
+            ok = False
+            print("\n  To generate data, run:")
+            print("    python autolora/scripts/convert_data.py \\")
+            print("        --input ~/autolora/data/compressed_train.jsonl \\")
+            print("        --output_dir ~/autolora/data/")
+
+        # model
+        model_ok = resolved_model is not None and resolved_model.exists()
+        print(f"  base model:          {'OK at ' + str(resolved_model) if model_ok else 'MISSING — see autolora/scripts/fetch_base_model.py'}")
+        if not model_ok:
+            ok = False
+            print("\n  To fetch model, run:")
+            print("    python autolora/scripts/fetch_base_model.py")
+
+        print()
+        if not ok and not dry_run:
+            print("Preflight FAILED. Fix issues above or use --no_check to skip.\n")
+            sys.exit(1)
+        elif ok:
+            print("Preflight OK.\n")
+
+    # ---------------------------------------------------------------------------
+    # Build command
+    # ---------------------------------------------------------------------------
+    resolved_adapter.mkdir(parents=True, exist_ok=True)
+    LOG_DIR.mkdir(parents=True, exist_ok=True)
+
+    cmd = [
+        sys.executable, "-m", "mlx_lm.lora",
+        "--model", str(resolved_model or cfg.get("model", "")),
+        "--train",
+        "--data", str(resolved_train.parent),   # mlx_lm expects directory; train/valid.jsonl inside
+        "--adapter-path", str(resolved_adapter),
+        "--iters", str(cfg.get("iters", 1000)),
+        "--batch-size", str(cfg.get("batch_size", 4)),
+        "--num-layers", str(cfg.get("lora_layers", 16)),
+        "--rank", str(cfg.get("lora_rank", 16)),
+        "--learning-rate", str(cfg.get("learning_rate", 2e-4)),
+        "--max-seq-length", str(cfg.get("max_seq_length", 8192)),
+        "--steps-per-eval", str(cfg.get("steps_per_eval", 100)),
+        "--val-batches", str(cfg.get("val_batches", 25)),
+        "--save-every", str(cfg.get("save_every", 200)),
+        "--grad-checkpoint",
+    ]
+
+    # Log file
+    ts = _timestamp()
+    log_path = LOG_DIR / f"run_{ts}.log"
+
+    print("=== Training Command ===")
+    print(" ".join(cmd))
+    print()
+    print(f"Adapter output: {resolved_adapter}")
+    print(f"Log:            {log_path}")
+    print()
+
+    if dry_run:
+        print("[DRY RUN] — not executing. Exiting.")
+        return
+
+    _write_log_header(log_path, cmd, cfg)
+
+    # ---------------------------------------------------------------------------
+    # Launch
+    # ---------------------------------------------------------------------------
+    print("=== Starting Training ===\n")
+    start = time.time()
+
+    with log_path.open("a") as log_f:
+        proc = subprocess.Popen(
+            cmd,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.STDOUT,
+            text=True,
+            bufsize=1,
+        )
+
+        for line in proc.stdout:
+            print(line, end="")
+            log_f.write(line)
+            log_f.flush()
+
+        proc.wait()
+
+    elapsed = time.time() - start
+    status = "SUCCESS" if proc.returncode == 0 else f"FAILED (exit {proc.returncode})"
+
+    summary = {
+        "status": status,
+        "returncode": proc.returncode,
+        "elapsed_seconds": round(elapsed, 1),
+        "adapter_path": str(resolved_adapter),
+        "log": str(log_path),
+        "timestamp": datetime.now().isoformat(),
+        "config": cfg,
+    }
+
+    summary_path = LOG_DIR / f"run_{ts}_summary.json"
+    summary_path.write_text(json.dumps(summary, indent=2))
+
+    print(f"\n{'=' * 60}")
+    print(f"Training {status}  ({elapsed/60:.1f} min)")
+    print(f"Log:     {log_path}")
+    print(f"Summary: {summary_path}")
+
+    if proc.returncode != 0:
+        print("\nTraining failed. Check log above for errors.")
+        print("Common fixes:")
+        print("  OOM  → reduce --lora_rank 8 or --batch_size 2 or --max_seq_length 4096")
+        print("  Data → ensure ~/autolora/data/{train,valid}.jsonl exist with `messages` key")
+        sys.exit(proc.returncode)
+
+    # ---------------------------------------------------------------------------
+    # Post-training: scaffold Modelfile
+    # ---------------------------------------------------------------------------
+    print("\n=== Post-Training ===\n")
+    modelfile = _scaffold_modelfile(resolved_adapter)
+    print(f"Wrote Modelfile → {modelfile}")
+    print()
+    print("Next steps:")
+    print("  1. Fuse adapters into base model:")
+    print(f"       python -m mlx_lm.fuse \\")
+    print(f"           --model {resolved_model} \\")
+    print(f"           --adapter-path {resolved_adapter} \\")
+    print(f"           --save-path {FUSED_DIR}")
+    print()
+    print("  2. Convert fused model to GGUF (requires llama.cpp):")
+    print(f"       python llama.cpp/convert_hf_to_gguf.py {FUSED_DIR} \\")
+    print(f"           --outfile {FUSED_DIR}/model.gguf --outtype q4_k_m")
+    print()
+    print("  3. Create Ollama model:")
+    print(f"       ollama create hermes4.3:timmy-v1 -f {modelfile}")
+    print()
+    print("  4. Test:")
+    print("       ollama run hermes4.3:timmy-v1 'Hello, who are you?'")
+
+
+if __name__ == "__main__":
+    fire.Fire(train)
diff --git a/autolora/training_logs/v1/.gitkeep b/autolora/training_logs/v1/.gitkeep
new file mode 100644
index 00000000..e69de29b