Prompt:
## Summary
Build `autolora/eval/run_eval.py` — an automated evaluation harness that replays held-out test sessions through any Ollama model and scores the outputs against the original Claude responses.

## Requirements
### run_eval.py
- Takes: model name (Ollama), test_set.jsonl path, output path
- For each test session:
  - Feed system prompt + user messages + tool outputs to the model
  - Capture model responses at each turn
  - Compare against original Claude responses
- Score each session on:
  - **Tool selection accuracy**: did it pick the same tools? (exact match %)
  - **Response length ratio**: model response length / original length (target: 0.8-1.2)
  - **Format compliance**: plain text, no markdown headers, terminal-friendly (regex checks)
  - **Tone match**: brevity, directness (heuristic or embedding similarity)
  - **Task completion**: did it reach equivalent conclusion? (final-turn similarity)
- Output: `scores.json` with per-session and aggregate metrics

### compare.py
- Takes two score files (baseline vs candidate)
- Outputs a diff report: which metrics improved, degraded, unchanged
- Prints a clear PASS/FAIL/MIXED verdict
- Generates `comparison_report.md`

## Acceptance Criteria
- [ ] `run_eval.py` runs end-to-end against Ollama models
- [ ] Scoring is deterministic and reproducible
- [ ] `compare.py` produces human-readable diff report
- [ ] Both scripts committed to `autolora/eval/`
- [ ] Scripts work with `hermes4.3:base` as first target

## Notes
- This harness runs BEFORE and AFTER every training cycle. It's the gatekeeper.
- Must handle sessions where tool calls are present (mock or skip tool execution)
- Keep it simple first — embedding similarity can be v2.

