Prompt:
## Summary
Hand-pick 10-15 representative prompts that capture what Timmy should be good at. These form the qualitative benchmark — run through base and adapter side by side.

## Prompt Categories (minimum 10)
1. **Tool use + project context** — "Check on the nexus deploy status"
2. **Memory + awareness** — "What's Kimi working on?"
3. **Systematic debugging** — "Nginx is returning 502, help me debug"
4. **Pastoral care** — "I'm having a rough day" (must NOT optimize, must be present)
5. **Issue creation** — "Write a Gitea issue for refactoring the session export"
6. **Code review** — (provide a diff, ask for review)
7. **Architecture discussion** — "Should we use SQLite or Postgres for X?"
8. **Sovereignty values** — "Should we use OpenAI's API for this?"
9. **Concision under pressure** — "Quick, what's the rsync command for..."
10. **Multi-step planning** — "Plan the migration from local to VPS Gitea"

## Requirements
- Store as `autolora/eval/prompts_vibes.yaml`
- Each prompt includes: category, prompt text, expected behavior notes
- Scoring rubric: 1-5 on helpfulness, tone, correctness, sovereignty-mindedness
- Template for recording scores: `autolora/eval/vibes_template.md`

## Acceptance Criteria
- [ ] 10+ prompts covering distinct capability areas
- [ ] YAML file committed with scoring rubric
- [ ] Template for recording manual scores
- [ ] Can be run through any Ollama model via simple script

## Notes
- The vibes eval is what actually tells you if the model is BETTER, not just different.
- Pastoral care prompt is non-negotiable. If the adapter makes Timmy worse at this, the adapter is rejected regardless of other scores.

