Prompt:
## Summary
First LoRA training run. Use MLX framework for Apple Silicon-native QLoRA training on compressed conversation data.

## Dependencies
- #23 (compressed training data ready)
- #25 (Hermes 4.3 base model in Ollama)
- #21 (baseline eval completed)

## Requirements
1. Set up MLX-LM for LoRA training (`pip install mlx-lm`)
2. Convert compressed JSONL to MLX training format
3. Training config:
   - Base: Hermes 4.3 36B (may need safetensors, not GGUF — investigate)
   - Method: QLoRA (4-bit base + LoRA adapters)
   - Rank: r=16 (start conservative)
   - Learning rate: 2e-4
   - Epochs: 2-3
   - Target modules: q_proj, v_proj (standard)
4. Output: adapter weights to `~/autolora/adapters/v1/`
5. Create Ollama model: `hermes4.3:timmy-v1`

## Open Questions
- MLX LoRA may need safetensors base, not GGUF — need conversion step?
- Memory budget: 36GB unified, Q4 base ~20GB, need ~12GB for training overhead
- May need to use smaller rank (r=8) if memory is tight

## Acceptance Criteria
- [ ] Training completes without OOM
- [ ] Adapter weights saved to `~/autolora/adapters/v1/`
- [ ] `hermes4.3:timmy-v1` loadable in Ollama
- [ ] Training log committed to `autolora/training_logs/v1/`

## Notes
- This is the first attempt. Expect to iterate on hyperparams.
- If MLX doesn't work cleanly, fallback is Unsloth or TRL via Python.
- Google Cloud $100 credits available for future faster training, but local first.

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

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

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

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

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

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

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

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