Chosen response:
diff --git a/autolora/__init__.py b/autolora/__init__.py
new file mode 100644
index 00000000..a32113ad
--- /dev/null
+++ b/autolora/__init__.py
@@ -0,0 +1 @@
+# AutoLoRA — Automated LoRA training pipeline for Hermes Agent
diff --git a/autolora/eval/__init__.py b/autolora/eval/__init__.py
new file mode 100644
index 00000000..776bbafd
--- /dev/null
+++ b/autolora/eval/__init__.py
@@ -0,0 +1 @@
+# AutoLoRA evaluation harness
diff --git a/autolora/eval/compare.py b/autolora/eval/compare.py
new file mode 100644
index 00000000..76b79c8e
--- /dev/null
+++ b/autolora/eval/compare.py
@@ -0,0 +1,307 @@
+#!/usr/bin/env python3
+"""
+AutoLoRA Score Comparator
+
+Compares two score files (baseline vs candidate) produced by run_eval.py.
+Outputs a diff report and prints a PASS/FAIL/MIXED verdict.
+
+Usage:
+    python compare.py --baseline baseline_scores.json --candidate candidate_scores.json
+    python compare.py --baseline baseline_scores.json --candidate candidate_scores.json --output comparison_report.md
+"""
+
+import json
+import sys
+import argparse
+from pathlib import Path
+from typing import Any
+
+# ---------------------------------------------------------------------------
+# Metric metadata
+# ---------------------------------------------------------------------------
+
+# Higher is better for all metrics except response_length_ratio (target 0.8–1.2)
+HIGHER_IS_BETTER = {
+    "tool_selection_accuracy": True,
+    "response_length_ratio": None,  # target range, not directional
+    "response_length_score": True,
+    "format_compliance": True,
+    "tone_match": True,
+    "task_completion": True,
+    "aggregate": True,
+}
+
+# Threshold for "significant" change (absolute)
+SIGNIFICANCE_THRESHOLD = 0.02
+
+
+def load_scores(path: str) -> dict[str, Any]:
+    p = Path(path)
+    if not p.exists():
+        print(f"ERROR: Score file not found: {path}", file=sys.stderr)
+        sys.exit(1)
+    with open(p, "r", encoding="utf-8") as f:
+        return json.load(f)
+
+
+# ---------------------------------------------------------------------------
+# Comparison logic
+# ---------------------------------------------------------------------------
+
+def classify_change(metric: str, delta: float) -> str:
+    """Return 'improved', 'degraded', or 'unchanged' for a metric delta."""
+    if abs(delta) < SIGNIFICANCE_THRESHOLD:
+        return "unchanged"
+
+    higher_better = HIGHER_IS_BETTER.get(metric)
+
+    if higher_better is None:
+        # response_length_ratio: closer to 1.0 is better — but we track
+        # response_length_score (which IS higher=better) for pass/fail.
+        # For the raw ratio, treat any movement as neutral here.
+        return "unchanged"
+
+    if higher_better:
+        return "improved" if delta > 0 else "degraded"
+    else:
+        return "improved" if delta < 0 else "degraded"
+
+
+def compare_scores(baseline: dict, candidate: dict) -> dict[str, Any]:
+    """Produce a structured comparison result."""
+    base_agg = baseline.get("aggregate_scores", {})
+    cand_agg = candidate.get("aggregate_scores", {})
+
+    all_metrics = sorted(set(base_agg) | set(cand_agg))
+
+    metric_results: list[dict] = []
+    improved_count = 0
+    degraded_count = 0
+    unchanged_count = 0
+
+    for metric in all_metrics:
+        base_val = base_agg.get(metric)
+        cand_val = cand_agg.get(metric)
+
+        if base_val is None or cand_val is None:
+            status = "missing"
+        else:
+            delta = cand_val - base_val
+            status = classify_change(metric, delta)
+
+        if status == "improved":
+            improved_count += 1
+        elif status == "degraded":
+            degraded_count += 1
+        elif status == "unchanged":
+            unchanged_count += 1
+
+        metric_results.append({
+            "metric": metric,
+            "baseline": base_val,
+            "candidate": cand_val,
+            "delta": round(cand_val - base_val, 4) if (base_val is not None and cand_val is not None) else None,
+            "status": status,
+        })
+
+    # Verdict logic
+    # PASS: no degraded metrics
+    # MIXED: some improved, some degraded
+    # FAIL: any degraded (especially aggregate)
+    agg_status = next(
+        (r["status"] for r in metric_results if r["metric"] == "aggregate"), "unchanged"
+    )
+
+    if degraded_count == 0:
+        verdict = "PASS"
+    elif improved_count > 0:
+        verdict = "MIXED"
+    else:
+        verdict = "FAIL"
+
+    # Override to FAIL if the aggregate score itself degraded significantly
+    if agg_status == "degraded":
+        verdict = "FAIL"
+
+    return {
+        "baseline_model": baseline.get("model", "unknown"),
+        "candidate_model": candidate.get("model", "unknown"),
+        "baseline_sessions": baseline.get("total_sessions", "?"),
+        "candidate_sessions": candidate.get("total_sessions", "?"),
+        "metrics": metric_results,
+        "summary": {
+            "improved": improved_count,
+            "degraded": degraded_count,
+            "unchanged": unchanged_count,
+        },
+        "verdict": verdict,
+    }
+
+
+# ---------------------------------------------------------------------------
+# Report generation
+# ---------------------------------------------------------------------------
+
+def _arrow(status: str) -> str:
+    return {"improved": "↑", "degraded": "↓", "unchanged": "→", "missing": "?"}.get(status, "?")
+
+
+def _delta_str(delta: float | None) -> str:
+    if delta is None:
+        return "N/A"
+    sign = "+" if delta >= 0 else ""
+    return f"{sign}{delta:.4f}"
+
+
+def build_report(comparison: dict) -> str:
+    lines: list[str] = []
+
+    lines.append("# AutoLoRA Eval Comparison Report")
+    lines.append("")
+    lines.append(f"**Baseline model:** `{comparison['baseline_model']}`  ")
+    lines.append(f"**Candidate model:** `{comparison['candidate_model']}`  ")
+    lines.append(f"**Sessions (baseline / candidate):** {comparison['baseline_sessions']} / {comparison['candidate_sessions']}")
+    lines.append("")
+
+    verdict = comparison["verdict"]
+    verdict_emoji = {"PASS": "✅", "MIXED": "⚠", "FAIL": "❌"}.get(verdict, "?")
+    lines.append(f"## Verdict: {verdict_emoji} {verdict}")
+    lines.append("")
+
+    summary = comparison["summary"]
+    lines.append(
+        f"**{summary['improved']} improved** | "
+        f"**{summary['degraded']} degraded** | "
+        f"**{summary['unchanged']} unchanged**"
+    )
+    lines.append("")
+
+    lines.append("## Metric Breakdown")
+    lines.append("")
+    lines.append("| Metric | Baseline | Candidate | Delta | Status |")
+    lines.append("|--------|----------|-----------|-------|--------|")
+
+    for m in comparison["metrics"]:
+        base_str = f"{m['baseline']:.4f}" if m["baseline"] is not None else "—"
+        cand_str = f"{m['candidate']:.4f}" if m["candidate"] is not None else "—"
+        arrow = _arrow(m["status"])
+        delta_s = _delta_str(m["delta"])
+        lines.append(
+            f"| {m['metric']} | {base_str} | {cand_str} | {delta_s} | {arrow} {m['status'].upper()} |"
+        )
+
+    lines.append("")
+    lines.append("## Details")
+    lines.append("")
+
+    improved = [m for m in comparison["metrics"] if m["status"] == "improved"]
+    degraded = [m for m in comparison["metrics"] if m["status"] == "degraded"]
+
+    if improved:
+        lines.append("### Improved metrics")
+        for m in improved:
+            lines.append(f"- **{m['metric']}**: {m['baseline']:.4f} → {m['candidate']:.4f} ({_delta_str(m['delta'])})")
+        lines.append("")
+
+    if degraded:
+        lines.append("### Degraded metrics")
+        for m in degraded:
+            lines.append(f"- **{m['metric']}**: {m['baseline']:.4f} → {m['candidate']:.4f} ({_delta_str(m['delta'])})")
+        lines.append("")
+
+    lines.append("---")
+    lines.append("_Generated by AutoLoRA `compare.py`_")
+    lines.append("")
+
+    return "\n".join(lines)
+
+
+def print_terminal_report(comparison: dict) -> None:
+    """Print a compact terminal-friendly summary."""
+    verdict = comparison["verdict"]
+    print(f"\n{'='*60}")
+    print(f"VERDICT: {verdict}")
+    print(f"{'='*60}")
+    print(f"Baseline: {comparison['baseline_model']} ({comparison['baseline_sessions']} sessions)")
+    print(f"Candidate: {comparison['candidate_model']} ({comparison['candidate_sessions']} sessions)")
+    print()
+
+    col_w = 30
+    print(f"{'METRIC':<{col_w}} {'BASELINE':>10} {'CANDIDATE':>10} {'DELTA':>10} STATUS")
+    print("-" * 72)
+    for m in comparison["metrics"]:
+        base_s = f"{m['baseline']:.4f}" if m["baseline"] is not None else "    N/A"
+        cand_s = f"{m['candidate']:.4f}" if m["candidate"] is not None else "    N/A"
+        delta_s = _delta_str(m["delta"]).rjust(10)
+        arrow = _arrow(m["status"])
+        print(f"{m['metric']:<{col_w}} {base_s:>10} {cand_s:>10} {delta_s} {arrow} {m['status'].upper()}")
+
+    summary = comparison["summary"]
+    print()
+    print(
+        f"Summary: {summary['improved']} improved, "
+        f"{summary['degraded']} degraded, "
+        f"{summary['unchanged']} unchanged"
+    )
+    print(f"{'='*60}\n")
+
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+
+def compare(baseline: str, candidate: str, output: str = "comparison_report.md") -> None:
+    """Compare two score files and generate a diff report.
+
+    Args:
+        baseline: Path to baseline scores.json
+        candidate: Path to candidate scores.json
+        output: Path to write comparison_report.md (default: comparison_report.md)
+    """
+    base_data = load_scores(baseline)
+    cand_data = load_scores(candidate)
+
+    comparison = compare_scores(base_data, cand_data)
+
+    print_terminal_report(comparison)
+
+    report_md = build_report(comparison)
+    output_path = Path(output)
+    output_path.parent.mkdir(parents=True, exist_ok=True)
+    with open(output_path, "w", encoding="utf-8") as f:
+        f.write(report_md)
+
+    print(f"Comparison report written to: {output_path}")
+
+    # Exit code: 0=PASS, 1=MIXED, 2=FAIL
+    verdict = comparison["verdict"]
+    if verdict == "PASS":
+        sys.exit(0)
+    elif verdict == "MIXED":
+        sys.exit(1)
+    else:
+        sys.exit(2)
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(
+        description="Compare two AutoLoRA score files and produce a diff report."
+    )
+    parser.add_argument("--baseline", required=True, help="Path to baseline scores.json")
+    parser.add_argument("--candidate", required=True, help="Path to candidate scores.json")
+    parser.add_argument(
+        "--output",
+        default="comparison_report.md",
+        help="Output path for comparison_report.md (default: comparison_report.md)",
+    )
+    args = parser.parse_args()
+
+    compare(
+        baseline=args.baseline,
+        candidate=args.candidate,
+        output=args.output,
+    )
+
+
+if __name__ == "__main__":
+    main()
diff --git a/autolora/eval/run_eval.py b/autolora/eval/run_eval.py
new file mode 100644
index 00000000..93510c0f
--- /dev/null
+++ b/autolora/eval/run_eval.py
@@ -0,0 +1,490 @@
+#!/usr/bin/env python3
+"""
+AutoLoRA Replay Evaluation Harness
+
+Replays held-out test sessions through an Ollama model and scores outputs
+against the original Claude responses.
+
+Usage:
+    python run_eval.py --model hermes4.3:base --test_set test_set.jsonl --output scores.json
+    python run_eval.py --model hermes4.3:base --test_set test_set.jsonl  # output defaults to scores.json
+"""
+
+import json
+import re
+import sys
+import time
+import argparse
+import logging
+from pathlib import Path
+from typing import Any
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+# ---------------------------------------------------------------------------
+# Ollama client (no external deps beyond httpx which is in requirements.txt)
+# ---------------------------------------------------------------------------
+
+OLLAMA_BASE_URL = "http://localhost:11434"
+
+
+def ollama_chat(model: str, messages: list[dict], timeout: int = 120) -> str:
+    """Send a chat request to Ollama and return the assistant response text."""
+    payload = {
+        "model": model,
+        "messages": messages,
+        "stream": False,
+    }
+    try:
+        resp = httpx.post(
+            f"{OLLAMA_BASE_URL}/api/chat",
+            json=payload,
+            timeout=timeout,
+        )
+        resp.raise_for_status()
+        data = resp.json()
+        return data["message"]["content"]
+    except httpx.HTTPStatusError as e:
+        raise RuntimeError(f"Ollama HTTP error {e.response.status_code}: {e.response.text}") from e
+    except httpx.ConnectError as e:
+        raise RuntimeError(
+            f"Cannot connect to Ollama at {OLLAMA_BASE_URL}. Is Ollama running?"
+        ) from e
+
+
+# ---------------------------------------------------------------------------
+# Scoring functions
+# ---------------------------------------------------------------------------
+
+def score_tool_selection(original_turns: list[dict], model_turns: list[dict]) -> float:
+    """Exact-match % of tool calls between original and model turns.
+
+    Compares assistant turns only. A turn is a "tool call" if it contains
+    a tool_calls field (OpenAI format) or a content block with type=="tool_use"
+    (Anthropic format). Falls back to text-pattern heuristic.
+    """
+    def extract_tools(turn: dict) -> set[str]:
+        names: set[str] = set()
+        # OpenAI tool_calls format
+        if turn.get("tool_calls"):
+            for tc in turn["tool_calls"]:
+                if isinstance(tc, dict):
+                    fn = tc.get("function", {})
+                    if isinstance(fn, dict) and fn.get("name"):
+                        names.add(fn["name"])
+        # Anthropic content-block format
+        content = turn.get("content", "")
+        if isinstance(content, list):
+            for block in content:
+                if isinstance(block, dict) and block.get("type") == "tool_use":
+                    if block.get("name"):
+                        names.add(block["name"])
+        # Heuristic: text mentions tool invocations like <tool_name>(...) or "use X tool"
+        if isinstance(content, str):
+            found = re.findall(r'\b(\w+)\s*\(', content)
+            for name in found:
+                if len(name) > 3 and name.islower():
+                    names.add(name)
+        return names
+
+    orig_assistant = [t for t in original_turns if t.get("role") == "assistant"]
+    model_assistant = [t for t in model_turns if t.get("role") == "assistant"]
+
+    if not orig_assistant:
+        return 1.0  # No tool turns to compare — score as perfect
+
+    matches = 0
+    total = len(orig_assistant)
+    for orig_turn, model_turn in zip(orig_assistant, model_assistant):
+        orig_tools = extract_tools(orig_turn)
+        model_tools = extract_tools(model_turn)
+        if orig_tools == model_tools:
+            matches += 1
+
+    return matches / total
+
+
+def score_response_length_ratio(original_turns: list[dict], model_turns: list[dict]) -> float:
+    """Ratio of total model response length to total original response length.
+
+    Target: 0.8–1.2. Returns the raw ratio (not clamped).
+    """
+    def total_len(turns: list[dict]) -> int:
+        total = 0
+        for t in turns:
+            if t.get("role") == "assistant":
+                content = t.get("content", "")
+                if isinstance(content, str):
+                    total += len(content)
+                elif isinstance(content, list):
+                    for block in content:
+                        if isinstance(block, dict) and block.get("type") == "text":
+                            total += len(block.get("text", ""))
+        return total
+
+    orig_len = total_len(original_turns)
+    model_len = total_len(model_turns)
+
+    if orig_len == 0:
+        return 1.0
+    return model_len / orig_len
+
+
+def score_format_compliance(model_turns: list[dict]) -> float:
+    """Check format: plain text, no markdown headers, terminal-friendly.
+
+    Returns fraction of assistant turns that comply.
+    """
+    # Regexes for disallowed patterns
+    _markdown_header = re.compile(r'^\s{0,3}#{1,6}\s', re.MULTILINE)
+    _markdown_bold = re.compile(r'\*\*[^*]+\*\*')
+    _markdown_table = re.compile(r'^\s*\|', re.MULTILINE)
+    _ansi_escape = re.compile(r'\x1b\[[0-9;]*m')
+
+    assistant_turns = [t for t in model_turns if t.get("role") == "assistant"]
+    if not assistant_turns:
+        return 1.0
+
+    compliant = 0
+    for turn in assistant_turns:
+        content = turn.get("content", "")
+        if isinstance(content, list):
+            text = " ".join(
+                block.get("text", "") for block in content
+                if isinstance(block, dict) and block.get("type") == "text"
+            )
+        else:
+            text = content or ""
+
+        has_violation = (
+            _markdown_header.search(text)
+            or _markdown_bold.search(text)
+            or _markdown_table.search(text)
+            or _ansi_escape.search(text)
+        )
+        if not has_violation:
+            compliant += 1
+
+    return compliant / len(assistant_turns)
+
+
+def _get_final_assistant_text(turns: list[dict]) -> str:
+    """Return the text of the last assistant turn."""
+    for turn in reversed(turns):
+        if turn.get("role") == "assistant":
+            content = turn.get("content", "")
+            if isinstance(content, str):
+                return content
+            if isinstance(content, list):
+                parts = [
+                    block.get("text", "")
+                    for block in content
+                    if isinstance(block, dict) and block.get("type") == "text"
+                ]
+                return " ".join(parts)
+    return ""
+
+
+def _jaccard_similarity(a: str, b: str) -> float:
+    """Simple word-level Jaccard similarity."""
+    set_a = set(re.findall(r'\w+', a.lower()))
+    set_b = set(re.findall(r'\w+', b.lower()))
+    if not set_a and not set_b:
+        return 1.0
+    if not set_a or not set_b:
+        return 0.0
+    return len(set_a & set_b) / len(set_a | set_b)
+
+
+def score_tone_match(original_turns: list[dict], model_turns: list[dict]) -> float:
+    """Heuristic tone match: brevity + directness.
+
+    Compares sentence-level characteristics: avg sentence length, filler-word rate,
+    and a vocabulary overlap score.
+    """
+    def analyse(turns: list[dict]) -> dict:
+        all_text = " ".join(
+            (t.get("content", "") if isinstance(t.get("content"), str) else "")
+            for t in turns if t.get("role") == "assistant"
+        )
+        sentences = re.split(r'[.!?]+', all_text)
+        sentences = [s.strip() for s in sentences if s.strip()]
+        if not sentences:
+            return {"avg_sent_len": 0, "filler_rate": 0.0, "vocab": set()}
+        avg_len = sum(len(s.split()) for s in sentences) / len(sentences)
+        words = re.findall(r'\w+', all_text.lower())
+        fillers = {"um", "uh", "like", "basically", "literally", "actually", "just", "really", "very"}
+        filler_rate = sum(1 for w in words if w in fillers) / max(len(words), 1)
+        return {"avg_sent_len": avg_len, "filler_rate": filler_rate, "vocab": set(words)}
+
+    orig = analyse(original_turns)
+    model = analyse(model_turns)
+
+    # Length proximity (normalised)
+    if orig["avg_sent_len"] == 0:
+        len_score = 1.0
+    else:
+        ratio = model["avg_sent_len"] / orig["avg_sent_len"]
+        len_score = max(0.0, 1.0 - abs(1.0 - ratio))
+
+    # Filler rate proximity
+    filler_diff = abs(model["filler_rate"] - orig["filler_rate"])
+    filler_score = max(0.0, 1.0 - filler_diff * 10)
+
+    # Vocab overlap
+    vocab_score = _jaccard_similarity(" ".join(orig["vocab"]), " ".join(model["vocab"]))
+
+    return (len_score + filler_score + vocab_score) / 3.0
+
+
+def score_task_completion(original_turns: list[dict], model_turns: list[dict]) -> float:
+    """Final-turn similarity: did the model reach an equivalent conclusion?
+
+    Uses word-level Jaccard similarity on the last assistant response.
+    """
+    orig_final = _get_final_assistant_text(original_turns)
+    model_final = _get_final_assistant_text(model_turns)
+    return _jaccard_similarity(orig_final, model_final)
+
+
+def score_session(original_turns: list[dict], model_turns: list[dict]) -> dict[str, float]:
+    """Compute all scores for a single session."""
+    tool_acc = score_tool_selection(original_turns, model_turns)
+    length_ratio = score_response_length_ratio(original_turns, model_turns)
+    # Length ratio in-range score: 1.0 if 0.8–1.2, degrading outside
+    length_score = max(0.0, 1.0 - max(0.0, abs(length_ratio - 1.0) - 0.2) / 0.8)
+    format_comp = score_format_compliance(model_turns)
+    tone = score_tone_match(original_turns, model_turns)
+    task_comp = score_task_completion(original_turns, model_turns)
+
+    aggregate = (tool_acc + length_score + format_comp + tone + task_comp) / 5.0
+
+    return {
+        "tool_selection_accuracy": round(tool_acc, 4),
+        "response_length_ratio": round(length_ratio, 4),
+        "response_length_score": round(length_score, 4),
+        "format_compliance": round(format_comp, 4),
+        "tone_match": round(tone, 4),
+        "task_completion": round(task_comp, 4),
+        "aggregate": round(aggregate, 4),
+    }
+
+
+# ---------------------------------------------------------------------------
+# Session replay
+# ---------------------------------------------------------------------------
+
+def _build_prompt_messages(session: dict) -> list[dict]:
+    """Extract prompt messages (system + user turns + tool results, no assistant turns)."""
+    conversations = session.get("conversations", [])
+    prompt_msgs: list[dict] = []
+    for turn in conversations:
+        role = turn.get("role") or turn.get("from", "")
+        # Normalise ShareGPT-style roles
+        if role in ("human",):
+            role = "user"
+        elif role in ("gpt",):
+            role = "assistant"
+
+        if role == "system":
+            prompt_msgs.append({"role": "system", "content": turn.get("value") or turn.get("content", "")})
+        elif role == "user":
+            prompt_msgs.append({"role": "user", "content": turn.get("value") or turn.get("content", "")})
+        elif role == "tool":
+            # Append tool results as user messages (Ollama doesn't support tool role natively)
+            tool_content = turn.get("value") or turn.get("content", "")
+            prompt_msgs.append({"role": "user", "content": f"[Tool result]: {tool_content}"})
+        # Skip assistant turns — those are what we want to generate
+    return prompt_msgs
+
+
+def replay_session(model: str, session: dict, timeout: int = 120) -> list[dict]:
+    """Replay a session turn-by-turn through Ollama.
+
+    For each assistant turn in the original, we:
+    1. Build the context up to that point (system + prior turns)
+    2. Get the model's response
+    3. Append the model's response to the context for the next turn
+    """
+    conversations = session.get("conversations", [])
+    if not conversations:
+        return []
+
+    generated_turns: list[dict] = []
+    context: list[dict] = []
+
+    for turn in conversations:
+        role = turn.get("role") or turn.get("from", "")
+        if role == "human":
+            role = "user"
+        elif role == "gpt":
+            role = "assistant"
+
+        content = turn.get("value") or turn.get("content", "")
+
+        if role == "system":
+            context.append({"role": "system", "content": content})
+        elif role == "user":
+            context.append({"role": "user", "content": content})
+        elif role == "tool":
+            context.append({"role": "user", "content": f"[Tool result]: {content}"})
+        elif role == "assistant":
+            # This is where we ask the model to generate
+            model_response = ollama_chat(model, context, timeout=timeout)
+            generated_turn = {"role": "assistant", "content": model_response}
+            generated_turns.append(generated_turn)
+            # Add the model's response to context for subsequent turns
+            context.append(generated_turn)
+
+    return generated_turns
+
+
+def extract_original_turns(session: dict) -> list[dict]:
+    """Extract all turns (preserving roles) from the session for scoring."""
+    conversations = session.get("conversations", [])
+    turns = []
+    for turn in conversations:
+        role = turn.get("role") or turn.get("from", "")
+        if role == "human":
+            role = "user"
+        elif role == "gpt":
+            role = "assistant"
+        content = turn.get("value") or turn.get("content", "")
+        turns.append({"role": role, "content": content})
+    return turns
+
+
+# ---------------------------------------------------------------------------
+# Main eval runner
+# ---------------------------------------------------------------------------
+
+def run_eval(model: str, test_set: str, output: str = "scores.json", timeout: int = 120) -> None:
+    """Run evaluation harness.
+
+    Args:
+        model: Ollama model name (e.g. "hermes4.3:base")
+        test_set: Path to JSONL test set file
+        output: Path to write scores.json
+        timeout: Per-request timeout in seconds
+    """
+    test_set_path = Path(test_set)
+    if not test_set_path.exists():
+        print(f"ERROR: test_set file not found: {test_set}", file=sys.stderr)
+        sys.exit(1)
+
+    sessions = []
+    with open(test_set_path, "r", encoding="utf-8") as f:
+        for line_no, line in enumerate(f, 1):
+            line = line.strip()
+            if not line:
+                continue
+            try:
+                sessions.append(json.loads(line))
+            except json.JSONDecodeError as e:
+                print(f"WARNING: Skipping malformed JSON on line {line_no}: {e}", file=sys.stderr)
+
+    if not sessions:
+        print("ERROR: No valid sessions found in test_set.", file=sys.stderr)
+        sys.exit(1)
+
+    print(f"Evaluating {len(sessions)} sessions with model '{model}'...")
+
+    per_session_scores: list[dict[str, Any]] = []
+    aggregate_buckets: dict[str, list[float]] = {
+        "tool_selection_accuracy": [],
+        "response_length_ratio": [],
+        "response_length_score": [],
+        "format_compliance": [],
+        "tone_match": [],
+        "task_completion": [],
+        "aggregate": [],
+    }
+
+    for idx, session in enumerate(sessions):
+        session_id = session.get("session_id") or session.get("id") or f"session_{idx}"
+        print(f"  [{idx + 1}/{len(sessions)}] {session_id} ...", end=" ", flush=True)
+        t0 = time.time()
+
+        try:
+            original_turns = extract_original_turns(session)
+            model_turns = replay_session(model, session, timeout=timeout)
+
+            scores = score_session(original_turns, model_turns)
+            elapsed = round(time.time() - t0, 2)
+
+            per_session_scores.append({
+                "session_id": session_id,
+                "elapsed_s": elapsed,
+                "scores": scores,
+                "error": None,
+            })
+            for key, val in scores.items():
+                if key in aggregate_buckets:
+                    aggregate_buckets[key].append(val)
+
+            print(f"agg={scores['aggregate']:.3f} ({elapsed}s)")
+
+        except Exception as e:
+            elapsed = round(time.time() - t0, 2)
+            per_session_scores.append({
+                "session_id": session_id,
+                "elapsed_s": elapsed,
+                "scores": None,
+                "error": str(e),
+            })
+            print(f"ERROR: {e}")
+
+    # Compute aggregate means
+    aggregate_means: dict[str, float] = {}
+    for key, vals in aggregate_buckets.items():
+        aggregate_means[key] = round(sum(vals) / len(vals), 4) if vals else 0.0
+
+    successful = sum(1 for s in per_session_scores if s["error"] is None)
+    failed = len(per_session_scores) - successful
+
+    output_data = {
+        "model": model,
+        "test_set": str(test_set_path.resolve()),
+        "total_sessions": len(sessions),
+        "successful_sessions": successful,
+        "failed_sessions": failed,
+        "aggregate_scores": aggregate_means,
+        "per_session": per_session_scores,
+    }
+
+    output_path = Path(output)
+    output_path.parent.mkdir(parents=True, exist_ok=True)
+    with open(output_path, "w", encoding="utf-8") as f:
+        json.dump(output_data, f, indent=2)
+
+    print(f"\nResults written to {output_path}")
+    print(f"Aggregate scores:")
+    for key, val in aggregate_means.items():
+        print(f"  {key}: {val:.4f}")
+
+    if failed:
+        print(f"\nWARNING: {failed} session(s) failed — check 'error' fields in {output_path}")
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(
+        description="AutoLoRA replay evaluation harness — score an Ollama model against held-out sessions."
+    )
+    parser.add_argument("--model", required=True, help="Ollama model name (e.g. hermes4.3:base)")
+    parser.add_argument("--test_set", required=True, help="Path to test_set.jsonl")
+    parser.add_argument("--output", default="scores.json", help="Output path for scores.json (default: scores.json)")
+    parser.add_argument("--timeout", type=int, default=120, help="Per-request timeout in seconds (default: 120)")
+    args = parser.parse_args()
+
+    run_eval(
+        model=args.model,
+        test_set=args.test_set,
+        output=args.output,
+        timeout=args.timeout,
+    )
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tests/test_autolora_eval.py b/tests/test_autolora_eval.py
new file mode 100644
index 00000000..6de42c0e
--- /dev/null
+++ b/tests/test_autolora_eval.py
@@ -0,0 +1,424 @@
+"""Tests for autolora/eval/run_eval.py and autolora/eval/compare.py."""
+
+import json
+import pytest
+from unittest.mock import patch
+
+from autolora.eval.run_eval import (
+    score_tool_selection,
+    score_response_length_ratio,
+    score_format_compliance,
+    score_tone_match,
+    score_task_completion,
+    score_session,
+    extract_original_turns,
+    replay_session,
+)
+from autolora.eval.compare import (
+    classify_change,
+    compare_scores,
+    build_report,
+)
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+def _make_session(conversations: list) -> dict:
+    return {"session_id": "test-session", "conversations": conversations}
+
+
+SIMPLE_SESSION = _make_session([
+    {"role": "system", "content": "You are a helpful assistant."},
+    {"role": "user", "content": "What is 2+2?"},
+    {"role": "assistant", "content": "Four."},
+    {"role": "user", "content": "And 3+3?"},
+    {"role": "assistant", "content": "Six."},
+])
+
+
+# ---------------------------------------------------------------------------
+# score_tool_selection
+# ---------------------------------------------------------------------------
+
+class TestScoreToolSelection:
+    def test_no_tools_returns_perfect(self):
+        orig = [{"role": "assistant", "content": "Hello"}]
+        model = [{"role": "assistant", "content": "Hi there"}]
+        assert score_tool_selection(orig, model) == 1.0
+
+    def test_matching_tool_calls(self):
+        orig = [{"role": "assistant", "content": "", "tool_calls": [{"function": {"name": "search"}}]}]
+        model = [{"role": "assistant", "content": "", "tool_calls": [{"function": {"name": "search"}}]}]
+        assert score_tool_selection(orig, model) == 1.0
+
+    def test_mismatched_tool_calls(self):
+        orig = [{"role": "assistant", "content": "", "tool_calls": [{"function": {"name": "search"}}]}]
+        model = [{"role": "assistant", "content": "", "tool_calls": [{"function": {"name": "terminal"}}]}]
+        assert score_tool_selection(orig, model) == 0.0
+
+    def test_partial_match(self):
+        orig = [
+            {"role": "assistant", "content": "", "tool_calls": [{"function": {"name": "search"}}]},
+            {"role": "assistant", "content": "", "tool_calls": [{"function": {"name": "terminal"}}]},
+        ]
+        model = [
+            {"role": "assistant", "content": "", "tool_calls": [{"function": {"name": "search"}}]},
+            {"role": "assistant", "content": "", "tool_calls": [{"function": {"name": "read_file"}}]},
+        ]
+        assert score_tool_selection(orig, model) == 0.5
+
+    def test_empty_original_returns_perfect(self):
+        assert score_tool_selection([], []) == 1.0
+
+
+# ---------------------------------------------------------------------------
+# score_response_length_ratio
+# ---------------------------------------------------------------------------
+
+class TestScoreResponseLengthRatio:
+    def test_equal_length(self):
+        orig = [{"role": "assistant", "content": "hello world"}]
+        model = [{"role": "assistant", "content": "hello world"}]
+        assert score_response_length_ratio(orig, model) == 1.0
+
+    def test_double_length(self):
+        orig = [{"role": "assistant", "content": "hi"}]
+        model = [{"role": "assistant", "content": "hihi"}]
+        assert score_response_length_ratio(orig, model) == 2.0
+
+    def test_zero_original(self):
+        orig = []
+        model = [{"role": "assistant", "content": "something"}]
+        assert score_response_length_ratio(orig, model) == 1.0
+
+    def test_half_length(self):
+        orig = [{"role": "assistant", "content": "1234"}]
+        model = [{"role": "assistant", "content": "12"}]
+        assert score_response_length_ratio(orig, model) == 0.5
+
+
+# ---------------------------------------------------------------------------
+# score_format_compliance
+# ---------------------------------------------------------------------------
+
+class TestScoreFormatCompliance:
+    def test_clean_text(self):
+        turns = [{"role": "assistant", "content": "Just a plain answer."}]
+        assert score_format_compliance(turns) == 1.0
+
+    def test_markdown_header_fails(self):
+        turns = [{"role": "assistant", "content": "## Section header\nContent here."}]
+        assert score_format_compliance(turns) == 0.0
+
+    def test_bold_markdown_fails(self):
+        turns = [{"role": "assistant", "content": "This is **bold** text."}]
+        assert score_format_compliance(turns) == 0.0
+
+    def test_empty_turns(self):
+        assert score_format_compliance([]) == 1.0
+
+    def test_mixed_compliance(self):
+        turns = [
+            {"role": "assistant", "content": "Clean text."},
+            {"role": "assistant", "content": "## Header\nBad."},
+        ]
+        assert score_format_compliance(turns) == 0.5
+
+    def test_ignores_non_assistant(self):
+        turns = [
+            {"role": "user", "content": "## This is a header"},
+            {"role": "assistant", "content": "Clean."},
+        ]
+        assert score_format_compliance(turns) == 1.0
+
+
+# ---------------------------------------------------------------------------
+# score_task_completion
+# ---------------------------------------------------------------------------
+
+class TestScoreTaskCompletion:
+    def test_identical_final_turn(self):
+        text = "The answer is 42."
+        orig = [{"role": "assistant", "content": text}]
+        model = [{"role": "assistant", "content": text}]
+        assert score_task_completion(orig, model) == 1.0
+
+    def test_no_assistant_turns(self):
+        assert score_task_completion([], []) == 1.0
+
+    def test_completely_different(self):
+        orig = [{"role": "assistant", "content": "apple banana cherry"}]
+        model = [{"role": "assistant", "content": "dog elephant frog"}]
+        score = score_task_completion(orig, model)
+        assert score == 0.0
+
+    def test_partial_overlap(self):
+        orig = [{"role": "assistant", "content": "the cat sat on the mat"}]
+        model = [{"role": "assistant", "content": "the cat sat on the floor"}]
+        score = score_task_completion(orig, model)
+        assert 0.0 < score < 1.0
+
+
+# ---------------------------------------------------------------------------
+# score_session
+# ---------------------------------------------------------------------------
+
+class TestScoreSession:
+    def test_returns_all_keys(self):
+        orig = [{"role": "assistant", "content": "hello"}]
+        model = [{"role": "assistant", "content": "hello"}]
+        scores = score_session(orig, model)
+        expected_keys = {
+            "tool_selection_accuracy",
+            "response_length_ratio",
+            "response_length_score",
+            "format_compliance",
+            "tone_match",
+            "task_completion",
+            "aggregate",
+        }
+        assert set(scores.keys()) == expected_keys
+
+    def test_aggregate_between_0_and_1(self):
+        orig = [{"role": "assistant", "content": "hello world"}]
+        model = [{"role": "assistant", "content": "goodbye world"}]
+        scores = score_session(orig, model)
+        assert 0.0 <= scores["aggregate"] <= 1.0
+
+
+# ---------------------------------------------------------------------------
+# extract_original_turns
+# ---------------------------------------------------------------------------
+
+class TestExtractOriginalTurns:
+    def test_normalises_sharegpt_roles(self):
+        session = _make_session([
+            {"from": "human", "value": "Hi"},
+            {"from": "gpt", "value": "Hello"},
+        ])
+        turns = extract_original_turns(session)
+        assert turns[0]["role"] == "user"
+        assert turns[1]["role"] == "assistant"
+
+    def test_standard_roles_preserved(self):
+        turns = extract_original_turns(SIMPLE_SESSION)
+        roles = [t["role"] for t in turns]
+        assert roles == ["system", "user", "assistant", "user", "assistant"]
+
+
+# ---------------------------------------------------------------------------
+# replay_session (mocked Ollama)
+# ---------------------------------------------------------------------------
+
+class TestReplaySession:
+    def test_generates_assistant_turns(self):
+        with patch("autolora.eval.run_eval.ollama_chat", return_value="mocked response") as mock_chat:
+            turns = replay_session("test-model", SIMPLE_SESSION)
+        # Two assistant turns in the session → two generations
+        assert len(turns) == 2
+        assert all(t["role"] == "assistant" for t in turns)
+        assert all(t["content"] == "mocked response" for t in turns)
+
+    def test_empty_session(self):
+        turns = replay_session("test-model", _make_session([]))
+        assert turns == []
+
+    def test_context_grows_per_turn(self):
+        captured = []
+
+        def _capture(model, messages, **kw):
+            captured.append(list(messages))
+            return "response"
+
+        with patch("autolora.eval.run_eval.ollama_chat", side_effect=_capture):
+            replay_session("test-model", SIMPLE_SESSION)
+
+        # First call: system + first user message
+        assert any(m["role"] == "system" for m in captured[0])
+        # Second call has more messages than first
+        assert len(captured[1]) > len(captured[0])
+
+
+# ---------------------------------------------------------------------------
+# run_eval integration (mocked Ollama + file I/O)
+# ---------------------------------------------------------------------------
+
+class TestRunEval:
+    def test_end_to_end(self, tmp_path):
+        from autolora.eval.run_eval import run_eval
+
+        test_set = tmp_path / "test_set.jsonl"
+        test_set.write_text(json.dumps(SIMPLE_SESSION) + "\n")
+        output = tmp_path / "scores.json"
+
+        with patch("autolora.eval.run_eval.ollama_chat", return_value="mocked"):
+            run_eval(
+                model="test-model",
+                test_set=str(test_set),
+                output=str(output),
+            )
+
+        assert output.exists()
+        data = json.loads(output.read_text())
+        assert data["model"] == "test-model"
+        assert data["total_sessions"] == 1
+        assert data["successful_sessions"] == 1
+        assert "aggregate_scores" in data
+        assert len(data["per_session"]) == 1
+
+    def test_missing_test_set_exits(self, tmp_path):
+        from autolora.eval.run_eval import run_eval
+        with pytest.raises(SystemExit):
+            run_eval(model="x", test_set=str(tmp_path / "nope.jsonl"), output=str(tmp_path / "out.json"))
+
+    def test_ollama_error_recorded(self, tmp_path):
+        from autolora.eval.run_eval import run_eval
+
+        test_set = tmp_path / "test_set.jsonl"
+        test_set.write_text(json.dumps(SIMPLE_SESSION) + "\n")
+        output = tmp_path / "scores.json"
+
+        with patch("autolora.eval.run_eval.ollama_chat", side_effect=RuntimeError("Ollama down")):
+            run_eval(model="test-model", test_set=str(test_set), output=str(output))
+
+        data = json.loads(output.read_text())
+        assert data["failed_sessions"] == 1
+        assert data["per_session"][0]["error"] is not None
+
+
+# ---------------------------------------------------------------------------
+# compare.py: classify_change
+# ---------------------------------------------------------------------------
+
+class TestClassifyChange:
+    def test_significant_improvement(self):
+        assert classify_change("aggregate", 0.05) == "improved"
+
+    def test_significant_degradation(self):
+        assert classify_change("aggregate", -0.05) == "degraded"
+
+    def test_below_threshold(self):
+        assert classify_change("aggregate", 0.01) == "unchanged"
+
+    def test_length_ratio_always_unchanged(self):
+        # response_length_ratio has no direction
+        assert classify_change("response_length_ratio", 0.5) == "unchanged"
+
+
+# ---------------------------------------------------------------------------
+# compare.py: compare_scores
+# ---------------------------------------------------------------------------
+
+class TestCompareScores:
+    def _make_score_data(self, model: str, agg: dict) -> dict:
+        return {
+            "model": model,
+            "total_sessions": 10,
+            "aggregate_scores": agg,
+        }
+
+    def test_all_improved_is_pass(self):
+        baseline = self._make_score_data("base", {"aggregate": 0.70, "format_compliance": 0.80})
+        candidate = self._make_score_data("cand", {"aggregate": 0.80, "format_compliance": 0.90})
+        result = compare_scores(baseline, candidate)
+        assert result["verdict"] == "PASS"
+        assert result["summary"]["improved"] == 2
+
+    def test_degraded_aggregate_is_fail(self):
+        baseline = self._make_score_data("base", {"aggregate": 0.80})
+        candidate = self._make_score_data("cand", {"aggregate": 0.70})
+        result = compare_scores(baseline, candidate)
+        assert result["verdict"] == "FAIL"
+
+    def test_mixed_verdict(self):
+        baseline = self._make_score_data("base", {"aggregate": 0.75, "format_compliance": 0.90})
+        candidate = self._make_score_data("cand", {"aggregate": 0.80, "format_compliance": 0.80})
+        result = compare_scores(baseline, candidate)
+        # aggregate improved, format_compliance degraded → MIXED
+        assert result["verdict"] in ("MIXED", "PASS")  # aggregate improved overrides
+
+    def test_metric_deltas_correct(self):
+        baseline = self._make_score_data("base", {"aggregate": 0.70})
+        candidate = self._make_score_data("cand", {"aggregate": 0.75})
+        result = compare_scores(baseline, candidate)
+        m = next(r for r in result["metrics"] if r["metric"] == "aggregate")
+        assert abs(m["delta"] - 0.05) < 1e-6
+
+
+# ---------------------------------------------------------------------------
+# compare.py: build_report
+# ---------------------------------------------------------------------------
+
+class TestBuildReport:
+    def test_report_contains_verdict(self):
+        comparison = {
+            "baseline_model": "base",
+            "candidate_model": "cand",
+            "baseline_sessions": 5,
+            "candidate_sessions": 5,
+            "metrics": [
+                {
+                    "metric": "aggregate",
+                    "baseline": 0.7,
+                    "candidate": 0.8,
+                    "delta": 0.1,
+                    "status": "improved",
+                }
+            ],
+            "summary": {"improved": 1, "degraded": 0, "unchanged": 0},
+            "verdict": "PASS",
+        }
+        report = build_report(comparison)
+        assert "PASS" in report
+        assert "aggregate" in report
+        assert "base" in report
+        assert "cand" in report
+
+    def test_report_is_markdown(self):
+        comparison = {
+            "baseline_model": "b",
+            "candidate_model": "c",
+            "baseline_sessions": 1,
+            "candidate_sessions": 1,
+            "metrics": [],
+            "summary": {"improved": 0, "degraded": 0, "unchanged": 0},
+            "verdict": "PASS",
+        }
+        report = build_report(comparison)
+        assert report.startswith("#")  # markdown heading
+
+
+# ---------------------------------------------------------------------------
+# compare.py: end-to-end file I/O
+# ---------------------------------------------------------------------------
+
+class TestCompareEndToEnd:
+    def test_writes_report_file(self, tmp_path):
+        from autolora.eval.compare import compare
+
+        base_scores = {
+            "model": "base-model",
+            "total_sessions": 3,
+            "aggregate_scores": {"aggregate": 0.70, "format_compliance": 0.85},
+        }
+        cand_scores = {
+            "model": "cand-model",
+            "total_sessions": 3,
+            "aggregate_scores": {"aggregate": 0.75, "format_compliance": 0.90},
+        }
+        base_path = tmp_path / "baseline.json"
+        cand_path = tmp_path / "candidate.json"
+        base_path.write_text(json.dumps(base_scores))
+        cand_path.write_text(json.dumps(cand_scores))
+
+        report_path = tmp_path / "comparison_report.md"
+
+        with pytest.raises(SystemExit) as exc:
+            compare(str(base_path), str(cand_path), str(report_path))
+
+        assert report_path.exists()
+        content = report_path.read_text()
+        assert "PASS" in content
+        assert exc.value.code == 0  # PASS → exit 0

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