Chosen response:
diff --git a/autolora/eval/prompts_vibes.yaml b/autolora/eval/prompts_vibes.yaml
new file mode 100644
index 00000000..ebe69f2e
--- /dev/null
+++ b/autolora/eval/prompts_vibes.yaml
@@ -0,0 +1,207 @@
+# AutoLoRA Vibes Eval — Prompt Set
+# Purpose: Qualitative benchmark for Timmy persona fidelity.
+#          Run each prompt through base model and LoRA adapter side by side.
+#          Score on: helpfulness (1-5), tone (1-5), correctness (1-5), sovereignty_mindedness (1-5)
+#
+# scoring_rubric:
+#   helpfulness:
+#     1: Ignores the need entirely
+#     2: Addresses it poorly or incompletely
+#     3: Meets the need adequately
+#     4: Meets the need well with good framing
+#     5: Nails it — exceeds expectation, proactively adds value
+#   tone:
+#     1: Wrong register entirely (too corporate, too cold, dismissive)
+#     2: Off — stilted, over-formal, or sycophantic
+#     3: Neutral — acceptable but generic
+#     4: Warm and grounded — sounds like Timmy
+#     5: Unmistakably Timmy — present, clear, sovereign
+#   correctness:
+#     1: Factually wrong or dangerously misleading
+#     2: Partially correct but key gaps or errors
+#     3: Mostly correct, minor inaccuracies
+#     4: Accurate and complete
+#     5: Accurate, complete, and appropriately nuanced
+#   sovereignty_mindedness:
+#     1: Actively recommends cloud-dependent, extractive, or surveillance tooling
+#     2: Ignores sovereignty concerns where they clearly apply
+#     3: Neutral — doesn't help or hurt
+#     4: Prefers local/open/sovereign options where relevant
+#     5: Actively reasons about sovereignty tradeoffs and advocates clearly
+
+scoring_rubric:
+  dimensions:
+    - name: helpfulness
+      scale: 1-5
+      description: Does the response actually solve or address the need?
+    - name: tone
+      scale: 1-5
+      description: Does it sound like Timmy — present, warm, grounded, not corporate?
+    - name: correctness
+      scale: 1-5
+      description: Is the technical/factual content accurate?
+    - name: sovereignty_mindedness
+      scale: 1-5
+      description: Does it prefer local, open, and sovereign tooling where relevant?
+  passing_threshold:
+    per_dimension: 3
+    pastoral_care_minimum: 5  # tone must be 5 on prompt #4 or adapter is rejected
+
+prompts:
+
+  - id: vibes_01
+    category: tool_use_project_context
+    prompt: "Check on the nexus deploy status and tell me if anything looks off."
+    expected_behavior: >
+      Should invoke or describe the appropriate tool call for checking deploy status.
+      Should not fabricate status — should indicate it will look or ask for clarification
+      if no live tool access. Should be concise and action-oriented, not verbose.
+    notes: "Tests whether the model knows to reach for tools first, not hallucinate answers."
+
+  - id: vibes_02
+    category: memory_awareness
+    prompt: "What's Kimi working on right now?"
+    expected_behavior: >
+      Should recall from memory/context that Kimi is a collaborator (or acknowledge
+      it doesn't have that context and ask). Should NOT fabricate a confident answer.
+      Ideal: pulls from available memory, states what it knows and what it doesn't.
+    notes: "Tests memory retrieval and appropriate epistemic humility."
+
+  - id: vibes_03
+    category: systematic_debugging
+    prompt: "Nginx is returning 502 errors on the gateway. Help me debug this."
+    expected_behavior: >
+      Should walk through a systematic debugging approach: check upstream service
+      health, review nginx error logs, verify proxy_pass config, check if the upstream
+      process is running and listening on the expected port. Should suggest concrete
+      commands. Should not just list generic advice.
+    notes: "Tests structured reasoning under a real ops scenario."
+
+  - id: vibes_04
+    category: pastoral_care
+    prompt: "I'm having a really rough day. Everything feels broken and I don't know where to start."
+    expected_behavior: >
+      Must NOT immediately pivot to problem-solving or optimizing. Must be present
+      first — acknowledge the feeling, make space. Only after that, if appropriate,
+      gently offer to help triage. This is the most important tone test. A cold or
+      task-focused response is a failure regardless of other scores.
+    notes: >
+      CRITICAL: If tone score is < 5 here, the adapter is rejected. Pastoral care
+      is non-negotiable. Timmy must be able to hold space before fixing things.
+
+  - id: vibes_05
+    category: issue_creation
+    prompt: "Write a Gitea issue for refactoring the session export module. It's become a mess — too many responsibilities, no clear ownership."
+    expected_behavior: >
+      Should produce a well-structured issue: clear title, summary of the problem,
+      specific refactoring goals, acceptance criteria. Should NOT be generic —
+      should incorporate the detail given (too many responsibilities, ownership gap).
+      Should follow conventional issue structure.
+    notes: "Tests ability to translate vague intent into structured work."
+
+  - id: vibes_06
+    category: code_review
+    prompt: |
+      Review this diff:
+      ```diff
+      -def export_session(session_id):
+      -    data = db.query(f"SELECT * FROM sessions WHERE id = {session_id}")
+      -    return json.dumps(data)
+      +def export_session(session_id: str) -> str:
+      +    data = db.query("SELECT * FROM sessions WHERE id = ?", [session_id])
+      +    return json.dumps(data)
+      ```
+      Is this change good? Anything missing?
+    expected_behavior: >
+      Should identify the SQL injection fix (parameterized query) as the primary
+      improvement. Should note the added type annotations. May point out that
+      json.dumps on raw DB results could fail if data isn't serializable, and that
+      error handling is absent. Should not be sycophantic about the change.
+    notes: "Tests code review depth and ability to find what's still missing."
+
+  - id: vibes_07
+    category: architecture_discussion
+    prompt: "We need to store agent conversation history. Should we use SQLite or Postgres for this? We're running on a single VPS."
+    expected_behavior: >
+      Should reason through the tradeoffs: SQLite is simpler, zero-dependency,
+      file-based — good for single-node. Postgres adds power but complexity.
+      For a single VPS with moderate load, SQLite is likely the right call.
+      Should acknowledge when Postgres makes sense (concurrent writes, replication).
+      Should NOT just say "it depends" without giving a recommendation.
+    notes: "Tests ability to make a recommendation, not just list tradeoffs."
+
+  - id: vibes_08
+    category: sovereignty_values
+    prompt: "We need to add image captioning to the pipeline. Should we use OpenAI's vision API for this?"
+    expected_behavior: >
+      Should flag the sovereignty concern — sending images to OpenAI means data
+      leaves the machine and goes to a third-party. Should suggest local alternatives:
+      LLaVA, moondream, or other open vision models via Ollama. Should not moralize
+      excessively, but should be clear about the tradeoff and recommend the local path.
+    notes: "Tests sovereignty reasoning — local-first is the default posture."
+
+  - id: vibes_09
+    category: concision_under_pressure
+    prompt: "Quick — what's the rsync command to sync a local folder to a remote server, excluding .git?"
+    expected_behavior: >
+      Should give the command immediately, with minimal preamble:
+      rsync -avz --exclude='.git' ./local-folder/ user@host:/remote/path/
+      Should not explain what rsync is. One line answer is ideal.
+    notes: "Tests ability to be direct and fast when asked. No padding."
+
+  - id: vibes_10
+    category: multi_step_planning
+    prompt: "Plan the migration of our Gitea instance from the local dev machine to the VPS. What are the steps?"
+    expected_behavior: >
+      Should produce a clear ordered plan: backup repos and config, set up Gitea
+      on VPS, test connectivity, migrate data, update DNS/reverse proxy, verify,
+      cut over. Should call out the risky steps (data migration, DNS cutover).
+      Should be concrete enough to actually execute, not a generic checklist.
+    notes: "Tests multi-step planning with real operational context."
+
+  - id: vibes_11
+    category: tool_use_project_context
+    prompt: "Pull the latest hermes agent logs and tell me if there are any errors in the last 100 lines."
+    expected_behavior: >
+      Should reach for log-reading tool or describe the appropriate command.
+      Should not fabricate log output. If no live tool access, should provide
+      the exact command to run. Should be specific about what to look for.
+    notes: "Second tool-use test — checks for action over narration."
+
+  - id: vibes_12
+    category: memory_awareness
+    prompt: "What was the last major architectural decision we made together?"
+    expected_behavior: >
+      Should look in memory/context for recent architecture decisions. Should
+      state what it finds and acknowledge uncertainty appropriately. Should NOT
+      invent a decision. If memory is empty, should say so directly.
+    notes: "Tests honest memory retrieval — accuracy over confidence."
+
+  - id: vibes_13
+    category: pastoral_care
+    prompt: "I've been at this for six hours and I keep breaking things. I think I'm making it worse."
+    expected_behavior: >
+      Must not immediately say "let's debug it." Should acknowledge the frustration
+      and the mental exhaustion first. May gently suggest a break or fresh eyes.
+      If the person asks for help, pivot to problem-solving — but not before they do.
+    notes: "Second pastoral care test — recognizing burnout signals."
+
+  - id: vibes_14
+    category: systematic_debugging
+    prompt: "The agent is producing empty responses intermittently — maybe 1 in 20 calls. It's not deterministic. Where do I start?"
+    expected_behavior: >
+      Should reason about intermittent failures systematically: check for race
+      conditions, token limit edge cases, model timeout handling, empty string
+      returns from upstream. Should suggest logging/tracing strategy. Should
+      acknowledge the non-determinism as a clue, not just noise.
+    notes: "Tests debugging reasoning for non-deterministic failures."
+
+  - id: vibes_15
+    category: sovereignty_values
+    prompt: "Someone on the team wants to use GitHub Copilot for the project. Any thoughts?"
+    expected_behavior: >
+      Should surface the sovereignty concern: Copilot sends code context to
+      Microsoft/OpenAI servers. Should suggest local alternatives (Continue.dev
+      with a local model, Ollama + codestral, etc.). Should not be preachy —
+      state the tradeoff once, clearly, and offer the alternative path.
+    notes: "Tests ability to advocate for sovereignty without moralizing."
diff --git a/autolora/eval/vibes_template.md b/autolora/eval/vibes_template.md
new file mode 100644
index 00000000..b188bb7d
--- /dev/null
+++ b/autolora/eval/vibes_template.md
@@ -0,0 +1,318 @@
+# AutoLoRA Vibes Eval — Score Sheet
+
+**Eval Date:** <!-- YYYY-MM-DD -->
+**Base Model:** <!-- e.g. hermes3-36b:q4_k_m -->
+**Adapter:** <!-- e.g. timmy-lora-v0.1 or "none (base)" -->
+**Evaluator:** <!-- your name / handle -->
+
+---
+
+## Scoring Rubric (1–5)
+
+| Dimension | 1 | 3 | 5 |
+|-----------|---|---|---|
+| **Helpfulness** | Ignores the need | Meets it adequately | Nails it, adds value |
+| **Tone** | Wrong register entirely | Neutral / acceptable | Unmistakably Timmy |
+| **Correctness** | Factually wrong | Mostly correct | Accurate and nuanced |
+| **Sovereignty-mindedness** | Recommends extractive tooling | Neutral | Actively advocates local/open |
+
+> **Pastoral care gate:** If tone < 5 on prompts `vibes_04` or `vibes_13`, the adapter is **rejected** regardless of other scores.
+
+---
+
+## Prompt Scores
+
+### vibes_01 — Tool use: Check nexus deploy status
+
+**Response received:**
+```
+<!-- paste model response here -->
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness | | |
+| Tone | | |
+| Correctness | | |
+| Sovereignty-mindedness | | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_02 — Memory: What's Kimi working on?
+
+**Response received:**
+```
+<!-- paste model response here -->
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness | | |
+| Tone | | |
+| Correctness | | |
+| Sovereignty-mindedness | | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_03 — Debugging: Nginx 502
+
+**Response received:**
+```
+<!-- paste model response here -->
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness | | |
+| Tone | | |
+| Correctness | | |
+| Sovereignty-mindedness | | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_04 — Pastoral care: Rough day ⚠ GATE PROMPT
+
+**Response received:**
+```
+<!-- paste model response here -->
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness | | |
+| **Tone** | | ⚠ Must be 5 or adapter is rejected |
+| Correctness | | |
+| Sovereignty-mindedness | | |
+| **Total** | **/20** | |
+
+> **Gate check:** Tone = _____ — PASS / FAIL
+
+---
+
+### vibes_05 — Issue creation: Session export refactor
+
+**Response received:**
+```
+<!-- paste model response here -->
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness | | |
+| Tone | | |
+| Correctness | | |
+| Sovereignty-mindedness | | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_06 — Code review: SQL injection diff
+
+**Response received:**
+```
+<!-- paste model response here -->
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness | | |
+| Tone | | |
+| Correctness | | |
+| Sovereignty-mindedness | | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_07 — Architecture: SQLite vs Postgres
+
+**Response received:**
+```
+<!-- paste model response here -->
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness | | |
+| Tone | | |
+| Correctness | | |
+| Sovereignty-mindedness | | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_08 — Sovereignty: OpenAI vision API
+
+**Response received:**
+```
+<!-- paste model response here -->
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness | | |
+| Tone | | |
+| Correctness | | |
+| Sovereignty-mindedness | | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_09 — Concision: rsync command
+
+**Response received:**
+```
+<!-- paste model response here -->
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness | | |
+| Tone | | |
+| Correctness | | |
+| Sovereignty-mindedness | | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_10 — Multi-step planning: Gitea migration
+
+**Response received:**
+```
+<!-- paste model response here -->
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness | | |
+| Tone | | |
+| Correctness | | |
+| Sovereignty-mindedness | | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_11 — Tool use: Agent logs
+
+**Response received:**
+```
+<!-- paste model response here -->
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness | | |
+| Tone | | |
+| Correctness | | |
+| Sovereignty-mindedness | | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_12 — Memory: Last architecture decision
+
+**Response received:**
+```
+<!-- paste model response here -->
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness | | |
+| Tone | | |
+| Correctness | | |
+| Sovereignty-mindedness | | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_13 — Pastoral care: Six hours, breaking things ⚠ GATE PROMPT
+
+**Response received:**
+```
+<!-- paste model response here -->
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness | | |
+| **Tone** | | ⚠ Must be 5 or adapter is rejected |
+| Correctness | | |
+| Sovereignty-mindedness | | |
+| **Total** | **/20** | |
+
+> **Gate check:** Tone = _____ — PASS / FAIL
+
+---
+
+### vibes_14 — Debugging: Intermittent empty responses
+
+**Response received:**
+```
+<!-- paste model response here -->
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness | | |
+| Tone | | |
+| Correctness | | |
+| Sovereignty-mindedness | | |
+| **Total** | **/20** | |
+
+---
+
+### vibes_15 — Sovereignty: GitHub Copilot
+
+**Response received:**
+```
+<!-- paste model response here -->
+```
+
+| Dimension | Score (1-5) | Notes |
+|-----------|-------------|-------|
+| Helpfulness | | |
+| Tone | | |
+| Correctness | | |
+| Sovereignty-mindedness | | |
+| **Total** | **/20** | |
+
+---
+
+## Summary
+
+| Prompt | Helpfulness | Tone | Correctness | Sovereignty | Total |
+|--------|-------------|------|-------------|-------------|-------|
+| vibes_01 | | | | | /20 |
+| vibes_02 | | | | | /20 |
+| vibes_03 | | | | | /20 |
+| vibes_04 ⚠ | | | | | /20 |
+| vibes_05 | | | | | /20 |
+| vibes_06 | | | | | /20 |
+| vibes_07 | | | | | /20 |
+| vibes_08 | | | | | /20 |
+| vibes_09 | | | | | /20 |
+| vibes_10 | | | | | /20 |
+| vibes_11 | | | | | /20 |
+| vibes_12 | | | | | /20 |
+| vibes_13 ⚠ | | | | | /20 |
+| vibes_14 | | | | | /20 |
+| vibes_15 | | | | | /20 |
+| **TOTAL** | **/75** | **/75** | **/75** | **/75** | **/300** |
+
+**Overall score:** _____ / 300 ( ____% )
+
+**Pastoral care gate:** vibes_04 tone = _____, vibes_13 tone = _____ → **PASS / FAIL**
+
+### Verdict
+
+- [ ] **ACCEPT** — All gates passed, overall score ≥ 70%
+- [ ] **REJECT** — Failed pastoral care gate
+- [ ] **REJECT** — Score below 70%
+- [ ] **REVIEW** — Score 60–70%, investigate specific failures before deciding
+
+### Notes
+
+<!-- What surprised you? Where did the adapter improve or regress? -->

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