Spaces:
Build error
feat: add `headroom perf` CLI and rewrite `headroom learn` to use LLM analysis
Browse filesProxy performance logging (`headroom perf`):
- Add always-on RotatingFileHandler to ~/.headroom/logs/proxy.log (10MB x 5 backups)
- Replace scattered log lines with structured PERF lines containing model, msgs,
tok_before/after/saved, cache_read/write/hit_pct, opt_ms, and transforms
- Emit PERF lines from all three response paths (streaming Anthropic, non-streaming
Anthropic, Bedrock streaming)
- Add `headroom perf` CLI that parses proxy logs and reports token savings, cache
hit rates, prefix stability, transform effectiveness, routing breakdown, TOIN
status, and actionable recommendations
- Support --hours and --raw flags for time filtering and raw record output
Learn module rewrite (LLM-based analysis):
- Replace all regex/heuristic analyzers with a single LLM call via LiteLLM
- New SessionAnalyzer builds compact digests and sends to any of 100+ models
- Auto-detect best model from API keys (Anthropic → OpenAI → Gemini)
- Add --model flag for explicit model selection
- Enrich scanner with SessionEvent (user messages, interruptions, subagent summaries),
token usage tracking, and timestamps
- Simplify models: remove EnvironmentFact, StructureNote, Correction, CommandPattern,
RetryPattern, AnalysisReport; add SessionEvent, AnalysisResult
- Simplify writer: remove Recommender class (LLM now produces recommendations directly)
- Update tests for new analyzer and models
- headroom/cli/learn.py +32 -20
- headroom/cli/main.py +1 -0
- headroom/cli/perf.py +48 -0
- headroom/learn/__init__.py +8 -13
- headroom/learn/analyzer.py +307 -557
- headroom/learn/models.py +34 -98
- headroom/learn/scanner.py +102 -11
- headroom/learn/writer.py +49 -318
- headroom/perf/__init__.py +4 -0
- headroom/perf/analyzer.py +461 -0
- headroom/proxy/server.py +82 -31
- tests/test_learn/test_analyzer.py +283 -257
- tests/test_learn/test_integration.py +69 -157
|
@@ -89,32 +89,47 @@ def _auto_detect_agents() -> list[tuple[str, ConversationScanner, ContextWriter]
|
|
| 89 |
default="auto",
|
| 90 |
help=_AGENT_HELP,
|
| 91 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
def learn(
|
| 93 |
project: Path | None,
|
| 94 |
analyze_all: bool,
|
| 95 |
apply: bool,
|
| 96 |
agent: str,
|
|
|
|
| 97 |
) -> None:
|
| 98 |
"""Learn from past tool call failures to prevent future ones.
|
| 99 |
|
| 100 |
-
Analyzes conversation history to find failure patterns
|
| 101 |
-
missing modules, stubborn retries) and generates context
|
| 102 |
-
them from recurring.
|
| 103 |
|
| 104 |
Supports multiple coding agents: Claude Code, Codex, Gemini CLI.
|
| 105 |
-
|
| 106 |
|
| 107 |
\b
|
| 108 |
Examples:
|
| 109 |
-
headroom learn # Auto-detect agent
|
| 110 |
headroom learn --apply # Write recommendations
|
|
|
|
| 111 |
headroom learn --all # Analyze all projects
|
| 112 |
headroom learn --agent codex --all # Analyze all Codex sessions
|
| 113 |
-
headroom learn --agent claude --project ~/myapp
|
| 114 |
"""
|
| 115 |
-
from ..learn.analyzer import
|
| 116 |
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
# Determine which agents to scan
|
| 120 |
if agent == "auto":
|
|
@@ -171,25 +186,22 @@ def learn(
|
|
| 171 |
click.echo(" No conversation data found.")
|
| 172 |
continue
|
| 173 |
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
recommender = Recommender()
|
| 177 |
-
report = analyzer.analyze(proj, sessions)
|
| 178 |
total_projects += 1
|
| 179 |
-
total_failures +=
|
| 180 |
|
| 181 |
click.echo(
|
| 182 |
-
f"\n Sessions: {
|
| 183 |
-
f"Calls: {
|
| 184 |
-
f"Failures: {
|
| 185 |
-
f"Corrections: {len(report.corrections)}"
|
| 186 |
)
|
| 187 |
|
| 188 |
-
if
|
| 189 |
-
click.echo(" No failures found.")
|
| 190 |
continue
|
| 191 |
|
| 192 |
-
recommendations =
|
| 193 |
if not recommendations:
|
| 194 |
click.echo(" No actionable patterns found.")
|
| 195 |
continue
|
|
|
|
| 89 |
default="auto",
|
| 90 |
help=_AGENT_HELP,
|
| 91 |
)
|
| 92 |
+
@click.option(
|
| 93 |
+
"--model",
|
| 94 |
+
type=str,
|
| 95 |
+
default=None,
|
| 96 |
+
help="LLM model for analysis (e.g., claude-sonnet-4-6, gpt-4o, gemini/gemini-2.0-flash). "
|
| 97 |
+
"Auto-detected from API keys if not specified.",
|
| 98 |
+
)
|
| 99 |
def learn(
|
| 100 |
project: Path | None,
|
| 101 |
analyze_all: bool,
|
| 102 |
apply: bool,
|
| 103 |
agent: str,
|
| 104 |
+
model: str | None,
|
| 105 |
) -> None:
|
| 106 |
"""Learn from past tool call failures to prevent future ones.
|
| 107 |
|
| 108 |
+
Analyzes conversation history using an LLM to find failure patterns
|
| 109 |
+
(wrong paths, missing modules, stubborn retries) and generates context
|
| 110 |
+
that prevents them from recurring.
|
| 111 |
|
| 112 |
Supports multiple coding agents: Claude Code, Codex, Gemini CLI.
|
| 113 |
+
Uses LiteLLM for provider-agnostic LLM access (100+ models).
|
| 114 |
|
| 115 |
\b
|
| 116 |
Examples:
|
| 117 |
+
headroom learn # Auto-detect agent & model
|
| 118 |
headroom learn --apply # Write recommendations
|
| 119 |
+
headroom learn --model gpt-4o # Use GPT-4o for analysis
|
| 120 |
headroom learn --all # Analyze all projects
|
| 121 |
headroom learn --agent codex --all # Analyze all Codex sessions
|
|
|
|
| 122 |
"""
|
| 123 |
+
from ..learn.analyzer import SessionAnalyzer, _detect_default_model
|
| 124 |
|
| 125 |
+
# Resolve model early to fail fast with a clear message
|
| 126 |
+
try:
|
| 127 |
+
resolved_model = model or _detect_default_model()
|
| 128 |
+
except RuntimeError as e:
|
| 129 |
+
click.echo(f"Error: {e}")
|
| 130 |
+
raise SystemExit(1) from None
|
| 131 |
+
|
| 132 |
+
analyzer = SessionAnalyzer(model=resolved_model)
|
| 133 |
|
| 134 |
# Determine which agents to scan
|
| 135 |
if agent == "auto":
|
|
|
|
| 186 |
click.echo(" No conversation data found.")
|
| 187 |
continue
|
| 188 |
|
| 189 |
+
click.echo(f" Analyzing with {resolved_model}...")
|
| 190 |
+
result_data = analyzer.analyze(proj, sessions)
|
|
|
|
|
|
|
| 191 |
total_projects += 1
|
| 192 |
+
total_failures += result_data.total_failures
|
| 193 |
|
| 194 |
click.echo(
|
| 195 |
+
f"\n Sessions: {result_data.total_sessions} | "
|
| 196 |
+
f"Calls: {result_data.total_calls} | "
|
| 197 |
+
f"Failures: {result_data.total_failures} ({result_data.failure_rate:.1%})"
|
|
|
|
| 198 |
)
|
| 199 |
|
| 200 |
+
if result_data.failure_rate == 0 and not result_data.recommendations:
|
| 201 |
+
click.echo(" No failures or patterns found.")
|
| 202 |
continue
|
| 203 |
|
| 204 |
+
recommendations = result_data.recommendations
|
| 205 |
if not recommendations:
|
| 206 |
click.echo(" No actionable patterns found.")
|
| 207 |
continue
|
|
@@ -38,6 +38,7 @@ def _register_commands() -> None:
|
|
| 38 |
learn, # noqa: F401
|
| 39 |
mcp, # noqa: F401
|
| 40 |
memory, # noqa: F401
|
|
|
|
| 41 |
proxy, # noqa: F401
|
| 42 |
)
|
| 43 |
|
|
|
|
| 38 |
learn, # noqa: F401
|
| 39 |
mcp, # noqa: F401
|
| 40 |
memory, # noqa: F401
|
| 41 |
+
perf, # noqa: F401
|
| 42 |
proxy, # noqa: F401
|
| 43 |
)
|
| 44 |
|
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Performance analysis CLI command."""
|
| 2 |
+
|
| 3 |
+
import click
|
| 4 |
+
|
| 5 |
+
from .main import main
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@main.command()
|
| 9 |
+
@click.option(
|
| 10 |
+
"--hours",
|
| 11 |
+
type=float,
|
| 12 |
+
default=168.0,
|
| 13 |
+
help="Analyze logs from the last N hours (default: 168 = 7 days)",
|
| 14 |
+
)
|
| 15 |
+
@click.option("--raw", is_flag=True, help="Show raw PERF records instead of report")
|
| 16 |
+
def perf(hours: float, raw: bool) -> None:
|
| 17 |
+
"""Analyze proxy performance from logs.
|
| 18 |
+
|
| 19 |
+
\b
|
| 20 |
+
Reads logs from ~/.headroom/logs/proxy.log and shows:
|
| 21 |
+
- Token savings and compression effectiveness
|
| 22 |
+
- Cache hit rates and prefix stability
|
| 23 |
+
- Transform and routing breakdown
|
| 24 |
+
- TOIN learning status
|
| 25 |
+
- Actionable recommendations
|
| 26 |
+
|
| 27 |
+
\b
|
| 28 |
+
Examples:
|
| 29 |
+
headroom perf Analyze last 7 days
|
| 30 |
+
headroom perf --hours 24 Analyze last 24 hours
|
| 31 |
+
headroom perf --raw Show raw parsed records
|
| 32 |
+
"""
|
| 33 |
+
from headroom.perf.analyzer import format_report, parse_log_files
|
| 34 |
+
|
| 35 |
+
report = parse_log_files(last_n_hours=hours)
|
| 36 |
+
|
| 37 |
+
if raw:
|
| 38 |
+
for r in report.perf_records:
|
| 39 |
+
click.echo(
|
| 40 |
+
f"{r.timestamp} {r.request_id} model={r.model} msgs={r.num_messages} "
|
| 41 |
+
f"before={r.tokens_before} after={r.tokens_after} saved={r.tokens_saved} "
|
| 42 |
+
f"cache_read={r.cache_read} cache_write={r.cache_write} "
|
| 43 |
+
f"cache_hit={r.cache_hit_pct}% opt={r.optimization_ms:.0f}ms"
|
| 44 |
+
)
|
| 45 |
+
if not report.perf_records:
|
| 46 |
+
click.echo("No PERF records found. Run the proxy first: headroom proxy")
|
| 47 |
+
else:
|
| 48 |
+
click.echo(format_report(report))
|
|
@@ -1,17 +1,12 @@
|
|
| 1 |
-
"""Headroom Learn — offline
|
| 2 |
|
| 3 |
-
Analyzes conversation logs
|
| 4 |
-
context (CLAUDE.md, MEMORY.md, .
|
|
|
|
| 5 |
|
| 6 |
Architecture:
|
| 7 |
-
Scanner (adapter) → Analyzer (
|
| 8 |
-
├── ClaudeCodeScanner ├──
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
├── PermissionAnalyzer
|
| 12 |
-
└── CrossSessionAnalyzer
|
| 13 |
-
|
| 14 |
-
Scanners read tool-specific log formats and produce normalized ToolCall sequences.
|
| 15 |
-
Analyzers work on ToolCall — same analysis for any agent system.
|
| 16 |
-
Writers output to tool-specific context injection mechanisms.
|
| 17 |
"""
|
|
|
|
| 1 |
+
"""Headroom Learn — offline session learning for coding agents.
|
| 2 |
|
| 3 |
+
Analyzes conversation logs using Sonnet 4.6 to extract actionable patterns
|
| 4 |
+
and generates context (CLAUDE.md, MEMORY.md, AGENTS.md, etc.) that prevents
|
| 5 |
+
future token waste.
|
| 6 |
|
| 7 |
Architecture:
|
| 8 |
+
Scanner (adapter) → Analyzer (LLM) → Writer (adapter)
|
| 9 |
+
├── ClaudeCodeScanner SessionAnalyzer ├── ClaudeCodeWriter
|
| 10 |
+
└── CodexScanner (Sonnet 4.6) ├── CodexWriter
|
| 11 |
+
└── GeminiWriter
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
"""
|
|
@@ -1,640 +1,390 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
each failure. The diff between failed input and successful input is the
|
| 5 |
-
actual learning.
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
"""
|
| 10 |
|
| 11 |
from __future__ import annotations
|
| 12 |
|
|
|
|
|
|
|
| 13 |
import os
|
| 14 |
-
import re
|
| 15 |
-
from collections import Counter, defaultdict
|
| 16 |
|
| 17 |
from .models import (
|
| 18 |
-
|
| 19 |
-
CommandPattern,
|
| 20 |
-
Correction,
|
| 21 |
-
EnvironmentFact,
|
| 22 |
-
ErrorCategory,
|
| 23 |
ProjectInfo,
|
| 24 |
-
|
|
|
|
| 25 |
SessionData,
|
| 26 |
-
|
| 27 |
ToolCall,
|
| 28 |
)
|
| 29 |
|
| 30 |
-
|
| 31 |
-
_CORRECTION_WINDOW = 10
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
-
class FailureAnalyzer:
|
| 35 |
-
"""Runs all analyzers on tool call data and produces an AnalysisReport."""
|
| 36 |
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
all_calls = [tc for s in sessions for tc in s.tool_calls]
|
| 39 |
failed_calls = [tc for tc in all_calls if tc.is_error]
|
| 40 |
|
| 41 |
-
|
| 42 |
project=project,
|
|
|
|
| 43 |
total_calls=len(all_calls),
|
| 44 |
total_failures=len(failed_calls),
|
| 45 |
-
total_sessions=len(sessions),
|
| 46 |
-
waste_bytes=sum(tc.output_bytes for tc in failed_calls),
|
| 47 |
)
|
| 48 |
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
# Phase 2: Analyze specific dimensions using corrections + raw data
|
| 53 |
-
report.environment_facts = _analyze_environment(sessions)
|
| 54 |
-
report.structure_notes = _analyze_structure(sessions, report.corrections)
|
| 55 |
-
report.command_patterns = _analyze_commands(sessions, report.corrections)
|
| 56 |
-
report.retry_patterns = _analyze_retries(sessions, report.corrections)
|
| 57 |
-
report.permission_issues = _analyze_permissions(sessions)
|
| 58 |
-
report.cross_session_patterns = _analyze_cross_session(sessions)
|
| 59 |
-
|
| 60 |
-
return report
|
| 61 |
|
|
|
|
|
|
|
| 62 |
|
| 63 |
-
#
|
| 64 |
-
|
| 65 |
-
# =============================================================================
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
-
|
| 69 |
-
"""For each failure, find the next success of the same tool type.
|
| 70 |
-
|
| 71 |
-
The pair (failed_input, success_input) is a Correction — the model
|
| 72 |
-
learned something and corrected its approach.
|
| 73 |
-
"""
|
| 74 |
-
corrections: list[Correction] = []
|
| 75 |
-
|
| 76 |
-
for session in sessions:
|
| 77 |
-
calls = session.tool_calls
|
| 78 |
-
for i, tc in enumerate(calls):
|
| 79 |
-
if not tc.is_error:
|
| 80 |
-
continue
|
| 81 |
-
# Skip sibling errors (cascades, not real failures)
|
| 82 |
-
if tc.error_category == ErrorCategory.SIBLING_ERROR:
|
| 83 |
-
continue
|
| 84 |
-
# Look forward for a success of the same tool
|
| 85 |
-
for j in range(i + 1, min(i + _CORRECTION_WINDOW, len(calls))):
|
| 86 |
-
candidate = calls[j]
|
| 87 |
-
if candidate.name != tc.name or candidate.is_error:
|
| 88 |
-
continue
|
| 89 |
-
# Found a success — is the input meaningfully different?
|
| 90 |
-
if candidate.input_data == tc.input_data:
|
| 91 |
-
continue # Exact same input succeeded (transient error)
|
| 92 |
-
corrections.append(
|
| 93 |
-
Correction(
|
| 94 |
-
tool_name=tc.name,
|
| 95 |
-
failed_input=tc.input_data,
|
| 96 |
-
success_input=candidate.input_data,
|
| 97 |
-
error_category=tc.error_category,
|
| 98 |
-
session_id=session.session_id,
|
| 99 |
-
)
|
| 100 |
-
)
|
| 101 |
-
break
|
| 102 |
-
|
| 103 |
-
return corrections
|
| 104 |
|
| 105 |
|
| 106 |
# =============================================================================
|
| 107 |
-
#
|
| 108 |
# =============================================================================
|
| 109 |
|
| 110 |
|
| 111 |
-
def
|
| 112 |
-
"""
|
| 113 |
-
facts: list[EnvironmentFact] = []
|
| 114 |
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
for session in sessions:
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
cmd = tc.input_data.get("command", "")
|
| 124 |
-
if not cmd:
|
| 125 |
-
continue
|
| 126 |
-
|
| 127 |
-
if tc.is_error and tc.error_category == ErrorCategory.MODULE_NOT_FOUND:
|
| 128 |
-
prefix = _extract_python_command(cmd)
|
| 129 |
-
if prefix:
|
| 130 |
-
python_failures[prefix] += 1
|
| 131 |
-
python_sessions[prefix].add(session.session_id)
|
| 132 |
-
elif not tc.is_error:
|
| 133 |
-
prefix = _extract_python_command(cmd)
|
| 134 |
-
if prefix:
|
| 135 |
-
python_successes[prefix] += 1
|
| 136 |
-
|
| 137 |
-
if python_failures:
|
| 138 |
-
wrong = sorted(python_failures.keys(), key=lambda x: -python_failures[x])
|
| 139 |
-
correct = None
|
| 140 |
-
for cmd, _count in python_successes.most_common():
|
| 141 |
-
if cmd not in python_failures or python_successes[cmd] > python_failures[cmd] * 2:
|
| 142 |
-
correct = cmd
|
| 143 |
-
break
|
| 144 |
-
if correct and wrong:
|
| 145 |
-
total_evidence = sum(python_failures[w] for w in wrong)
|
| 146 |
-
total_sessions = len(set().union(*(python_sessions[w] for w in wrong)))
|
| 147 |
-
facts.append(
|
| 148 |
-
EnvironmentFact(
|
| 149 |
-
category="python",
|
| 150 |
-
correct_command=correct,
|
| 151 |
-
wrong_commands=wrong[:5],
|
| 152 |
-
evidence_count=total_evidence,
|
| 153 |
-
sessions_seen=total_sessions,
|
| 154 |
-
)
|
| 155 |
-
)
|
| 156 |
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
|
|
|
|
| 159 |
|
| 160 |
-
|
| 161 |
-
# Structure Analyzer (uses corrections to learn correct paths)
|
| 162 |
-
# =============================================================================
|
| 163 |
|
| 164 |
|
| 165 |
-
def
|
| 166 |
-
|
| 167 |
-
) -> list[StructureNote]:
|
| 168 |
-
"""Find file structure issues and learn correct paths from corrections."""
|
| 169 |
-
notes: list[StructureNote] = []
|
| 170 |
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
for c in corrections:
|
| 174 |
-
if c.error_category != ErrorCategory.FILE_NOT_FOUND:
|
| 175 |
-
continue
|
| 176 |
-
# Extract file paths from Read tool or from Bash commands (Codex uses shell for everything)
|
| 177 |
-
if c.tool_name in ("Read", "read"):
|
| 178 |
-
failed_path = c.failed_input.get("file_path", "")
|
| 179 |
-
success_path = c.success_input.get("file_path", "")
|
| 180 |
-
elif c.tool_name in ("Bash", "bash"):
|
| 181 |
-
failed_path = _extract_path_from_command(c.failed_input.get("command", ""))
|
| 182 |
-
success_path = _extract_path_from_command(c.success_input.get("command", ""))
|
| 183 |
-
else:
|
| 184 |
-
continue
|
| 185 |
-
if failed_path and success_path and failed_path != success_path:
|
| 186 |
-
path_corrections[failed_path][success_path] += 1
|
| 187 |
-
|
| 188 |
-
for wrong_path, correct_paths in path_corrections.items():
|
| 189 |
-
best_correct, count = correct_paths.most_common(1)[0]
|
| 190 |
-
# Make paths relative to project for readability
|
| 191 |
-
wrong_short = _shorten_path(wrong_path)
|
| 192 |
-
correct_short = _shorten_path(best_correct)
|
| 193 |
-
notes.append(
|
| 194 |
-
StructureNote(
|
| 195 |
-
category="path_correction",
|
| 196 |
-
path=wrong_short,
|
| 197 |
-
correct_path=correct_short,
|
| 198 |
-
note=f"Not at `{wrong_short}` → actually at `{correct_short}`",
|
| 199 |
-
evidence_count=count,
|
| 200 |
-
)
|
| 201 |
-
)
|
| 202 |
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
if c.tool_name not in ("Grep", "grep"):
|
| 207 |
-
continue
|
| 208 |
-
failed_path = c.failed_input.get("path", "")
|
| 209 |
-
success_path = c.success_input.get("path", "")
|
| 210 |
-
if failed_path and success_path and failed_path != success_path:
|
| 211 |
-
scope_corrections[_shorten_path(failed_path)][_shorten_path(success_path)] += 1
|
| 212 |
-
|
| 213 |
-
for wrong_scope, correct_scopes in scope_corrections.items():
|
| 214 |
-
best_scope, count = correct_scopes.most_common(1)[0]
|
| 215 |
-
notes.append(
|
| 216 |
-
StructureNote(
|
| 217 |
-
category="search_scope",
|
| 218 |
-
path=wrong_scope,
|
| 219 |
-
correct_path=best_scope,
|
| 220 |
-
note=f"Grep fails at `{wrong_scope}` → use `{best_scope}` instead",
|
| 221 |
-
evidence_count=count,
|
| 222 |
-
)
|
| 223 |
-
)
|
| 224 |
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
large_sessions: dict[str, set[str]] = defaultdict(set)
|
| 228 |
-
for session in sessions:
|
| 229 |
-
for tc in session.tool_calls:
|
| 230 |
-
if (
|
| 231 |
-
tc.name in ("Read", "read")
|
| 232 |
-
and tc.is_error
|
| 233 |
-
and tc.error_category == ErrorCategory.FILE_TOO_LARGE
|
| 234 |
-
):
|
| 235 |
-
path = tc.input_data.get("file_path", "")
|
| 236 |
-
if path:
|
| 237 |
-
short = _shorten_path(path)
|
| 238 |
-
large_files[short] += 1
|
| 239 |
-
large_sessions[short].add(session.session_id)
|
| 240 |
-
|
| 241 |
-
for path, count in large_files.most_common(10):
|
| 242 |
-
if count < 2:
|
| 243 |
-
break
|
| 244 |
-
notes.append(
|
| 245 |
-
StructureNote(
|
| 246 |
-
category="large_file",
|
| 247 |
-
path=path,
|
| 248 |
-
note=f"Too large for full read — always use offset/limit ({count} failures, {len(large_sessions[path])} sessions)",
|
| 249 |
-
evidence_count=count,
|
| 250 |
-
sessions_seen=len(large_sessions[path]),
|
| 251 |
-
)
|
| 252 |
-
)
|
| 253 |
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
for tc in session.tool_calls:
|
| 260 |
-
if (
|
| 261 |
-
tc.name in ("Read", "read")
|
| 262 |
-
and tc.is_error
|
| 263 |
-
and tc.error_category == ErrorCategory.FILE_NOT_FOUND
|
| 264 |
-
):
|
| 265 |
-
path = tc.input_data.get("file_path", "")
|
| 266 |
-
if path and path not in corrected_paths:
|
| 267 |
-
missing_no_correction[path] += 1
|
| 268 |
-
missing_sessions[path].add(session.session_id)
|
| 269 |
-
|
| 270 |
-
for path, count in missing_no_correction.most_common(10):
|
| 271 |
-
if count < 2:
|
| 272 |
-
break
|
| 273 |
-
short = _shorten_path(path)
|
| 274 |
-
notes.append(
|
| 275 |
-
StructureNote(
|
| 276 |
-
category="missing_path",
|
| 277 |
-
path=short,
|
| 278 |
-
note=f"Does not exist ({count} attempts, {len(missing_sessions[path])} sessions)",
|
| 279 |
-
evidence_count=count,
|
| 280 |
-
sessions_seen=len(missing_sessions[path]),
|
| 281 |
-
)
|
| 282 |
)
|
| 283 |
|
| 284 |
-
return
|
| 285 |
|
| 286 |
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
def _analyze_commands(
|
| 293 |
-
sessions: list[SessionData], corrections: list[Correction]
|
| 294 |
-
) -> list[CommandPattern]:
|
| 295 |
-
"""Learn specific command patterns from Bash failure→success corrections."""
|
| 296 |
-
patterns: list[CommandPattern] = []
|
| 297 |
-
|
| 298 |
-
# Analyze Bash corrections
|
| 299 |
-
bash_corrections = [c for c in corrections if c.tool_name in ("Bash", "bash")]
|
| 300 |
-
|
| 301 |
-
# Group by error category to find patterns
|
| 302 |
-
by_category: dict[ErrorCategory, list[Correction]] = defaultdict(list)
|
| 303 |
-
for c in bash_corrections:
|
| 304 |
-
by_category[c.error_category].append(c)
|
| 305 |
-
|
| 306 |
-
# User-rejected commands: model should suggest, not execute
|
| 307 |
-
rejected = by_category.get(ErrorCategory.USER_REJECTED, [])
|
| 308 |
-
if rejected:
|
| 309 |
-
# Find the most commonly rejected command patterns
|
| 310 |
-
rejected_cmds: Counter[str] = Counter()
|
| 311 |
-
for c in rejected:
|
| 312 |
-
cmd = c.failed_input.get("command", "")
|
| 313 |
-
base = _extract_command_signature(cmd)
|
| 314 |
-
if base:
|
| 315 |
-
rejected_cmds[base] += 1
|
| 316 |
-
|
| 317 |
-
for cmd_sig, count in rejected_cmds.most_common(5):
|
| 318 |
-
if count < 2:
|
| 319 |
-
break
|
| 320 |
-
patterns.append(
|
| 321 |
-
CommandPattern(
|
| 322 |
-
category="user_prefers_manual",
|
| 323 |
-
wrong_pattern=f"Executing: {cmd_sig}",
|
| 324 |
-
correct_pattern="Show the command to the user and let them run it",
|
| 325 |
-
explanation=f"User rejected this command {count} times — they prefer to run it themselves",
|
| 326 |
-
evidence_count=count,
|
| 327 |
-
sessions_seen=len(
|
| 328 |
-
{
|
| 329 |
-
c.session_id
|
| 330 |
-
for c in rejected
|
| 331 |
-
if _extract_command_signature(c.failed_input.get("command", ""))
|
| 332 |
-
== cmd_sig
|
| 333 |
-
}
|
| 334 |
-
),
|
| 335 |
-
)
|
| 336 |
-
)
|
| 337 |
|
| 338 |
-
#
|
| 339 |
-
|
| 340 |
-
for c in build_fails:
|
| 341 |
-
failed_cmd = c.failed_input.get("command", "")
|
| 342 |
-
success_cmd = c.success_input.get("command", "")
|
| 343 |
-
if failed_cmd and success_cmd:
|
| 344 |
-
patterns.append(
|
| 345 |
-
CommandPattern(
|
| 346 |
-
category="build",
|
| 347 |
-
wrong_pattern=_extract_command_signature(failed_cmd),
|
| 348 |
-
correct_pattern=_extract_command_signature(success_cmd),
|
| 349 |
-
explanation="Build failed with first form, succeeded with second",
|
| 350 |
-
evidence_count=1,
|
| 351 |
-
)
|
| 352 |
-
)
|
| 353 |
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
if wp:
|
| 363 |
-
wrong_pythons[wp] += 1
|
| 364 |
-
if cp:
|
| 365 |
-
correct_pythons[cp] += 1
|
| 366 |
-
|
| 367 |
-
if wrong_pythons and correct_pythons:
|
| 368 |
-
wrong = wrong_pythons.most_common(1)[0][0]
|
| 369 |
-
correct = correct_pythons.most_common(1)[0][0]
|
| 370 |
-
if wrong != correct:
|
| 371 |
-
patterns.append(
|
| 372 |
-
CommandPattern(
|
| 373 |
-
category="python_runtime",
|
| 374 |
-
wrong_pattern=f"`{wrong}` (modules not available)",
|
| 375 |
-
correct_pattern=f"`{correct}` (has project dependencies)",
|
| 376 |
-
explanation=f"Using `{wrong}` causes ModuleNotFoundError — use `{correct}` which has the project's venv",
|
| 377 |
-
evidence_count=sum(wrong_pythons.values()),
|
| 378 |
-
)
|
| 379 |
-
)
|
| 380 |
-
|
| 381 |
-
# Deduplicate patterns
|
| 382 |
-
seen = set()
|
| 383 |
-
unique = []
|
| 384 |
-
for p in patterns:
|
| 385 |
-
key = (p.category, p.wrong_pattern[:50])
|
| 386 |
-
if key not in seen:
|
| 387 |
-
seen.add(key)
|
| 388 |
-
unique.append(p)
|
| 389 |
-
return unique
|
| 390 |
|
| 391 |
|
| 392 |
# =============================================================================
|
| 393 |
-
#
|
| 394 |
# =============================================================================
|
| 395 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
|
| 397 |
-
def _analyze_retries(
|
| 398 |
-
sessions: list[SessionData], corrections: list[Correction]
|
| 399 |
-
) -> list[RetryPattern]:
|
| 400 |
-
"""Find stubborn retries with specific fix suggestions from corrections."""
|
| 401 |
-
patterns: list[RetryPattern] = []
|
| 402 |
-
|
| 403 |
-
# Build a correction lookup: (tool, error_category) → list of corrections
|
| 404 |
-
correction_lookup: dict[tuple[str, str], list[Correction]] = defaultdict(list)
|
| 405 |
-
for c in corrections:
|
| 406 |
-
correction_lookup[(c.tool_name, c.error_category.value)].append(c)
|
| 407 |
-
|
| 408 |
-
# Find retry streaks
|
| 409 |
-
pattern_counter: Counter[tuple[str, str, str]] = Counter()
|
| 410 |
-
max_retries: dict[tuple[str, str, str], int] = {}
|
| 411 |
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
for tc in session.tool_calls:
|
| 415 |
-
key = f"{tc.name}:{tc.error_category.value}"
|
| 416 |
-
if tc.is_error:
|
| 417 |
-
streak[key].append(tc)
|
| 418 |
-
else:
|
| 419 |
-
if len(streak.get(key, [])) >= 3:
|
| 420 |
-
calls = streak[key]
|
| 421 |
-
pk = (tc.name, calls[0].error_category.value, calls[0].input_summary[:50])
|
| 422 |
-
pattern_counter[pk] += 1
|
| 423 |
-
max_retries[pk] = max(max_retries.get(pk, 0), len(calls))
|
| 424 |
-
streak[key] = []
|
| 425 |
-
for _key, calls in streak.items():
|
| 426 |
-
if len(calls) >= 3:
|
| 427 |
-
pk = (calls[0].name, calls[0].error_category.value, calls[0].input_summary[:50])
|
| 428 |
-
pattern_counter[pk] += 1
|
| 429 |
-
max_retries[pk] = max(max_retries.get(pk, 0), len(calls))
|
| 430 |
-
|
| 431 |
-
for (tool, err_cat, input_key), count in pattern_counter.most_common(10):
|
| 432 |
-
max_r = max_retries.get((tool, err_cat, input_key), 3)
|
| 433 |
-
|
| 434 |
-
# Try to get a SPECIFIC suggestion from corrections
|
| 435 |
-
relevant_corrections = correction_lookup.get((tool, err_cat), [])
|
| 436 |
-
suggestion = _build_specific_suggestion(tool, err_cat, relevant_corrections)
|
| 437 |
-
|
| 438 |
-
patterns.append(
|
| 439 |
-
RetryPattern(
|
| 440 |
-
tool_name=tool,
|
| 441 |
-
error_category=ErrorCategory(err_cat),
|
| 442 |
-
description=f"{tool} failing with {err_cat}: {input_key}",
|
| 443 |
-
max_retries_seen=max_r,
|
| 444 |
-
suggestion=suggestion,
|
| 445 |
-
evidence_count=count,
|
| 446 |
-
)
|
| 447 |
-
)
|
| 448 |
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
if
|
| 485 |
-
|
| 486 |
-
for
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
return f"Use {' or '.join(sorted(correct_cmds))} (has project dependencies)"
|
| 492 |
-
|
| 493 |
-
# Fallback: show one correction example
|
| 494 |
-
c = corrections[0]
|
| 495 |
-
return f"What worked: {c.success_summary[:80]}"
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
def _default_suggestion(tool: str, error_category: str) -> str:
|
| 499 |
-
"""Fallback when no corrections are available."""
|
| 500 |
-
defaults = {
|
| 501 |
-
(
|
| 502 |
-
"Glob",
|
| 503 |
-
"no_matches",
|
| 504 |
-
): "Broaden pattern to **/*.ext or use ls to explore directory structure",
|
| 505 |
-
("Grep", "no_matches"): "Try case-insensitive (-i) or broaden search scope",
|
| 506 |
-
("Grep", "timeout"): "Scope Grep to a specific subdirectory — the full repo is too large",
|
| 507 |
-
("Read", "file_not_found"): "Use Glob to discover the file path before Read",
|
| 508 |
-
("Read", "file_too_large"): "Use offset/limit parameters for this file",
|
| 509 |
-
("Bash", "module_not_found"): "Use the project's virtualenv Python",
|
| 510 |
-
("Bash", "permission_denied"): "Do not retry — try a different approach",
|
| 511 |
-
("Bash", "command_not_found"): "Verify tool is installed: which <tool>",
|
| 512 |
-
("Bash", "user_rejected"): "User does not want this command executed. Show it instead.",
|
| 513 |
-
("Edit", "unknown"): "If old_string has multiple matches, add more surrounding context",
|
| 514 |
-
}
|
| 515 |
-
return defaults.get((tool, error_category), "Try an alternative approach after 2 failures")
|
| 516 |
|
| 517 |
|
| 518 |
# =============================================================================
|
| 519 |
-
#
|
| 520 |
# =============================================================================
|
| 521 |
|
| 522 |
|
| 523 |
-
def
|
| 524 |
-
"""
|
| 525 |
-
|
| 526 |
-
denied_cmds: dict[str, str] = {} # key → full command example
|
| 527 |
|
| 528 |
-
for
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
|
|
|
|
|
|
| 541 |
)
|
| 542 |
-
key = f"{tc.name}: {sig}"
|
| 543 |
-
denied[key] += 1
|
| 544 |
-
if key not in denied_cmds:
|
| 545 |
-
denied_cmds[key] = tc.input_summary[:80]
|
| 546 |
-
|
| 547 |
-
results = []
|
| 548 |
-
for key, count in denied.most_common(10):
|
| 549 |
-
if count < 3:
|
| 550 |
-
break
|
| 551 |
-
results.append(
|
| 552 |
-
f"{key} — denied {count} times. Show the command to the user instead of executing it."
|
| 553 |
)
|
| 554 |
-
return results
|
| 555 |
-
|
| 556 |
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 560 |
|
|
|
|
|
|
|
| 561 |
|
| 562 |
-
|
| 563 |
-
"""Find failure patterns that repeat across 3+ sessions."""
|
| 564 |
-
pattern_sessions: dict[str, set[str]] = defaultdict(set)
|
| 565 |
|
| 566 |
-
for session in sessions:
|
| 567 |
-
for tc in session.tool_calls:
|
| 568 |
-
if not tc.is_error or tc.error_category == ErrorCategory.SIBLING_ERROR:
|
| 569 |
-
continue
|
| 570 |
-
key = f"{tc.name}|{tc.error_category.value}|{tc.input_summary[:60]}"
|
| 571 |
-
pattern_sessions[key].add(session.session_id)
|
| 572 |
-
|
| 573 |
-
cross_session = []
|
| 574 |
-
for key, session_ids in sorted(pattern_sessions.items(), key=lambda x: -len(x[1])):
|
| 575 |
-
if len(session_ids) < 3:
|
| 576 |
-
continue
|
| 577 |
-
parts = key.split("|", 2)
|
| 578 |
-
tool, err, inp = parts[0], parts[1], parts[2] if len(parts) > 2 else "?"
|
| 579 |
-
cross_session.append(f"{tool} {err}: {inp} (across {len(session_ids)} sessions)")
|
| 580 |
-
if len(cross_session) >= 15:
|
| 581 |
-
break
|
| 582 |
|
| 583 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 584 |
|
| 585 |
|
| 586 |
# =============================================================================
|
| 587 |
-
#
|
| 588 |
# =============================================================================
|
| 589 |
|
| 590 |
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
r"(?:\s|$|-)" # Must be followed by space, end, or dash (python3 -c, python -)
|
| 594 |
-
)
|
| 595 |
|
|
|
|
|
|
|
| 596 |
|
| 597 |
-
def
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
for part in cmd.split("&&"):
|
| 602 |
-
result = _extract_python_command(part.strip())
|
| 603 |
-
if result:
|
| 604 |
-
return result
|
| 605 |
-
return None
|
| 606 |
-
m = _PYTHON_CMD_RE.match(cmd)
|
| 607 |
-
return m.group(1) if m else None
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
def _extract_command_signature(cmd: str) -> str:
|
| 611 |
-
"""Extract a normalizable command signature (first ~60 chars, no args)."""
|
| 612 |
-
cmd = cmd.strip()
|
| 613 |
-
# Truncate at first newline
|
| 614 |
-
if "\n" in cmd:
|
| 615 |
-
cmd = cmd.split("\n")[0]
|
| 616 |
-
return cmd[:60]
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
def _extract_path_from_command(cmd: str) -> str:
|
| 620 |
-
"""Extract a file path from a shell command (cat, sed, head, etc.)."""
|
| 621 |
-
cmd = cmd.strip()
|
| 622 |
-
# Look for path-like tokens (containing / and ending in a file extension)
|
| 623 |
-
path_re = re.compile(r"""(?:^|[\s'"])(/[^\s'"]+\.\w+)""")
|
| 624 |
-
match = path_re.search(cmd)
|
| 625 |
-
if match:
|
| 626 |
-
return match.group(1)
|
| 627 |
-
# Also try: last token that looks like a path
|
| 628 |
-
tokens = cmd.split()
|
| 629 |
-
for token in reversed(tokens):
|
| 630 |
-
if "/" in token and not token.startswith("-"):
|
| 631 |
-
return token.strip("'\"")
|
| 632 |
-
return ""
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
def _shorten_path(path: str) -> str:
|
| 636 |
-
"""Make a path relative to home for readability."""
|
| 637 |
-
home = os.path.expanduser("~")
|
| 638 |
-
if path.startswith(home):
|
| 639 |
-
return "~" + path[len(home) :]
|
| 640 |
-
return path
|
|
|
|
| 1 |
+
"""Session analysis via LLM — replaces all regex/heuristic analysis.
|
| 2 |
|
| 3 |
+
Pipeline: Scanner (events) → Digest Builder → LLM → Recommendations
|
|
|
|
|
|
|
| 4 |
|
| 5 |
+
No regex patterns, no static lookback windows, no hardcoded heuristics.
|
| 6 |
+
A single LLM call understands the full conversation context and produces
|
| 7 |
+
structured recommendations for CLAUDE.md / MEMORY.md.
|
| 8 |
+
|
| 9 |
+
Supports any LLM provider via LiteLLM: Anthropic, OpenAI, Google, Bedrock,
|
| 10 |
+
Ollama, and 100+ others. Auto-detects the best available model from env vars.
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
+
import json
|
| 16 |
+
import logging
|
| 17 |
import os
|
|
|
|
|
|
|
| 18 |
|
| 19 |
from .models import (
|
| 20 |
+
AnalysisResult,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
ProjectInfo,
|
| 22 |
+
Recommendation,
|
| 23 |
+
RecommendationTarget,
|
| 24 |
SessionData,
|
| 25 |
+
SessionEvent,
|
| 26 |
ToolCall,
|
| 27 |
)
|
| 28 |
|
| 29 |
+
logger = logging.getLogger(__name__)
|
|
|
|
| 30 |
|
| 31 |
+
# Default models by provider (checked in order)
|
| 32 |
+
_MODEL_DEFAULTS: list[tuple[str, str]] = [
|
| 33 |
+
("ANTHROPIC_API_KEY", "claude-sonnet-4-6"),
|
| 34 |
+
("OPENAI_API_KEY", "gpt-4o"),
|
| 35 |
+
("GEMINI_API_KEY", "gemini/gemini-2.0-flash"),
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
_MAX_DIGEST_TOKENS = 80_000 # Budget for the digest (leave room for prompt + output)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _detect_default_model() -> str:
|
| 42 |
+
"""Pick the best available model based on which API keys are set."""
|
| 43 |
+
for env_var, model in _MODEL_DEFAULTS:
|
| 44 |
+
if os.environ.get(env_var):
|
| 45 |
+
return model
|
| 46 |
+
raise RuntimeError(
|
| 47 |
+
"No LLM API key found. headroom learn needs one of:\n"
|
| 48 |
+
" export ANTHROPIC_API_KEY=sk-ant-... → uses claude-sonnet-4-6\n"
|
| 49 |
+
" export OPENAI_API_KEY=sk-... → uses gpt-4o\n"
|
| 50 |
+
" export GEMINI_API_KEY=... → uses gemini-2.0-flash\n"
|
| 51 |
+
"Or specify a model directly: headroom learn --model <litellm-model-name>"
|
| 52 |
+
)
|
| 53 |
|
|
|
|
|
|
|
| 54 |
|
| 55 |
+
class SessionAnalyzer:
|
| 56 |
+
"""Analyzes session data via LLM to produce actionable recommendations.
|
| 57 |
+
|
| 58 |
+
Uses LiteLLM for provider-agnostic access to 100+ models.
|
| 59 |
+
Auto-detects the best available model from environment API keys.
|
| 60 |
+
"""
|
| 61 |
+
|
| 62 |
+
def __init__(self, model: str | None = None):
|
| 63 |
+
self.model = model
|
| 64 |
+
|
| 65 |
+
def analyze(
|
| 66 |
+
self, project: ProjectInfo, sessions: list[SessionData]
|
| 67 |
+
) -> AnalysisResult:
|
| 68 |
+
"""Analyze sessions and produce recommendations via LLM."""
|
| 69 |
all_calls = [tc for s in sessions for tc in s.tool_calls]
|
| 70 |
failed_calls = [tc for tc in all_calls if tc.is_error]
|
| 71 |
|
| 72 |
+
result = AnalysisResult(
|
| 73 |
project=project,
|
| 74 |
+
total_sessions=len(sessions),
|
| 75 |
total_calls=len(all_calls),
|
| 76 |
total_failures=len(failed_calls),
|
|
|
|
|
|
|
| 77 |
)
|
| 78 |
|
| 79 |
+
if not failed_calls and not any(s.events for s in sessions):
|
| 80 |
+
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
+
# Build compact digest of all sessions
|
| 83 |
+
digest = _build_digest(project, sessions)
|
| 84 |
|
| 85 |
+
# Resolve model (auto-detect if not specified)
|
| 86 |
+
model = self.model or _detect_default_model()
|
|
|
|
| 87 |
|
| 88 |
+
# Call LLM for analysis
|
| 89 |
+
try:
|
| 90 |
+
raw = _call_llm(digest, model)
|
| 91 |
+
result.recommendations = _parse_llm_response(raw)
|
| 92 |
+
except Exception as e:
|
| 93 |
+
logger.warning("LLM analysis failed: %s", e)
|
| 94 |
+
# Return result with stats but no recommendations
|
| 95 |
|
| 96 |
+
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
|
| 99 |
# =============================================================================
|
| 100 |
+
# Digest Builder — compact text representation of session events
|
| 101 |
# =============================================================================
|
| 102 |
|
| 103 |
|
| 104 |
+
def _build_digest(project: ProjectInfo, sessions: list[SessionData]) -> str:
|
| 105 |
+
"""Build a token-efficient text digest of all session events.
|
|
|
|
| 106 |
|
| 107 |
+
The digest includes:
|
| 108 |
+
- Project context
|
| 109 |
+
- Per-session summaries with condensed event streams
|
| 110 |
+
- Error outputs (truncated), success indicators, user messages
|
| 111 |
+
"""
|
| 112 |
+
lines: list[str] = []
|
| 113 |
+
|
| 114 |
+
# Project header
|
| 115 |
+
lines.append(f"Project: {project.name} ({project.project_path})")
|
| 116 |
+
total_calls = sum(len(s.tool_calls) for s in sessions)
|
| 117 |
+
total_failures = sum(s.failure_count for s in sessions)
|
| 118 |
+
total_tokens_in = sum(s.total_input_tokens for s in sessions)
|
| 119 |
+
total_tokens_out = sum(s.total_output_tokens for s in sessions)
|
| 120 |
+
lines.append(
|
| 121 |
+
f"Total: {len(sessions)} sessions, {total_calls} tool calls, "
|
| 122 |
+
f"{total_failures} failures ({total_failures / total_calls:.1%})"
|
| 123 |
+
if total_calls
|
| 124 |
+
else f"Total: {len(sessions)} sessions, 0 tool calls"
|
| 125 |
+
)
|
| 126 |
+
if total_tokens_in:
|
| 127 |
+
lines.append(f"Tokens used: {total_tokens_in:,} in / {total_tokens_out:,} out")
|
| 128 |
+
lines.append("")
|
| 129 |
+
|
| 130 |
+
# Budget tracking — stop adding events when we approach the limit
|
| 131 |
+
# Rough estimate: 4 chars per token
|
| 132 |
+
char_budget = _MAX_DIGEST_TOKENS * 4
|
| 133 |
+
chars_used = sum(len(ln) for ln in lines)
|
| 134 |
|
| 135 |
for session in sessions:
|
| 136 |
+
if chars_used > char_budget:
|
| 137 |
+
lines.append(f"... (remaining {len(sessions) - sessions.index(session)} sessions truncated)")
|
| 138 |
+
break
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
+
session_header = (
|
| 141 |
+
f"=== Session {session.session_id[:12]} "
|
| 142 |
+
f"({len(session.tool_calls)} calls, {session.failure_count} failures"
|
| 143 |
+
)
|
| 144 |
+
if session.total_input_tokens:
|
| 145 |
+
session_header += f", {session.total_input_tokens:,} input tokens"
|
| 146 |
+
session_header += ") ==="
|
| 147 |
+
lines.append(session_header)
|
| 148 |
+
chars_used += len(session_header)
|
| 149 |
+
|
| 150 |
+
# Use events if available (richer context), fall back to tool_calls
|
| 151 |
+
if session.events:
|
| 152 |
+
for event in session.events:
|
| 153 |
+
if chars_used > char_budget:
|
| 154 |
+
lines.append(" ... (remaining events truncated)")
|
| 155 |
+
break
|
| 156 |
+
event_line = _format_event(event)
|
| 157 |
+
if event_line:
|
| 158 |
+
lines.append(event_line)
|
| 159 |
+
chars_used += len(event_line)
|
| 160 |
+
else:
|
| 161 |
+
for tc in session.tool_calls:
|
| 162 |
+
if chars_used > char_budget:
|
| 163 |
+
lines.append(" ... (remaining calls truncated)")
|
| 164 |
+
break
|
| 165 |
+
tc_line = _format_tool_call(tc)
|
| 166 |
+
lines.append(tc_line)
|
| 167 |
+
chars_used += len(tc_line)
|
| 168 |
|
| 169 |
+
lines.append("")
|
| 170 |
|
| 171 |
+
return "\n".join(lines)
|
|
|
|
|
|
|
| 172 |
|
| 173 |
|
| 174 |
+
def _format_event(event: SessionEvent) -> str | None:
|
| 175 |
+
"""Format a single event into a compact digest line."""
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
+
if event.type == "tool_call" and event.tool_call:
|
| 178 |
+
return _format_tool_call(event.tool_call)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
+
if event.type == "user_message" and event.text.strip():
|
| 181 |
+
text = event.text.strip()[:300]
|
| 182 |
+
return f" [{event.msg_index}] USER: \"{text}\""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
+
if event.type == "interruption":
|
| 185 |
+
return f" [{event.msg_index}] INTERRUPTED: {event.text[:150]}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
|
| 187 |
+
if event.type == "agent_summary":
|
| 188 |
+
return (
|
| 189 |
+
f" [{event.msg_index}] SUBAGENT: {event.agent_tool_count} tool calls, "
|
| 190 |
+
f"{event.agent_tokens:,} tokens, {event.agent_duration_ms / 1000:.1f}s "
|
| 191 |
+
f"— prompt: \"{event.agent_prompt[:100]}\""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
)
|
| 193 |
|
| 194 |
+
return None
|
| 195 |
|
| 196 |
|
| 197 |
+
def _format_tool_call(tc: ToolCall) -> str:
|
| 198 |
+
"""Format a single tool call into a compact digest line."""
|
| 199 |
+
status = "ERROR" if tc.is_error else "OK"
|
| 200 |
+
error_cat = f"({tc.error_category.value})" if tc.is_error else ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
|
| 202 |
+
# Input summary
|
| 203 |
+
input_str = tc.input_summary[:120]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
|
| 205 |
+
if tc.is_error:
|
| 206 |
+
# Include truncated error output for failures
|
| 207 |
+
output_preview = tc.output[:200].replace("\n", " ").strip()
|
| 208 |
+
return f" [{tc.msg_index}] {tc.name}: {input_str} → {status}{error_cat}: {output_preview}"
|
| 209 |
+
else:
|
| 210 |
+
# Just indicate success with size
|
| 211 |
+
size = f"({tc.output_bytes} bytes)" if tc.output_bytes > 0 else ""
|
| 212 |
+
return f" [{tc.msg_index}] {tc.name}: {input_str} → {status} {size}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
|
| 214 |
|
| 215 |
# =============================================================================
|
| 216 |
+
# LLM Call — Sonnet 4.6 with structured output
|
| 217 |
# =============================================================================
|
| 218 |
|
| 219 |
+
_SYSTEM_PROMPT = """\
|
| 220 |
+
You are an expert at analyzing coding agent sessions to extract actionable patterns.
|
| 221 |
+
|
| 222 |
+
You will receive a digest of tool call sessions from a coding agent (Claude Code, Codex, etc.).
|
| 223 |
+
Your job is to identify patterns that, if documented, would PREVENT TOKEN WASTE in future sessions.
|
| 224 |
+
|
| 225 |
+
Focus on:
|
| 226 |
+
1. **Environment rules** — what runtime commands work vs fail (e.g., "use uv run python, not python3")
|
| 227 |
+
2. **File structure facts** — known large files, correct paths, search scopes
|
| 228 |
+
3. **User preferences** — things the user corrected, rejected, or explicitly requested
|
| 229 |
+
4. **Failure patterns** — repeated failures that could be prevented with upfront knowledge
|
| 230 |
+
5. **Workflow rules** — subagent guidance, command execution preferences
|
| 231 |
+
6. **Token waste hotspots** — patterns that waste the most tokens (re-reads, wrong paths, retries)
|
| 232 |
+
|
| 233 |
+
Rules:
|
| 234 |
+
- Only include patterns with CLEAR evidence from the data (2+ occurrences or explicit user direction)
|
| 235 |
+
- Every recommendation must be specific and actionable (not "be careful" but "use X instead of Y")
|
| 236 |
+
- Estimate tokens saved per recommendation (how many tokens would be saved per session if this rule existed)
|
| 237 |
+
- Separate stable project facts (CONTEXT_FILE) from evolving preferences (MEMORY_FILE)
|
| 238 |
+
- CONTEXT_FILE rules go in CLAUDE.md/AGENTS.md — they are project-level, stable facts
|
| 239 |
+
- MEMORY_FILE rules go in MEMORY.md — they are session-level, evolving preferences
|
| 240 |
+
- Keep recommendations concise — each should be 1-3 lines of markdown
|
| 241 |
+
- Do NOT produce tautological rules (e.g., "use python3 not python3")
|
| 242 |
+
- Do NOT produce rules about things that only happened once (transient errors)
|
| 243 |
+
|
| 244 |
+
Return ONLY valid JSON matching this schema — no other text:
|
| 245 |
+
{
|
| 246 |
+
"context_file_rules": [
|
| 247 |
+
{
|
| 248 |
+
"section": "string — section heading (e.g., 'Environment', 'File Paths', 'Commands')",
|
| 249 |
+
"content": "string — markdown content, 1-3 bullet points",
|
| 250 |
+
"estimated_tokens_saved": "integer — tokens saved per session if rule existed",
|
| 251 |
+
"evidence_count": "integer — number of occurrences supporting this rule"
|
| 252 |
+
}
|
| 253 |
+
],
|
| 254 |
+
"memory_file_rules": [
|
| 255 |
+
{
|
| 256 |
+
"section": "string — section heading",
|
| 257 |
+
"content": "string — markdown content, 1-3 bullet points",
|
| 258 |
+
"estimated_tokens_saved": "integer",
|
| 259 |
+
"evidence_count": "integer"
|
| 260 |
+
}
|
| 261 |
+
]
|
| 262 |
+
}
|
| 263 |
+
"""
|
| 264 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
|
| 266 |
+
def _call_llm(digest: str, model: str) -> dict:
|
| 267 |
+
"""Call LLM with the session digest and return parsed JSON.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
|
| 269 |
+
Uses LiteLLM for provider-agnostic access. The model string determines
|
| 270 |
+
the provider: "claude-*" → Anthropic, "gpt-*" → OpenAI, "gemini/*" → Google, etc.
|
| 271 |
+
"""
|
| 272 |
+
import litellm
|
| 273 |
+
|
| 274 |
+
# Suppress LiteLLM's verbose logging
|
| 275 |
+
litellm.suppress_debug_info = True
|
| 276 |
+
|
| 277 |
+
# For Anthropic models, bypass ANTHROPIC_BASE_URL which may point to
|
| 278 |
+
# the user's local headroom proxy
|
| 279 |
+
api_base = None
|
| 280 |
+
if model.startswith("claude"):
|
| 281 |
+
api_base = "https://api.anthropic.com"
|
| 282 |
+
|
| 283 |
+
response = litellm.completion(
|
| 284 |
+
model=model,
|
| 285 |
+
messages=[
|
| 286 |
+
{"role": "system", "content": _SYSTEM_PROMPT},
|
| 287 |
+
{
|
| 288 |
+
"role": "user",
|
| 289 |
+
"content": (
|
| 290 |
+
"Analyze these coding agent sessions and return JSON recommendations:\n\n"
|
| 291 |
+
+ digest
|
| 292 |
+
),
|
| 293 |
+
},
|
| 294 |
+
],
|
| 295 |
+
max_tokens=4096,
|
| 296 |
+
api_base=api_base,
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
# Extract text from response
|
| 300 |
+
text = response.choices[0].message.content or ""
|
| 301 |
+
|
| 302 |
+
# Parse JSON — handle both raw JSON and ```json fenced blocks
|
| 303 |
+
text = text.strip()
|
| 304 |
+
if text.startswith("```"):
|
| 305 |
+
lines = text.split("\n")
|
| 306 |
+
lines = [ln for ln in lines[1:] if not ln.strip().startswith("```")]
|
| 307 |
+
text = "\n".join(lines)
|
| 308 |
+
|
| 309 |
+
result: dict = json.loads(text)
|
| 310 |
+
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
|
| 312 |
|
| 313 |
# =============================================================================
|
| 314 |
+
# Response Parser — LLM JSON → Recommendation list
|
| 315 |
# =============================================================================
|
| 316 |
|
| 317 |
|
| 318 |
+
def _parse_llm_response(raw: dict) -> list[Recommendation]:
|
| 319 |
+
"""Convert LLM structured output into Recommendation objects."""
|
| 320 |
+
recommendations: list[Recommendation] = []
|
|
|
|
| 321 |
|
| 322 |
+
for rule in raw.get("context_file_rules", []):
|
| 323 |
+
if not isinstance(rule, dict):
|
| 324 |
+
continue
|
| 325 |
+
section = rule.get("section", "").strip()
|
| 326 |
+
content = rule.get("content", "").strip()
|
| 327 |
+
if not section or not content:
|
| 328 |
+
continue
|
| 329 |
+
recommendations.append(
|
| 330 |
+
Recommendation(
|
| 331 |
+
target=RecommendationTarget.CONTEXT_FILE,
|
| 332 |
+
section=section,
|
| 333 |
+
content=content,
|
| 334 |
+
confidence=0.9,
|
| 335 |
+
evidence_count=_safe_int(rule.get("evidence_count", 1)),
|
| 336 |
+
estimated_tokens_saved=_safe_int(rule.get("estimated_tokens_saved", 0)),
|
| 337 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
)
|
|
|
|
|
|
|
| 339 |
|
| 340 |
+
for rule in raw.get("memory_file_rules", []):
|
| 341 |
+
if not isinstance(rule, dict):
|
| 342 |
+
continue
|
| 343 |
+
section = rule.get("section", "").strip()
|
| 344 |
+
content = rule.get("content", "").strip()
|
| 345 |
+
if not section or not content:
|
| 346 |
+
continue
|
| 347 |
+
recommendations.append(
|
| 348 |
+
Recommendation(
|
| 349 |
+
target=RecommendationTarget.MEMORY_FILE,
|
| 350 |
+
section=section,
|
| 351 |
+
content=content,
|
| 352 |
+
confidence=0.7,
|
| 353 |
+
evidence_count=_safe_int(rule.get("evidence_count", 1)),
|
| 354 |
+
estimated_tokens_saved=_safe_int(rule.get("estimated_tokens_saved", 0)),
|
| 355 |
+
)
|
| 356 |
+
)
|
| 357 |
|
| 358 |
+
# Sort by estimated token savings
|
| 359 |
+
recommendations.sort(key=lambda r: r.estimated_tokens_saved, reverse=True)
|
| 360 |
|
| 361 |
+
return recommendations
|
|
|
|
|
|
|
| 362 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
|
| 364 |
+
def _safe_int(val: object) -> int:
|
| 365 |
+
"""Safely convert a value to int."""
|
| 366 |
+
if isinstance(val, int):
|
| 367 |
+
return val
|
| 368 |
+
if isinstance(val, (float, str)):
|
| 369 |
+
try:
|
| 370 |
+
return int(val)
|
| 371 |
+
except (ValueError, TypeError):
|
| 372 |
+
return 0
|
| 373 |
+
return 0
|
| 374 |
|
| 375 |
|
| 376 |
# =============================================================================
|
| 377 |
+
# Legacy compatibility alias
|
| 378 |
# =============================================================================
|
| 379 |
|
| 380 |
|
| 381 |
+
class FailureAnalyzer:
|
| 382 |
+
"""Legacy alias for SessionAnalyzer — used by existing CLI code."""
|
|
|
|
|
|
|
| 383 |
|
| 384 |
+
def __init__(self) -> None:
|
| 385 |
+
self._analyzer = SessionAnalyzer()
|
| 386 |
|
| 387 |
+
def analyze(
|
| 388 |
+
self, project: ProjectInfo, sessions: list[SessionData]
|
| 389 |
+
) -> AnalysisResult:
|
| 390 |
+
return self._analyzer.analyze(project, sessions)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -76,13 +76,42 @@ class ToolCall:
|
|
| 76 |
return str(self.input_data)[:80]
|
| 77 |
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
@dataclass
|
| 80 |
class SessionData:
|
| 81 |
"""Normalized data from a single conversation session."""
|
| 82 |
|
| 83 |
session_id: str
|
| 84 |
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
|
|
| 85 |
timestamp: datetime | None = None
|
|
|
|
|
|
|
| 86 |
|
| 87 |
@property
|
| 88 |
def failure_count(self) -> int:
|
|
@@ -118,92 +147,6 @@ class RecommendationTarget(str, Enum):
|
|
| 118 |
MEMORY_FILE = "memory_file" # MEMORY.md or equivalent
|
| 119 |
|
| 120 |
|
| 121 |
-
@dataclass
|
| 122 |
-
class EnvironmentFact:
|
| 123 |
-
"""A learned fact about the project's runtime environment."""
|
| 124 |
-
|
| 125 |
-
category: str # "python", "build_tool", "test_runner", "linter"
|
| 126 |
-
correct_command: str # What works: "uv run python"
|
| 127 |
-
wrong_commands: list[str] = field(default_factory=list) # What fails: ["python3"]
|
| 128 |
-
evidence_count: int = 0 # How many failures support this
|
| 129 |
-
sessions_seen: int = 0 # Across how many sessions
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
@dataclass
|
| 133 |
-
class StructureNote:
|
| 134 |
-
"""A learned fact about the project's file structure."""
|
| 135 |
-
|
| 136 |
-
category: str # "large_file", "missing_path", "path_correction", "search_scope"
|
| 137 |
-
path: str # The file path in question
|
| 138 |
-
note: str # Human-readable note
|
| 139 |
-
correct_path: str = "" # If corrected, what the actual path is
|
| 140 |
-
evidence_count: int = 0
|
| 141 |
-
sessions_seen: int = 0
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
@dataclass
|
| 145 |
-
class Correction:
|
| 146 |
-
"""A failure→success pair: what failed and what worked instead.
|
| 147 |
-
|
| 148 |
-
This is the core learning primitive. By comparing the failed input to
|
| 149 |
-
the successful input, we extract specific actionable knowledge.
|
| 150 |
-
"""
|
| 151 |
-
|
| 152 |
-
tool_name: str
|
| 153 |
-
failed_input: dict # The input that failed
|
| 154 |
-
success_input: dict # The input that succeeded
|
| 155 |
-
error_category: ErrorCategory
|
| 156 |
-
session_id: str
|
| 157 |
-
|
| 158 |
-
@property
|
| 159 |
-
def failed_summary(self) -> str:
|
| 160 |
-
if self.tool_name in ("Read", "read"):
|
| 161 |
-
return str(self.failed_input.get("file_path", "?"))
|
| 162 |
-
if self.tool_name in ("Bash", "bash"):
|
| 163 |
-
return str(self.failed_input.get("command", "?"))[:100]
|
| 164 |
-
if self.tool_name in ("Grep", "grep"):
|
| 165 |
-
path = str(self.failed_input.get("path", ""))
|
| 166 |
-
pattern = str(self.failed_input.get("pattern", ""))
|
| 167 |
-
return f"pattern={pattern[:40]} path={path}"
|
| 168 |
-
return str(self.failed_input)[:80]
|
| 169 |
-
|
| 170 |
-
@property
|
| 171 |
-
def success_summary(self) -> str:
|
| 172 |
-
if self.tool_name in ("Read", "read"):
|
| 173 |
-
return str(self.success_input.get("file_path", "?"))
|
| 174 |
-
if self.tool_name in ("Bash", "bash"):
|
| 175 |
-
return str(self.success_input.get("command", "?"))[:100]
|
| 176 |
-
if self.tool_name in ("Grep", "grep"):
|
| 177 |
-
path = str(self.success_input.get("path", ""))
|
| 178 |
-
pattern = str(self.success_input.get("pattern", ""))
|
| 179 |
-
return f"pattern={pattern[:40]} path={path}"
|
| 180 |
-
return str(self.success_input)[:80]
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
@dataclass
|
| 184 |
-
class CommandPattern:
|
| 185 |
-
"""A learned pattern about how commands should be run in this project."""
|
| 186 |
-
|
| 187 |
-
category: str # "gradle", "python", "test", "build", "lint"
|
| 188 |
-
wrong_pattern: str # What fails (e.g., "cd /path && ./gradlew")
|
| 189 |
-
correct_pattern: str # What works (e.g., "../gradlew from axion/")
|
| 190 |
-
explanation: str # Why (e.g., "user rejects cd-based gradle, use relative path")
|
| 191 |
-
evidence_count: int = 0
|
| 192 |
-
sessions_seen: int = 0
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
@dataclass
|
| 196 |
-
class RetryPattern:
|
| 197 |
-
"""A pattern of stubborn retries that should be prevented."""
|
| 198 |
-
|
| 199 |
-
tool_name: str
|
| 200 |
-
error_category: ErrorCategory
|
| 201 |
-
description: str # What keeps failing
|
| 202 |
-
max_retries_seen: int # Worst case observed
|
| 203 |
-
suggestion: str # What to do instead (SPECIFIC, from success correlation)
|
| 204 |
-
evidence_count: int = 0
|
| 205 |
-
|
| 206 |
-
|
| 207 |
@dataclass
|
| 208 |
class Recommendation:
|
| 209 |
"""A concrete recommendation to write to a context/memory file."""
|
|
@@ -213,25 +156,18 @@ class Recommendation:
|
|
| 213 |
content: str # Markdown content for the section
|
| 214 |
confidence: float = 0.0 # 0-1, based on evidence strength
|
| 215 |
evidence_count: int = 0 # Number of failures supporting this
|
|
|
|
| 216 |
|
| 217 |
|
| 218 |
@dataclass
|
| 219 |
-
class
|
| 220 |
-
"""
|
| 221 |
|
| 222 |
project: ProjectInfo
|
|
|
|
| 223 |
total_calls: int = 0
|
| 224 |
total_failures: int = 0
|
| 225 |
-
|
| 226 |
-
waste_bytes: int = 0
|
| 227 |
-
|
| 228 |
-
environment_facts: list[EnvironmentFact] = field(default_factory=list)
|
| 229 |
-
structure_notes: list[StructureNote] = field(default_factory=list)
|
| 230 |
-
retry_patterns: list[RetryPattern] = field(default_factory=list)
|
| 231 |
-
command_patterns: list[CommandPattern] = field(default_factory=list)
|
| 232 |
-
corrections: list[Correction] = field(default_factory=list)
|
| 233 |
-
permission_issues: list[str] = field(default_factory=list)
|
| 234 |
-
cross_session_patterns: list[str] = field(default_factory=list)
|
| 235 |
|
| 236 |
@property
|
| 237 |
def failure_rate(self) -> float:
|
|
|
|
| 76 |
return str(self.input_data)[:80]
|
| 77 |
|
| 78 |
|
| 79 |
+
@dataclass
|
| 80 |
+
class SessionEvent:
|
| 81 |
+
"""Any event in a session — tool calls, user messages, interruptions.
|
| 82 |
+
|
| 83 |
+
Provides richer context than ToolCall alone, enabling
|
| 84 |
+
user preference mining and conversation understanding.
|
| 85 |
+
"""
|
| 86 |
+
|
| 87 |
+
type: str # "tool_call", "user_message", "interruption", "agent_summary"
|
| 88 |
+
msg_index: int
|
| 89 |
+
timestamp: str | None = None
|
| 90 |
+
|
| 91 |
+
# For tool_call type
|
| 92 |
+
tool_call: ToolCall | None = None
|
| 93 |
+
|
| 94 |
+
# For user_message type
|
| 95 |
+
text: str = ""
|
| 96 |
+
|
| 97 |
+
# For agent_summary type (subagent results)
|
| 98 |
+
agent_id: str = ""
|
| 99 |
+
agent_tool_count: int = 0
|
| 100 |
+
agent_tokens: int = 0
|
| 101 |
+
agent_duration_ms: int = 0
|
| 102 |
+
agent_prompt: str = ""
|
| 103 |
+
|
| 104 |
+
|
| 105 |
@dataclass
|
| 106 |
class SessionData:
|
| 107 |
"""Normalized data from a single conversation session."""
|
| 108 |
|
| 109 |
session_id: str
|
| 110 |
tool_calls: list[ToolCall] = field(default_factory=list)
|
| 111 |
+
events: list[SessionEvent] = field(default_factory=list)
|
| 112 |
timestamp: datetime | None = None
|
| 113 |
+
total_input_tokens: int = 0
|
| 114 |
+
total_output_tokens: int = 0
|
| 115 |
|
| 116 |
@property
|
| 117 |
def failure_count(self) -> int:
|
|
|
|
| 147 |
MEMORY_FILE = "memory_file" # MEMORY.md or equivalent
|
| 148 |
|
| 149 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
@dataclass
|
| 151 |
class Recommendation:
|
| 152 |
"""A concrete recommendation to write to a context/memory file."""
|
|
|
|
| 156 |
content: str # Markdown content for the section
|
| 157 |
confidence: float = 0.0 # 0-1, based on evidence strength
|
| 158 |
evidence_count: int = 0 # Number of failures supporting this
|
| 159 |
+
estimated_tokens_saved: int = 0 # Projected savings if recommendation is followed
|
| 160 |
|
| 161 |
|
| 162 |
@dataclass
|
| 163 |
+
class AnalysisResult:
|
| 164 |
+
"""Output of session analysis — stats + recommendations."""
|
| 165 |
|
| 166 |
project: ProjectInfo
|
| 167 |
+
total_sessions: int = 0
|
| 168 |
total_calls: int = 0
|
| 169 |
total_failures: int = 0
|
| 170 |
+
recommendations: list[Recommendation] = field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
@property
|
| 173 |
def failure_rate(self) -> float:
|
|
@@ -16,6 +16,7 @@ from .models import (
|
|
| 16 |
ErrorCategory,
|
| 17 |
ProjectInfo,
|
| 18 |
SessionData,
|
|
|
|
| 19 |
ToolCall,
|
| 20 |
)
|
| 21 |
|
|
@@ -213,6 +214,9 @@ class ClaudeCodeScanner(ConversationScanner):
|
|
| 213 |
session_id = jsonl_path.stem
|
| 214 |
tool_uses: dict[str, tuple[str, dict]] = {} # tc_id → (tool_name, input)
|
| 215 |
tool_calls: list[ToolCall] = []
|
|
|
|
|
|
|
|
|
|
| 216 |
msg_index = 0
|
| 217 |
|
| 218 |
try:
|
|
@@ -225,17 +229,43 @@ class ClaudeCodeScanner(ConversationScanner):
|
|
| 225 |
|
| 226 |
msg_index += 1
|
| 227 |
line_type = d.get("type", "")
|
|
|
|
| 228 |
|
| 229 |
if line_type == "assistant":
|
| 230 |
self._extract_tool_uses(d, tool_uses)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
elif line_type == "user":
|
| 232 |
-
self._extract_tool_results(
|
|
|
|
|
|
|
|
|
|
| 233 |
|
| 234 |
except (OSError, UnicodeDecodeError) as e:
|
| 235 |
logger.debug("Failed to read %s: %s", jsonl_path, e)
|
| 236 |
return None
|
| 237 |
|
| 238 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
|
| 240 |
def _extract_tool_uses(self, d: dict, tool_uses: dict[str, tuple[str, dict]]) -> None:
|
| 241 |
"""Extract tool_use blocks from an assistant message."""
|
|
@@ -258,7 +288,9 @@ class ClaudeCodeScanner(ConversationScanner):
|
|
| 258 |
d: dict,
|
| 259 |
tool_uses: dict[str, tuple[str, dict]],
|
| 260 |
tool_calls: list[ToolCall],
|
|
|
|
| 261 |
msg_index: int,
|
|
|
|
| 262 |
) -> None:
|
| 263 |
"""Extract tool_result blocks from a user message and match to tool_uses."""
|
| 264 |
msg = d.get("message", {})
|
|
@@ -288,18 +320,77 @@ class ClaudeCodeScanner(ConversationScanner):
|
|
| 288 |
|
| 289 |
error_cat = classify_error(result_content) if is_err else ErrorCategory.UNKNOWN
|
| 290 |
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
msg_index=msg_index,
|
| 300 |
-
|
|
|
|
| 301 |
)
|
| 302 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
|
| 304 |
|
| 305 |
def _decode_project_path(escaped_name: str) -> Path | None:
|
|
|
|
| 16 |
ErrorCategory,
|
| 17 |
ProjectInfo,
|
| 18 |
SessionData,
|
| 19 |
+
SessionEvent,
|
| 20 |
ToolCall,
|
| 21 |
)
|
| 22 |
|
|
|
|
| 214 |
session_id = jsonl_path.stem
|
| 215 |
tool_uses: dict[str, tuple[str, dict]] = {} # tc_id → (tool_name, input)
|
| 216 |
tool_calls: list[ToolCall] = []
|
| 217 |
+
events: list[SessionEvent] = []
|
| 218 |
+
total_input_tokens = 0
|
| 219 |
+
total_output_tokens = 0
|
| 220 |
msg_index = 0
|
| 221 |
|
| 222 |
try:
|
|
|
|
| 229 |
|
| 230 |
msg_index += 1
|
| 231 |
line_type = d.get("type", "")
|
| 232 |
+
ts = d.get("timestamp", None)
|
| 233 |
|
| 234 |
if line_type == "assistant":
|
| 235 |
self._extract_tool_uses(d, tool_uses)
|
| 236 |
+
# Extract token usage
|
| 237 |
+
usage = d.get("message", {}).get("usage", {})
|
| 238 |
+
total_input_tokens += usage.get("input_tokens", 0)
|
| 239 |
+
total_input_tokens += usage.get("cache_read_input_tokens", 0)
|
| 240 |
+
total_input_tokens += usage.get("cache_creation_input_tokens", 0)
|
| 241 |
+
total_output_tokens += usage.get("output_tokens", 0)
|
| 242 |
elif line_type == "user":
|
| 243 |
+
self._extract_tool_results(
|
| 244 |
+
d, tool_uses, tool_calls, events, msg_index, ts
|
| 245 |
+
)
|
| 246 |
+
self._extract_user_events(d, events, msg_index, ts)
|
| 247 |
|
| 248 |
except (OSError, UnicodeDecodeError) as e:
|
| 249 |
logger.debug("Failed to read %s: %s", jsonl_path, e)
|
| 250 |
return None
|
| 251 |
|
| 252 |
+
# Also wrap tool_calls as events for unified access
|
| 253 |
+
for tc in tool_calls:
|
| 254 |
+
if not any(
|
| 255 |
+
e.type == "tool_call" and e.tool_call is tc for e in events
|
| 256 |
+
):
|
| 257 |
+
events.append(
|
| 258 |
+
SessionEvent(type="tool_call", msg_index=tc.msg_index, tool_call=tc)
|
| 259 |
+
)
|
| 260 |
+
events.sort(key=lambda e: e.msg_index)
|
| 261 |
+
|
| 262 |
+
return SessionData(
|
| 263 |
+
session_id=session_id,
|
| 264 |
+
tool_calls=tool_calls,
|
| 265 |
+
events=events,
|
| 266 |
+
total_input_tokens=total_input_tokens,
|
| 267 |
+
total_output_tokens=total_output_tokens,
|
| 268 |
+
)
|
| 269 |
|
| 270 |
def _extract_tool_uses(self, d: dict, tool_uses: dict[str, tuple[str, dict]]) -> None:
|
| 271 |
"""Extract tool_use blocks from an assistant message."""
|
|
|
|
| 288 |
d: dict,
|
| 289 |
tool_uses: dict[str, tuple[str, dict]],
|
| 290 |
tool_calls: list[ToolCall],
|
| 291 |
+
events: list[SessionEvent],
|
| 292 |
msg_index: int,
|
| 293 |
+
timestamp: str | None = None,
|
| 294 |
) -> None:
|
| 295 |
"""Extract tool_result blocks from a user message and match to tool_uses."""
|
| 296 |
msg = d.get("message", {})
|
|
|
|
| 320 |
|
| 321 |
error_cat = classify_error(result_content) if is_err else ErrorCategory.UNKNOWN
|
| 322 |
|
| 323 |
+
tc = ToolCall(
|
| 324 |
+
name=name,
|
| 325 |
+
tool_call_id=tc_id,
|
| 326 |
+
input_data=inp,
|
| 327 |
+
output=result_content,
|
| 328 |
+
is_error=is_err,
|
| 329 |
+
error_category=error_cat,
|
| 330 |
+
msg_index=msg_index,
|
| 331 |
+
output_bytes=len(result_content.encode("utf-8")),
|
| 332 |
+
)
|
| 333 |
+
tool_calls.append(tc)
|
| 334 |
+
events.append(
|
| 335 |
+
SessionEvent(type="tool_call", msg_index=msg_index, timestamp=timestamp, tool_call=tc)
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
# Extract subagent summary from toolUseResult metadata
|
| 339 |
+
if name in ("Agent", "agent"):
|
| 340 |
+
tool_result_meta = d.get("toolUseResult", {})
|
| 341 |
+
if isinstance(tool_result_meta, dict):
|
| 342 |
+
events.append(
|
| 343 |
+
SessionEvent(
|
| 344 |
+
type="agent_summary",
|
| 345 |
+
msg_index=msg_index,
|
| 346 |
+
timestamp=timestamp,
|
| 347 |
+
agent_id=tool_result_meta.get("agentId", ""),
|
| 348 |
+
agent_tool_count=tool_result_meta.get("totalToolUseCount", 0),
|
| 349 |
+
agent_tokens=tool_result_meta.get("totalTokens", 0),
|
| 350 |
+
agent_duration_ms=tool_result_meta.get("totalDurationMs", 0),
|
| 351 |
+
agent_prompt=tool_result_meta.get("prompt", "")[:200],
|
| 352 |
+
)
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
def _extract_user_events(
|
| 356 |
+
self,
|
| 357 |
+
d: dict,
|
| 358 |
+
events: list[SessionEvent],
|
| 359 |
+
msg_index: int,
|
| 360 |
+
timestamp: str | None = None,
|
| 361 |
+
) -> None:
|
| 362 |
+
"""Extract user text messages and interruptions from a user line."""
|
| 363 |
+
msg = d.get("message", {})
|
| 364 |
+
content = msg.get("content", "")
|
| 365 |
+
|
| 366 |
+
# Human text messages have content as a string, not a list
|
| 367 |
+
if isinstance(content, str) and content.strip():
|
| 368 |
+
events.append(
|
| 369 |
+
SessionEvent(
|
| 370 |
+
type="user_message",
|
| 371 |
msg_index=msg_index,
|
| 372 |
+
timestamp=timestamp,
|
| 373 |
+
text=content[:500],
|
| 374 |
)
|
| 375 |
)
|
| 376 |
+
return
|
| 377 |
+
|
| 378 |
+
# Check for interruptions in list-format content
|
| 379 |
+
if isinstance(content, list):
|
| 380 |
+
for block in content:
|
| 381 |
+
if not isinstance(block, dict):
|
| 382 |
+
continue
|
| 383 |
+
if block.get("type") == "text":
|
| 384 |
+
text = block.get("text", "")
|
| 385 |
+
if "[Request interrupted by user" in text:
|
| 386 |
+
events.append(
|
| 387 |
+
SessionEvent(
|
| 388 |
+
type="interruption",
|
| 389 |
+
msg_index=msg_index,
|
| 390 |
+
timestamp=timestamp,
|
| 391 |
+
text=text[:200],
|
| 392 |
+
)
|
| 393 |
+
)
|
| 394 |
|
| 395 |
|
| 396 |
def _decode_project_path(escaped_name: str) -> Path | None:
|
|
@@ -12,14 +12,9 @@ from datetime import datetime, timezone
|
|
| 12 |
from pathlib import Path
|
| 13 |
|
| 14 |
from .models import (
|
| 15 |
-
AnalysisReport,
|
| 16 |
-
CommandPattern,
|
| 17 |
-
EnvironmentFact,
|
| 18 |
ProjectInfo,
|
| 19 |
Recommendation,
|
| 20 |
RecommendationTarget,
|
| 21 |
-
RetryPattern,
|
| 22 |
-
StructureNote,
|
| 23 |
)
|
| 24 |
|
| 25 |
# Marker delimiters for Headroom-managed sections
|
|
@@ -31,213 +26,6 @@ _MARKER_PATTERN = re.compile(
|
|
| 31 |
)
|
| 32 |
|
| 33 |
|
| 34 |
-
# =============================================================================
|
| 35 |
-
# Recommender: AnalysisReport → Recommendations
|
| 36 |
-
# =============================================================================
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
class Recommender:
|
| 40 |
-
"""Converts an AnalysisReport into concrete markdown recommendations.
|
| 41 |
-
|
| 42 |
-
Quality gates:
|
| 43 |
-
- Each recommendation needs min_evidence failures to be included
|
| 44 |
-
- Total recommendations need min_total_evidence to trigger any file writes
|
| 45 |
-
- Low-confidence recommendations are filtered out
|
| 46 |
-
"""
|
| 47 |
-
|
| 48 |
-
def __init__(
|
| 49 |
-
self,
|
| 50 |
-
min_evidence: int = 2,
|
| 51 |
-
min_confidence: float = 0.3,
|
| 52 |
-
min_total_evidence: int = 3,
|
| 53 |
-
):
|
| 54 |
-
self.min_evidence = min_evidence
|
| 55 |
-
self.min_confidence = min_confidence
|
| 56 |
-
self.min_total_evidence = min_total_evidence
|
| 57 |
-
|
| 58 |
-
def recommend(self, report: AnalysisReport) -> list[Recommendation]:
|
| 59 |
-
recommendations: list[Recommendation] = []
|
| 60 |
-
|
| 61 |
-
# Environment facts → CONTEXT_FILE (CLAUDE.md)
|
| 62 |
-
if report.environment_facts:
|
| 63 |
-
content = self._format_environment(report.environment_facts)
|
| 64 |
-
recommendations.append(
|
| 65 |
-
Recommendation(
|
| 66 |
-
target=RecommendationTarget.CONTEXT_FILE,
|
| 67 |
-
section="Environment",
|
| 68 |
-
content=content,
|
| 69 |
-
confidence=min(
|
| 70 |
-
1.0, sum(f.evidence_count for f in report.environment_facts) / 10
|
| 71 |
-
),
|
| 72 |
-
evidence_count=sum(f.evidence_count for f in report.environment_facts),
|
| 73 |
-
)
|
| 74 |
-
)
|
| 75 |
-
|
| 76 |
-
# Large files → CONTEXT_FILE
|
| 77 |
-
large_files = [n for n in report.structure_notes if n.category == "large_file"]
|
| 78 |
-
if large_files:
|
| 79 |
-
content = self._format_large_files(large_files)
|
| 80 |
-
recommendations.append(
|
| 81 |
-
Recommendation(
|
| 82 |
-
target=RecommendationTarget.CONTEXT_FILE,
|
| 83 |
-
section="Known Large Files",
|
| 84 |
-
content=content,
|
| 85 |
-
confidence=min(1.0, sum(n.evidence_count for n in large_files) / 5),
|
| 86 |
-
evidence_count=sum(n.evidence_count for n in large_files),
|
| 87 |
-
)
|
| 88 |
-
)
|
| 89 |
-
|
| 90 |
-
# Path corrections → CONTEXT_FILE (these are stable project structure facts)
|
| 91 |
-
path_corrections = [n for n in report.structure_notes if n.category == "path_correction"]
|
| 92 |
-
if path_corrections:
|
| 93 |
-
content = self._format_path_corrections(path_corrections)
|
| 94 |
-
recommendations.append(
|
| 95 |
-
Recommendation(
|
| 96 |
-
target=RecommendationTarget.CONTEXT_FILE,
|
| 97 |
-
section="File Path Corrections",
|
| 98 |
-
content=content,
|
| 99 |
-
confidence=0.9,
|
| 100 |
-
evidence_count=sum(n.evidence_count for n in path_corrections),
|
| 101 |
-
)
|
| 102 |
-
)
|
| 103 |
-
|
| 104 |
-
# Search scope corrections → CONTEXT_FILE
|
| 105 |
-
scope_corrections = [n for n in report.structure_notes if n.category == "search_scope"]
|
| 106 |
-
if scope_corrections:
|
| 107 |
-
content = self._format_scope_corrections(scope_corrections)
|
| 108 |
-
recommendations.append(
|
| 109 |
-
Recommendation(
|
| 110 |
-
target=RecommendationTarget.CONTEXT_FILE,
|
| 111 |
-
section="Search Scope",
|
| 112 |
-
content=content,
|
| 113 |
-
confidence=0.8,
|
| 114 |
-
evidence_count=sum(n.evidence_count for n in scope_corrections),
|
| 115 |
-
)
|
| 116 |
-
)
|
| 117 |
-
|
| 118 |
-
# Command patterns → CONTEXT_FILE (stable project-level facts)
|
| 119 |
-
if report.command_patterns:
|
| 120 |
-
content = self._format_command_patterns(report.command_patterns)
|
| 121 |
-
recommendations.append(
|
| 122 |
-
Recommendation(
|
| 123 |
-
target=RecommendationTarget.CONTEXT_FILE,
|
| 124 |
-
section="Command Patterns",
|
| 125 |
-
content=content,
|
| 126 |
-
confidence=0.9,
|
| 127 |
-
evidence_count=sum(p.evidence_count for p in report.command_patterns),
|
| 128 |
-
)
|
| 129 |
-
)
|
| 130 |
-
|
| 131 |
-
# Missing paths (no correction found) → MEMORY_FILE
|
| 132 |
-
missing_paths = [n for n in report.structure_notes if n.category == "missing_path"]
|
| 133 |
-
if missing_paths:
|
| 134 |
-
content = self._format_missing_paths(missing_paths)
|
| 135 |
-
recommendations.append(
|
| 136 |
-
Recommendation(
|
| 137 |
-
target=RecommendationTarget.MEMORY_FILE,
|
| 138 |
-
section="Known Missing Paths",
|
| 139 |
-
content=content,
|
| 140 |
-
confidence=0.6,
|
| 141 |
-
evidence_count=sum(n.evidence_count for n in missing_paths),
|
| 142 |
-
)
|
| 143 |
-
)
|
| 144 |
-
|
| 145 |
-
# Retry patterns (with specific suggestions) → MEMORY_FILE
|
| 146 |
-
if report.retry_patterns:
|
| 147 |
-
content = self._format_retry_patterns(report.retry_patterns)
|
| 148 |
-
recommendations.append(
|
| 149 |
-
Recommendation(
|
| 150 |
-
target=RecommendationTarget.MEMORY_FILE,
|
| 151 |
-
section="Retry Prevention",
|
| 152 |
-
content=content,
|
| 153 |
-
confidence=0.7,
|
| 154 |
-
evidence_count=sum(p.evidence_count for p in report.retry_patterns),
|
| 155 |
-
)
|
| 156 |
-
)
|
| 157 |
-
|
| 158 |
-
# Permission issues → MEMORY_FILE
|
| 159 |
-
if report.permission_issues:
|
| 160 |
-
content = self._format_permissions(report.permission_issues)
|
| 161 |
-
recommendations.append(
|
| 162 |
-
Recommendation(
|
| 163 |
-
target=RecommendationTarget.MEMORY_FILE,
|
| 164 |
-
section="Permission Notes",
|
| 165 |
-
content=content,
|
| 166 |
-
confidence=0.5,
|
| 167 |
-
evidence_count=len(report.permission_issues),
|
| 168 |
-
)
|
| 169 |
-
)
|
| 170 |
-
|
| 171 |
-
# Quality gate: filter out weak recommendations
|
| 172 |
-
recommendations = [
|
| 173 |
-
r
|
| 174 |
-
for r in recommendations
|
| 175 |
-
if r.evidence_count >= self.min_evidence and r.confidence >= self.min_confidence
|
| 176 |
-
]
|
| 177 |
-
|
| 178 |
-
# Quality gate: if total evidence is too low, don't recommend anything
|
| 179 |
-
total_evidence = sum(r.evidence_count for r in recommendations)
|
| 180 |
-
if total_evidence < self.min_total_evidence:
|
| 181 |
-
return []
|
| 182 |
-
|
| 183 |
-
return recommendations
|
| 184 |
-
|
| 185 |
-
def _format_environment(self, facts: list[EnvironmentFact]) -> str:
|
| 186 |
-
lines = []
|
| 187 |
-
for fact in facts:
|
| 188 |
-
wrong = ", ".join(f"`{w}`" for w in fact.wrong_commands[:3])
|
| 189 |
-
lines.append(
|
| 190 |
-
f"- **{fact.category.title()}**: use `{fact.correct_command}` "
|
| 191 |
-
f"(not {wrong} — {fact.evidence_count} failures observed)"
|
| 192 |
-
)
|
| 193 |
-
return "\n".join(lines)
|
| 194 |
-
|
| 195 |
-
def _format_large_files(self, notes: list[StructureNote]) -> str:
|
| 196 |
-
lines = ["Always use `offset` and `limit` parameters with Read for these files:"]
|
| 197 |
-
for note in sorted(notes, key=lambda n: -n.evidence_count):
|
| 198 |
-
lines.append(f"- `{note.path}` ({note.note})")
|
| 199 |
-
return "\n".join(lines)
|
| 200 |
-
|
| 201 |
-
def _format_path_corrections(self, notes: list[StructureNote]) -> str:
|
| 202 |
-
lines = ["These file paths are commonly guessed wrong. Use the correct paths:"]
|
| 203 |
-
for note in sorted(notes, key=lambda n: -n.evidence_count):
|
| 204 |
-
lines.append(f"- `{note.path}` → actually at `{note.correct_path}`")
|
| 205 |
-
return "\n".join(lines)
|
| 206 |
-
|
| 207 |
-
def _format_scope_corrections(self, notes: list[StructureNote]) -> str:
|
| 208 |
-
lines = ["When searching, use these scopes (broader paths work, narrow ones fail):"]
|
| 209 |
-
for note in sorted(notes, key=lambda n: -n.evidence_count):
|
| 210 |
-
lines.append(f"- Don't search `{note.path}` → use `{note.correct_path}` instead")
|
| 211 |
-
return "\n".join(lines)
|
| 212 |
-
|
| 213 |
-
def _format_command_patterns(self, patterns: list[CommandPattern]) -> str:
|
| 214 |
-
lines = []
|
| 215 |
-
for p in sorted(patterns, key=lambda p: -p.evidence_count):
|
| 216 |
-
lines.append(f"- **{p.category}**: {p.explanation}")
|
| 217 |
-
lines.append(f" - Wrong: {p.wrong_pattern}")
|
| 218 |
-
lines.append(f" - Correct: {p.correct_pattern}")
|
| 219 |
-
return "\n".join(lines)
|
| 220 |
-
|
| 221 |
-
def _format_missing_paths(self, notes: list[StructureNote]) -> str:
|
| 222 |
-
lines = []
|
| 223 |
-
for note in sorted(notes, key=lambda n: -n.evidence_count):
|
| 224 |
-
lines.append(f"- `{note.path}` — {note.note}")
|
| 225 |
-
return "\n".join(lines)
|
| 226 |
-
|
| 227 |
-
def _format_retry_patterns(self, patterns: list[RetryPattern]) -> str:
|
| 228 |
-
lines = []
|
| 229 |
-
for p in sorted(patterns, key=lambda p: -p.evidence_count):
|
| 230 |
-
lines.append(f"- {p.description}")
|
| 231 |
-
lines.append(f" → {p.suggestion}")
|
| 232 |
-
return "\n".join(lines)
|
| 233 |
-
|
| 234 |
-
def _format_permissions(self, issues: list[str]) -> str:
|
| 235 |
-
lines = []
|
| 236 |
-
for issue in issues:
|
| 237 |
-
lines.append(f"- {issue}")
|
| 238 |
-
return "\n".join(lines)
|
| 239 |
-
|
| 240 |
-
|
| 241 |
# =============================================================================
|
| 242 |
# Abstract Writer
|
| 243 |
# =============================================================================
|
|
@@ -273,6 +61,42 @@ class WriteResult:
|
|
| 273 |
self.content_by_file[path] = content
|
| 274 |
|
| 275 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
# =============================================================================
|
| 277 |
# Claude Code Writer
|
| 278 |
# =============================================================================
|
|
@@ -290,28 +114,23 @@ class ClaudeCodeWriter(ContextWriter):
|
|
| 290 |
result = WriteResult()
|
| 291 |
result.dry_run = dry_run
|
| 292 |
|
| 293 |
-
# Group recommendations by target
|
| 294 |
context_recs = [r for r in recommendations if r.target == RecommendationTarget.CONTEXT_FILE]
|
| 295 |
memory_recs = [r for r in recommendations if r.target == RecommendationTarget.MEMORY_FILE]
|
| 296 |
|
| 297 |
-
# Generate CLAUDE.md content
|
| 298 |
if context_recs:
|
| 299 |
claude_md_path = self._resolve_context_path(project)
|
| 300 |
-
section_content =
|
| 301 |
-
full_content =
|
| 302 |
result.add(claude_md_path, full_content)
|
| 303 |
-
|
| 304 |
if not dry_run:
|
| 305 |
claude_md_path.parent.mkdir(parents=True, exist_ok=True)
|
| 306 |
claude_md_path.write_text(full_content)
|
| 307 |
|
| 308 |
-
# Generate MEMORY.md content
|
| 309 |
if memory_recs:
|
| 310 |
memory_path = self._resolve_memory_path(project)
|
| 311 |
-
section_content =
|
| 312 |
-
full_content =
|
| 313 |
result.add(memory_path, full_content)
|
| 314 |
-
|
| 315 |
if not dry_run:
|
| 316 |
memory_path.parent.mkdir(parents=True, exist_ok=True)
|
| 317 |
memory_path.write_text(full_content)
|
|
@@ -319,47 +138,15 @@ class ClaudeCodeWriter(ContextWriter):
|
|
| 319 |
return result
|
| 320 |
|
| 321 |
def _resolve_context_path(self, project: ProjectInfo) -> Path:
|
| 322 |
-
"""Resolve path for CLAUDE.md."""
|
| 323 |
if project.context_file:
|
| 324 |
return project.context_file
|
| 325 |
return project.project_path / "CLAUDE.md"
|
| 326 |
|
| 327 |
def _resolve_memory_path(self, project: ProjectInfo) -> Path:
|
| 328 |
-
"""Resolve path for MEMORY.md."""
|
| 329 |
if project.memory_file:
|
| 330 |
return project.memory_file
|
| 331 |
return project.data_path / "memory" / "MEMORY.md"
|
| 332 |
|
| 333 |
-
def _build_section(self, recommendations: list[Recommendation]) -> str:
|
| 334 |
-
"""Build the marker-delimited section content."""
|
| 335 |
-
now = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
| 336 |
-
lines = [
|
| 337 |
-
_MARKER_START,
|
| 338 |
-
"## Headroom Learned Patterns",
|
| 339 |
-
f"*Auto-generated by `headroom learn` on {now} — do not edit manually*",
|
| 340 |
-
"",
|
| 341 |
-
]
|
| 342 |
-
|
| 343 |
-
for rec in recommendations:
|
| 344 |
-
lines.append(f"### {rec.section}")
|
| 345 |
-
lines.append(rec.content)
|
| 346 |
-
lines.append("")
|
| 347 |
-
|
| 348 |
-
lines.append(_MARKER_END)
|
| 349 |
-
return "\n".join(lines)
|
| 350 |
-
|
| 351 |
-
def _merge_into_file(self, file_path: Path, section: str) -> str:
|
| 352 |
-
"""Merge the section into an existing file, replacing any prior section."""
|
| 353 |
-
if file_path.exists():
|
| 354 |
-
existing = file_path.read_text()
|
| 355 |
-
# Replace existing headroom section
|
| 356 |
-
if _MARKER_START in existing:
|
| 357 |
-
return _MARKER_PATTERN.sub(section, existing)
|
| 358 |
-
# Append to end
|
| 359 |
-
return existing.rstrip() + "\n\n" + section + "\n"
|
| 360 |
-
else:
|
| 361 |
-
return section + "\n"
|
| 362 |
-
|
| 363 |
|
| 364 |
# =============================================================================
|
| 365 |
# Codex Writer (OpenAI Codex CLI)
|
|
@@ -367,11 +154,7 @@ class ClaudeCodeWriter(ContextWriter):
|
|
| 367 |
|
| 368 |
|
| 369 |
class CodexWriter(ContextWriter):
|
| 370 |
-
"""Writes learned patterns to AGENTS.md and instructions.md for Codex CLI.
|
| 371 |
-
|
| 372 |
-
Codex reads AGENTS.md from project root and ~/.codex/ for instructions.
|
| 373 |
-
instructions.md in ~/.codex/ acts as persistent cross-session memory.
|
| 374 |
-
"""
|
| 375 |
|
| 376 |
def write(
|
| 377 |
self,
|
|
@@ -387,8 +170,8 @@ class CodexWriter(ContextWriter):
|
|
| 387 |
|
| 388 |
if context_recs:
|
| 389 |
agents_md = project.context_file or (project.project_path / "AGENTS.md")
|
| 390 |
-
section_content =
|
| 391 |
-
full_content =
|
| 392 |
result.add(agents_md, full_content)
|
| 393 |
if not dry_run:
|
| 394 |
agents_md.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -396,8 +179,8 @@ class CodexWriter(ContextWriter):
|
|
| 396 |
|
| 397 |
if memory_recs:
|
| 398 |
instructions_md = project.memory_file or (project.data_path.parent / "instructions.md")
|
| 399 |
-
section_content =
|
| 400 |
-
full_content =
|
| 401 |
result.add(instructions_md, full_content)
|
| 402 |
if not dry_run:
|
| 403 |
instructions_md.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -405,29 +188,6 @@ class CodexWriter(ContextWriter):
|
|
| 405 |
|
| 406 |
return result
|
| 407 |
|
| 408 |
-
def _build_section(self, recommendations: list[Recommendation]) -> str:
|
| 409 |
-
now = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
| 410 |
-
lines = [
|
| 411 |
-
_MARKER_START,
|
| 412 |
-
"## Headroom Learned Patterns",
|
| 413 |
-
f"*Auto-generated by `headroom learn` on {now} — do not edit manually*",
|
| 414 |
-
"",
|
| 415 |
-
]
|
| 416 |
-
for rec in recommendations:
|
| 417 |
-
lines.append(f"### {rec.section}")
|
| 418 |
-
lines.append(rec.content)
|
| 419 |
-
lines.append("")
|
| 420 |
-
lines.append(_MARKER_END)
|
| 421 |
-
return "\n".join(lines)
|
| 422 |
-
|
| 423 |
-
def _merge_into_file(self, file_path: Path, section: str) -> str:
|
| 424 |
-
if file_path.exists():
|
| 425 |
-
existing = file_path.read_text()
|
| 426 |
-
if _MARKER_START in existing:
|
| 427 |
-
return _MARKER_PATTERN.sub(section, existing)
|
| 428 |
-
return existing.rstrip() + "\n\n" + section + "\n"
|
| 429 |
-
return section + "\n"
|
| 430 |
-
|
| 431 |
|
| 432 |
# =============================================================================
|
| 433 |
# Gemini Writer (Google Gemini CLI)
|
|
@@ -435,11 +195,7 @@ class CodexWriter(ContextWriter):
|
|
| 435 |
|
| 436 |
|
| 437 |
class GeminiWriter(ContextWriter):
|
| 438 |
-
"""Writes learned patterns to GEMINI.md for Gemini CLI.
|
| 439 |
-
|
| 440 |
-
Gemini reads GEMINI.md from project root and ~/.gemini/ for persistent context.
|
| 441 |
-
No separate memory file — everything goes into GEMINI.md.
|
| 442 |
-
"""
|
| 443 |
|
| 444 |
def write(
|
| 445 |
self,
|
|
@@ -450,40 +206,15 @@ class GeminiWriter(ContextWriter):
|
|
| 450 |
result = WriteResult()
|
| 451 |
result.dry_run = dry_run
|
| 452 |
|
| 453 |
-
|
| 454 |
-
all_recs = recommendations
|
| 455 |
-
if not all_recs:
|
| 456 |
return result
|
| 457 |
|
| 458 |
gemini_md = project.context_file or (project.project_path / "GEMINI.md")
|
| 459 |
-
section_content =
|
| 460 |
-
full_content =
|
| 461 |
result.add(gemini_md, full_content)
|
| 462 |
if not dry_run:
|
| 463 |
gemini_md.parent.mkdir(parents=True, exist_ok=True)
|
| 464 |
gemini_md.write_text(full_content)
|
| 465 |
|
| 466 |
return result
|
| 467 |
-
|
| 468 |
-
def _build_section(self, recommendations: list[Recommendation]) -> str:
|
| 469 |
-
now = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
| 470 |
-
lines = [
|
| 471 |
-
_MARKER_START,
|
| 472 |
-
"## Headroom Learned Patterns",
|
| 473 |
-
f"*Auto-generated by `headroom learn` on {now} — do not edit manually*",
|
| 474 |
-
"",
|
| 475 |
-
]
|
| 476 |
-
for rec in recommendations:
|
| 477 |
-
lines.append(f"### {rec.section}")
|
| 478 |
-
lines.append(rec.content)
|
| 479 |
-
lines.append("")
|
| 480 |
-
lines.append(_MARKER_END)
|
| 481 |
-
return "\n".join(lines)
|
| 482 |
-
|
| 483 |
-
def _merge_into_file(self, file_path: Path, section: str) -> str:
|
| 484 |
-
if file_path.exists():
|
| 485 |
-
existing = file_path.read_text()
|
| 486 |
-
if _MARKER_START in existing:
|
| 487 |
-
return _MARKER_PATTERN.sub(section, existing)
|
| 488 |
-
return existing.rstrip() + "\n\n" + section + "\n"
|
| 489 |
-
return section + "\n"
|
|
|
|
| 12 |
from pathlib import Path
|
| 13 |
|
| 14 |
from .models import (
|
|
|
|
|
|
|
|
|
|
| 15 |
ProjectInfo,
|
| 16 |
Recommendation,
|
| 17 |
RecommendationTarget,
|
|
|
|
|
|
|
| 18 |
)
|
| 19 |
|
| 20 |
# Marker delimiters for Headroom-managed sections
|
|
|
|
| 26 |
)
|
| 27 |
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
# =============================================================================
|
| 30 |
# Abstract Writer
|
| 31 |
# =============================================================================
|
|
|
|
| 61 |
self.content_by_file[path] = content
|
| 62 |
|
| 63 |
|
| 64 |
+
# =============================================================================
|
| 65 |
+
# Shared section builder
|
| 66 |
+
# =============================================================================
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _build_section(recommendations: list[Recommendation]) -> str:
|
| 70 |
+
"""Build the marker-delimited section content from recommendations."""
|
| 71 |
+
now = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
| 72 |
+
lines = [
|
| 73 |
+
_MARKER_START,
|
| 74 |
+
"## Headroom Learned Patterns",
|
| 75 |
+
f"*Auto-generated by `headroom learn` on {now} — do not edit manually*",
|
| 76 |
+
"",
|
| 77 |
+
]
|
| 78 |
+
|
| 79 |
+
for rec in recommendations:
|
| 80 |
+
lines.append(f"### {rec.section}")
|
| 81 |
+
if rec.estimated_tokens_saved > 0:
|
| 82 |
+
lines.append(f"*~{rec.estimated_tokens_saved:,} tokens/session saved*")
|
| 83 |
+
lines.append(rec.content)
|
| 84 |
+
lines.append("")
|
| 85 |
+
|
| 86 |
+
lines.append(_MARKER_END)
|
| 87 |
+
return "\n".join(lines)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _merge_into_file(file_path: Path, section: str) -> str:
|
| 91 |
+
"""Merge the section into an existing file, replacing any prior section."""
|
| 92 |
+
if file_path.exists():
|
| 93 |
+
existing = file_path.read_text()
|
| 94 |
+
if _MARKER_START in existing:
|
| 95 |
+
return _MARKER_PATTERN.sub(section, existing)
|
| 96 |
+
return existing.rstrip() + "\n\n" + section + "\n"
|
| 97 |
+
return section + "\n"
|
| 98 |
+
|
| 99 |
+
|
| 100 |
# =============================================================================
|
| 101 |
# Claude Code Writer
|
| 102 |
# =============================================================================
|
|
|
|
| 114 |
result = WriteResult()
|
| 115 |
result.dry_run = dry_run
|
| 116 |
|
|
|
|
| 117 |
context_recs = [r for r in recommendations if r.target == RecommendationTarget.CONTEXT_FILE]
|
| 118 |
memory_recs = [r for r in recommendations if r.target == RecommendationTarget.MEMORY_FILE]
|
| 119 |
|
|
|
|
| 120 |
if context_recs:
|
| 121 |
claude_md_path = self._resolve_context_path(project)
|
| 122 |
+
section_content = _build_section(context_recs)
|
| 123 |
+
full_content = _merge_into_file(claude_md_path, section_content)
|
| 124 |
result.add(claude_md_path, full_content)
|
|
|
|
| 125 |
if not dry_run:
|
| 126 |
claude_md_path.parent.mkdir(parents=True, exist_ok=True)
|
| 127 |
claude_md_path.write_text(full_content)
|
| 128 |
|
|
|
|
| 129 |
if memory_recs:
|
| 130 |
memory_path = self._resolve_memory_path(project)
|
| 131 |
+
section_content = _build_section(memory_recs)
|
| 132 |
+
full_content = _merge_into_file(memory_path, section_content)
|
| 133 |
result.add(memory_path, full_content)
|
|
|
|
| 134 |
if not dry_run:
|
| 135 |
memory_path.parent.mkdir(parents=True, exist_ok=True)
|
| 136 |
memory_path.write_text(full_content)
|
|
|
|
| 138 |
return result
|
| 139 |
|
| 140 |
def _resolve_context_path(self, project: ProjectInfo) -> Path:
|
|
|
|
| 141 |
if project.context_file:
|
| 142 |
return project.context_file
|
| 143 |
return project.project_path / "CLAUDE.md"
|
| 144 |
|
| 145 |
def _resolve_memory_path(self, project: ProjectInfo) -> Path:
|
|
|
|
| 146 |
if project.memory_file:
|
| 147 |
return project.memory_file
|
| 148 |
return project.data_path / "memory" / "MEMORY.md"
|
| 149 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
# =============================================================================
|
| 152 |
# Codex Writer (OpenAI Codex CLI)
|
|
|
|
| 154 |
|
| 155 |
|
| 156 |
class CodexWriter(ContextWriter):
|
| 157 |
+
"""Writes learned patterns to AGENTS.md and instructions.md for Codex CLI."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
def write(
|
| 160 |
self,
|
|
|
|
| 170 |
|
| 171 |
if context_recs:
|
| 172 |
agents_md = project.context_file or (project.project_path / "AGENTS.md")
|
| 173 |
+
section_content = _build_section(context_recs)
|
| 174 |
+
full_content = _merge_into_file(agents_md, section_content)
|
| 175 |
result.add(agents_md, full_content)
|
| 176 |
if not dry_run:
|
| 177 |
agents_md.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 179 |
|
| 180 |
if memory_recs:
|
| 181 |
instructions_md = project.memory_file or (project.data_path.parent / "instructions.md")
|
| 182 |
+
section_content = _build_section(memory_recs)
|
| 183 |
+
full_content = _merge_into_file(instructions_md, section_content)
|
| 184 |
result.add(instructions_md, full_content)
|
| 185 |
if not dry_run:
|
| 186 |
instructions_md.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 188 |
|
| 189 |
return result
|
| 190 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
# =============================================================================
|
| 193 |
# Gemini Writer (Google Gemini CLI)
|
|
|
|
| 195 |
|
| 196 |
|
| 197 |
class GeminiWriter(ContextWriter):
|
| 198 |
+
"""Writes learned patterns to GEMINI.md for Gemini CLI."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
|
| 200 |
def write(
|
| 201 |
self,
|
|
|
|
| 206 |
result = WriteResult()
|
| 207 |
result.dry_run = dry_run
|
| 208 |
|
| 209 |
+
if not recommendations:
|
|
|
|
|
|
|
| 210 |
return result
|
| 211 |
|
| 212 |
gemini_md = project.context_file or (project.project_path / "GEMINI.md")
|
| 213 |
+
section_content = _build_section(recommendations)
|
| 214 |
+
full_content = _merge_into_file(gemini_md, section_content)
|
| 215 |
result.add(gemini_md, full_content)
|
| 216 |
if not dry_run:
|
| 217 |
gemini_md.parent.mkdir(parents=True, exist_ok=True)
|
| 218 |
gemini_md.write_text(full_content)
|
| 219 |
|
| 220 |
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Headroom performance analysis.
|
| 2 |
+
|
| 3 |
+
Parse proxy logs and surface actionable insights via `headroom perf`.
|
| 4 |
+
"""
|
|
@@ -0,0 +1,461 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Analyze headroom proxy logs for performance insights.
|
| 2 |
+
|
| 3 |
+
Parses PERF log lines from ~/.headroom/logs/proxy.log* and produces
|
| 4 |
+
actionable reports on token savings, cache efficiency, and transform impact.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import re
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
LOG_DIR = Path.home() / ".headroom" / "logs"
|
| 14 |
+
|
| 15 |
+
# Matches: 2026-03-07 13:38:31,009 - headroom.proxy - INFO - [hr_...] PERF model=... ...
|
| 16 |
+
_PERF_RE = re.compile(
|
| 17 |
+
r"^(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d+) .* \[(?P<rid>[^\]]+)\] PERF (?P<kv>.+)$"
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
# Matches: content_router: 51 msgs — ...
|
| 21 |
+
_ROUTER_RE = re.compile(r"content_router: (?P<msgs>\d+) msgs — (?P<detail>.+)$")
|
| 22 |
+
|
| 23 |
+
# Matches: Transform content_router: 52503 -> 26006 tokens (saved 26497)
|
| 24 |
+
_TRANSFORM_RE = re.compile(
|
| 25 |
+
r"Transform (?P<name>\w+): (?P<before>\d+) -> (?P<after>\d+) tokens \(saved (?P<saved>\d+)\)"
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
# Matches: Pipeline complete: 52503 -> 26006 tokens (saved 26497, 50.5% reduction)
|
| 29 |
+
_PIPELINE_RE = re.compile(
|
| 30 |
+
r"Pipeline complete: (?P<before>\d+) -> (?P<after>\d+) tokens "
|
| 31 |
+
r"\(saved (?P<saved>\d+), (?P<pct>[\d.]+)% reduction\)"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# Matches: TOIN: 105 patterns, 3837 compressions, 0 retrievals, 0.0% retrieval rate
|
| 35 |
+
_TOIN_RE = re.compile(
|
| 36 |
+
r"TOIN: (?P<patterns>\d+) patterns, (?P<compressions>\d+) compressions, "
|
| 37 |
+
r"(?P<retrievals>\d+) retrievals, (?P<rate>[\d.]+)% retrieval rate"
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _parse_kv(kv_str: str) -> dict[str, str]:
|
| 42 |
+
"""Parse key=value pairs from a PERF log line."""
|
| 43 |
+
result: dict[str, str] = {}
|
| 44 |
+
for part in kv_str.split():
|
| 45 |
+
if "=" in part:
|
| 46 |
+
k, v = part.split("=", 1)
|
| 47 |
+
result[k] = v
|
| 48 |
+
return result
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@dataclass
|
| 52 |
+
class PerfRecord:
|
| 53 |
+
"""A single parsed PERF log entry."""
|
| 54 |
+
|
| 55 |
+
timestamp: str
|
| 56 |
+
request_id: str
|
| 57 |
+
model: str = ""
|
| 58 |
+
num_messages: int = 0
|
| 59 |
+
tokens_before: int = 0
|
| 60 |
+
tokens_after: int = 0
|
| 61 |
+
tokens_saved: int = 0
|
| 62 |
+
cache_read: int = 0
|
| 63 |
+
cache_write: int = 0
|
| 64 |
+
cache_hit_pct: int = 0
|
| 65 |
+
optimization_ms: float = 0
|
| 66 |
+
transforms: list[str] = field(default_factory=list)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@dataclass
|
| 70 |
+
class RouterRecord:
|
| 71 |
+
"""A parsed content_router summary line."""
|
| 72 |
+
|
| 73 |
+
timestamp: str
|
| 74 |
+
num_messages: int = 0
|
| 75 |
+
compressed: int = 0
|
| 76 |
+
excluded: int = 0
|
| 77 |
+
skipped: int = 0
|
| 78 |
+
unchanged: int = 0
|
| 79 |
+
content_blocks: int = 0
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@dataclass
|
| 83 |
+
class TransformRecord:
|
| 84 |
+
"""A parsed per-transform line."""
|
| 85 |
+
|
| 86 |
+
timestamp: str
|
| 87 |
+
name: str = ""
|
| 88 |
+
tokens_before: int = 0
|
| 89 |
+
tokens_after: int = 0
|
| 90 |
+
tokens_saved: int = 0
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@dataclass
|
| 94 |
+
class ToinRecord:
|
| 95 |
+
"""A parsed TOIN status line."""
|
| 96 |
+
|
| 97 |
+
timestamp: str
|
| 98 |
+
patterns: int = 0
|
| 99 |
+
compressions: int = 0
|
| 100 |
+
retrievals: int = 0
|
| 101 |
+
retrieval_rate: float = 0.0
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@dataclass
|
| 105 |
+
class PerfReport:
|
| 106 |
+
"""Aggregated performance report."""
|
| 107 |
+
|
| 108 |
+
perf_records: list[PerfRecord] = field(default_factory=list)
|
| 109 |
+
router_records: list[RouterRecord] = field(default_factory=list)
|
| 110 |
+
transform_records: list[TransformRecord] = field(default_factory=list)
|
| 111 |
+
toin_records: list[ToinRecord] = field(default_factory=list)
|
| 112 |
+
log_files_read: int = 0
|
| 113 |
+
total_lines_parsed: int = 0
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def parse_log_files(last_n_hours: float = 168.0) -> PerfReport:
|
| 117 |
+
"""Parse all proxy log files and return structured records.
|
| 118 |
+
|
| 119 |
+
Args:
|
| 120 |
+
last_n_hours: Only include records from the last N hours (default 7 days).
|
| 121 |
+
|
| 122 |
+
Returns:
|
| 123 |
+
PerfReport with all parsed records.
|
| 124 |
+
"""
|
| 125 |
+
report = PerfReport()
|
| 126 |
+
|
| 127 |
+
if not LOG_DIR.exists():
|
| 128 |
+
return report
|
| 129 |
+
|
| 130 |
+
# Collect log files: proxy.log, proxy.log.1, proxy.log.2, ...
|
| 131 |
+
log_files = sorted(LOG_DIR.glob("proxy.log*"), key=lambda p: p.stat().st_mtime)
|
| 132 |
+
|
| 133 |
+
for log_file in log_files:
|
| 134 |
+
report.log_files_read += 1
|
| 135 |
+
try:
|
| 136 |
+
with open(log_file, encoding="utf-8", errors="replace") as f:
|
| 137 |
+
for line in f:
|
| 138 |
+
report.total_lines_parsed += 1
|
| 139 |
+
line = line.rstrip()
|
| 140 |
+
|
| 141 |
+
# PERF lines (richest data)
|
| 142 |
+
m = _PERF_RE.match(line)
|
| 143 |
+
if m:
|
| 144 |
+
kv = _parse_kv(m.group("kv"))
|
| 145 |
+
transforms_str = kv.get("transforms", "none")
|
| 146 |
+
transforms = transforms_str.split(",") if transforms_str != "none" else []
|
| 147 |
+
report.perf_records.append(
|
| 148 |
+
PerfRecord(
|
| 149 |
+
timestamp=m.group("ts"),
|
| 150 |
+
request_id=m.group("rid"),
|
| 151 |
+
model=kv.get("model", ""),
|
| 152 |
+
num_messages=int(kv.get("msgs", 0)),
|
| 153 |
+
tokens_before=int(kv.get("tok_before", 0)),
|
| 154 |
+
tokens_after=int(kv.get("tok_after", 0)),
|
| 155 |
+
tokens_saved=int(kv.get("tok_saved", 0)),
|
| 156 |
+
cache_read=int(kv.get("cache_read", 0)),
|
| 157 |
+
cache_write=int(kv.get("cache_write", 0)),
|
| 158 |
+
cache_hit_pct=int(kv.get("cache_hit_pct", 0)),
|
| 159 |
+
optimization_ms=float(kv.get("opt_ms", 0)),
|
| 160 |
+
transforms=transforms,
|
| 161 |
+
)
|
| 162 |
+
)
|
| 163 |
+
continue
|
| 164 |
+
|
| 165 |
+
# content_router summary lines
|
| 166 |
+
if "content_router:" in line and "msgs" in line:
|
| 167 |
+
m2 = _ROUTER_RE.search(line)
|
| 168 |
+
if m2:
|
| 169 |
+
ts = line[:23]
|
| 170 |
+
detail = m2.group("detail")
|
| 171 |
+
rec = RouterRecord(
|
| 172 |
+
timestamp=ts,
|
| 173 |
+
num_messages=int(m2.group("msgs")),
|
| 174 |
+
)
|
| 175 |
+
# Parse counts from detail string
|
| 176 |
+
for part in detail.split(","):
|
| 177 |
+
part = part.strip()
|
| 178 |
+
num_match = re.match(r"(\d+)\s+(\w+)", part)
|
| 179 |
+
if num_match:
|
| 180 |
+
count = int(num_match.group(1))
|
| 181 |
+
kind = num_match.group(2)
|
| 182 |
+
if kind == "compressed":
|
| 183 |
+
rec.compressed = count
|
| 184 |
+
elif kind == "excluded":
|
| 185 |
+
rec.excluded = count
|
| 186 |
+
elif kind == "skipped":
|
| 187 |
+
rec.skipped = count
|
| 188 |
+
elif kind == "unchanged":
|
| 189 |
+
rec.unchanged = count
|
| 190 |
+
elif kind == "content" and "block" in part:
|
| 191 |
+
rec.content_blocks = count
|
| 192 |
+
report.router_records.append(rec)
|
| 193 |
+
continue
|
| 194 |
+
|
| 195 |
+
# Per-transform lines
|
| 196 |
+
m3 = _TRANSFORM_RE.search(line)
|
| 197 |
+
if m3:
|
| 198 |
+
ts = line[:23]
|
| 199 |
+
report.transform_records.append(
|
| 200 |
+
TransformRecord(
|
| 201 |
+
timestamp=ts,
|
| 202 |
+
name=m3.group("name"),
|
| 203 |
+
tokens_before=int(m3.group("before")),
|
| 204 |
+
tokens_after=int(m3.group("after")),
|
| 205 |
+
tokens_saved=int(m3.group("saved")),
|
| 206 |
+
)
|
| 207 |
+
)
|
| 208 |
+
continue
|
| 209 |
+
|
| 210 |
+
# TOIN status lines
|
| 211 |
+
m4 = _TOIN_RE.search(line)
|
| 212 |
+
if m4:
|
| 213 |
+
ts = line[:23]
|
| 214 |
+
report.toin_records.append(
|
| 215 |
+
ToinRecord(
|
| 216 |
+
timestamp=ts,
|
| 217 |
+
patterns=int(m4.group("patterns")),
|
| 218 |
+
compressions=int(m4.group("compressions")),
|
| 219 |
+
retrievals=int(m4.group("retrievals")),
|
| 220 |
+
retrieval_rate=float(m4.group("rate")),
|
| 221 |
+
)
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
except OSError:
|
| 225 |
+
continue
|
| 226 |
+
|
| 227 |
+
return report
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def format_report(report: PerfReport) -> str:
|
| 231 |
+
"""Format a PerfReport into a human-readable string."""
|
| 232 |
+
lines: list[str] = []
|
| 233 |
+
|
| 234 |
+
if not report.perf_records and not report.router_records:
|
| 235 |
+
lines.append("No performance data found in ~/.headroom/logs/")
|
| 236 |
+
lines.append("")
|
| 237 |
+
lines.append("Start the proxy to begin collecting data:")
|
| 238 |
+
lines.append(" headroom proxy")
|
| 239 |
+
return "\n".join(lines)
|
| 240 |
+
|
| 241 |
+
# Header
|
| 242 |
+
lines.append("Headroom Performance Report")
|
| 243 |
+
lines.append("=" * 60)
|
| 244 |
+
lines.append("")
|
| 245 |
+
|
| 246 |
+
records = report.perf_records
|
| 247 |
+
|
| 248 |
+
if records:
|
| 249 |
+
# Overview
|
| 250 |
+
total_before = sum(r.tokens_before for r in records)
|
| 251 |
+
total_after = sum(r.tokens_after for r in records)
|
| 252 |
+
total_saved = sum(r.tokens_saved for r in records)
|
| 253 |
+
pct = (total_saved / total_before * 100) if total_before > 0 else 0
|
| 254 |
+
|
| 255 |
+
models = {r.model for r in records}
|
| 256 |
+
lines.append(f"Requests: {len(records)}")
|
| 257 |
+
lines.append(f"Models: {', '.join(sorted(models))}")
|
| 258 |
+
lines.append(
|
| 259 |
+
f"Tokens: {total_before:,} input -> {total_after:,} after transforms "
|
| 260 |
+
f"({pct:.1f}% reduction)"
|
| 261 |
+
)
|
| 262 |
+
lines.append(f"Total saved: {total_saved:,} tokens")
|
| 263 |
+
lines.append("")
|
| 264 |
+
|
| 265 |
+
# Cache analysis
|
| 266 |
+
cache_records = [r for r in records if (r.cache_read + r.cache_write) > 0]
|
| 267 |
+
if cache_records:
|
| 268 |
+
lines.append("Cache Performance")
|
| 269 |
+
lines.append("-" * 40)
|
| 270 |
+
total_cr = sum(r.cache_read for r in cache_records)
|
| 271 |
+
total_cw = sum(r.cache_write for r in cache_records)
|
| 272 |
+
total_cache = total_cr + total_cw
|
| 273 |
+
hit_pct = (total_cr / total_cache * 100) if total_cache > 0 else 0
|
| 274 |
+
lines.append(f" Cache read: {total_cr:,} tokens")
|
| 275 |
+
lines.append(f" Cache write: {total_cw:,} tokens")
|
| 276 |
+
lines.append(f" Hit rate: {hit_pct:.1f}%")
|
| 277 |
+
|
| 278 |
+
# Identify cache instability: requests where write >> read
|
| 279 |
+
unstable = [r for r in cache_records if r.cache_write > r.cache_read * 2]
|
| 280 |
+
if unstable:
|
| 281 |
+
lines.append(
|
| 282 |
+
f" Unstable: {len(unstable)}/{len(cache_records)} requests "
|
| 283 |
+
f"had cache_write > 2x cache_read"
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
# Show cache progression (first 5 vs last 5)
|
| 287 |
+
if len(cache_records) >= 10:
|
| 288 |
+
first5_cr = sum(r.cache_read for r in cache_records[:5])
|
| 289 |
+
first5_cw = sum(r.cache_write for r in cache_records[:5])
|
| 290 |
+
last5_cr = sum(r.cache_read for r in cache_records[-5:])
|
| 291 |
+
last5_cw = sum(r.cache_write for r in cache_records[-5:])
|
| 292 |
+
lines.append(f" First 5 avg: read={first5_cr // 5:,} write={first5_cw // 5:,}")
|
| 293 |
+
lines.append(f" Last 5 avg: read={last5_cr // 5:,} write={last5_cw // 5:,}")
|
| 294 |
+
if last5_cr > first5_cr * 2:
|
| 295 |
+
lines.append(" -> Cache stabilizing over conversation lifetime")
|
| 296 |
+
elif first5_cw > first5_cr * 3:
|
| 297 |
+
lines.append(
|
| 298 |
+
" ! Early turns have poor cache hits — "
|
| 299 |
+
"compression decisions may be flipping"
|
| 300 |
+
)
|
| 301 |
+
lines.append("")
|
| 302 |
+
|
| 303 |
+
# Optimization latency
|
| 304 |
+
opt_times = [r.optimization_ms for r in records if r.optimization_ms > 0]
|
| 305 |
+
if opt_times:
|
| 306 |
+
avg_opt = sum(opt_times) / len(opt_times)
|
| 307 |
+
max_opt = max(opt_times)
|
| 308 |
+
lines.append("Optimization Overhead")
|
| 309 |
+
lines.append("-" * 40)
|
| 310 |
+
lines.append(f" Average: {avg_opt:.0f}ms")
|
| 311 |
+
lines.append(f" Max: {max_opt:.0f}ms")
|
| 312 |
+
slow = [t for t in opt_times if t > 500]
|
| 313 |
+
if slow:
|
| 314 |
+
lines.append(f" >500ms: {len(slow)} requests")
|
| 315 |
+
lines.append("")
|
| 316 |
+
|
| 317 |
+
# Conversation size distribution
|
| 318 |
+
msg_counts = [r.num_messages for r in records if r.num_messages > 0]
|
| 319 |
+
if msg_counts:
|
| 320 |
+
lines.append("Conversation Size")
|
| 321 |
+
lines.append("-" * 40)
|
| 322 |
+
lines.append(f" Min msgs: {min(msg_counts)}")
|
| 323 |
+
lines.append(f" Max msgs: {max(msg_counts)}")
|
| 324 |
+
lines.append(f" Avg msgs: {sum(msg_counts) // len(msg_counts)}")
|
| 325 |
+
lines.append("")
|
| 326 |
+
|
| 327 |
+
# Transform effectiveness (from transform_records)
|
| 328 |
+
if report.transform_records:
|
| 329 |
+
lines.append("Transform Effectiveness")
|
| 330 |
+
lines.append("-" * 40)
|
| 331 |
+
by_name: dict[str, list[TransformRecord]] = {}
|
| 332 |
+
for tr in report.transform_records:
|
| 333 |
+
by_name.setdefault(tr.name, []).append(tr)
|
| 334 |
+
for name, recs in sorted(by_name.items(), key=lambda x: -sum(r.tokens_saved for r in x[1])):
|
| 335 |
+
total_s = sum(r.tokens_saved for r in recs)
|
| 336 |
+
total_b = sum(r.tokens_before for r in recs)
|
| 337 |
+
avg_pct = (total_s / total_b * 100) if total_b > 0 else 0
|
| 338 |
+
lines.append(
|
| 339 |
+
f" {name}: {avg_pct:.1f}% avg reduction, {len(recs)} uses, {total_s:,} saved"
|
| 340 |
+
)
|
| 341 |
+
lines.append("")
|
| 342 |
+
|
| 343 |
+
# Router routing breakdown
|
| 344 |
+
if report.router_records:
|
| 345 |
+
lines.append("Content Router Routing")
|
| 346 |
+
lines.append("-" * 40)
|
| 347 |
+
total_compressed = sum(r.compressed for r in report.router_records)
|
| 348 |
+
total_excluded = sum(r.excluded for r in report.router_records)
|
| 349 |
+
total_skipped = sum(r.skipped for r in report.router_records)
|
| 350 |
+
total_unchanged = sum(r.unchanged for r in report.router_records)
|
| 351 |
+
total_all = total_compressed + total_excluded + total_skipped + total_unchanged
|
| 352 |
+
if total_all > 0:
|
| 353 |
+
lines.append(
|
| 354 |
+
f" Compressed: {total_compressed} ({total_compressed / total_all * 100:.0f}%)"
|
| 355 |
+
)
|
| 356 |
+
lines.append(
|
| 357 |
+
f" Excluded: {total_excluded} ({total_excluded / total_all * 100:.0f}%) — Read/Glob outputs"
|
| 358 |
+
)
|
| 359 |
+
lines.append(
|
| 360 |
+
f" Skipped: {total_skipped} ({total_skipped / total_all * 100:.0f}%) — <50 words"
|
| 361 |
+
)
|
| 362 |
+
lines.append(
|
| 363 |
+
f" Unchanged: {total_unchanged} ({total_unchanged / total_all * 100:.0f}%) — ratio too high"
|
| 364 |
+
)
|
| 365 |
+
if total_excluded > total_compressed * 3:
|
| 366 |
+
lines.append(" ! Excluded tools dominate — consider compressing stale Read outputs")
|
| 367 |
+
lines.append("")
|
| 368 |
+
|
| 369 |
+
# TOIN status
|
| 370 |
+
if report.toin_records:
|
| 371 |
+
latest = report.toin_records[-1]
|
| 372 |
+
lines.append("TOIN Learning")
|
| 373 |
+
lines.append("-" * 40)
|
| 374 |
+
lines.append(f" Patterns: {latest.patterns}")
|
| 375 |
+
lines.append(f" Compressions: {latest.compressions:,}")
|
| 376 |
+
lines.append(f" Retrievals: {latest.retrievals} ({latest.retrieval_rate}%)")
|
| 377 |
+
if latest.retrieval_rate == 0 and latest.compressions > 100:
|
| 378 |
+
lines.append(" ! 0% retrieval rate — TOIN learning but never used")
|
| 379 |
+
lines.append("")
|
| 380 |
+
|
| 381 |
+
# Recommendations
|
| 382 |
+
recommendations = _generate_recommendations(report)
|
| 383 |
+
if recommendations:
|
| 384 |
+
lines.append("Recommendations")
|
| 385 |
+
lines.append("-" * 40)
|
| 386 |
+
for i, rec in enumerate(recommendations, 1):
|
| 387 |
+
lines.append(f" {i}. {rec}")
|
| 388 |
+
lines.append("")
|
| 389 |
+
|
| 390 |
+
# Footer
|
| 391 |
+
lines.append(
|
| 392 |
+
f"Log files: {report.log_files_read} | Lines parsed: {report.total_lines_parsed:,}"
|
| 393 |
+
)
|
| 394 |
+
lines.append(f"Log dir: {LOG_DIR}")
|
| 395 |
+
|
| 396 |
+
return "\n".join(lines)
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def _generate_recommendations(report: PerfReport) -> list[str]:
|
| 400 |
+
"""Generate actionable recommendations from the report data."""
|
| 401 |
+
recs: list[str] = []
|
| 402 |
+
|
| 403 |
+
if report.perf_records:
|
| 404 |
+
cache_recs = [r for r in report.perf_records if (r.cache_read + r.cache_write) > 0]
|
| 405 |
+
if cache_recs:
|
| 406 |
+
total_cr = sum(r.cache_read for r in cache_recs)
|
| 407 |
+
total_cw = sum(r.cache_write for r in cache_recs)
|
| 408 |
+
if total_cw > total_cr * 1.5:
|
| 409 |
+
recs.append(
|
| 410 |
+
"Cache prefix unstable — compression decisions may be flipping "
|
| 411 |
+
"across turns due to adaptive min_ratio threshold"
|
| 412 |
+
)
|
| 413 |
+
|
| 414 |
+
# Check early-turn instability
|
| 415 |
+
if len(cache_recs) >= 5:
|
| 416 |
+
first5 = cache_recs[:5]
|
| 417 |
+
early_ratio = sum(r.cache_read for r in first5) / max(
|
| 418 |
+
1, sum(r.cache_write for r in first5)
|
| 419 |
+
)
|
| 420 |
+
if early_ratio < 0.5:
|
| 421 |
+
recs.append(
|
| 422 |
+
"First 5 turns have very low cache hit ratio — "
|
| 423 |
+
"consider pinning compression decisions for prefix stability"
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
# Optimization latency
|
| 427 |
+
slow = [r for r in report.perf_records if r.optimization_ms > 500]
|
| 428 |
+
if len(slow) > len(report.perf_records) * 0.2:
|
| 429 |
+
recs.append(
|
| 430 |
+
f"{len(slow)} requests took >500ms for optimization — "
|
| 431 |
+
"consider disabling LLMLingua or reducing transform pipeline"
|
| 432 |
+
)
|
| 433 |
+
|
| 434 |
+
if report.router_records:
|
| 435 |
+
total_excluded = sum(r.excluded for r in report.router_records)
|
| 436 |
+
total_compressed = sum(r.compressed for r in report.router_records)
|
| 437 |
+
if total_excluded > 0 and total_compressed > 0:
|
| 438 |
+
if total_excluded > total_compressed * 3:
|
| 439 |
+
recs.append(
|
| 440 |
+
"Read/Glob outputs are majority of messages but excluded — "
|
| 441 |
+
"compress stale reads (>10 turns old) for significant savings"
|
| 442 |
+
)
|
| 443 |
+
|
| 444 |
+
if report.toin_records:
|
| 445 |
+
latest = report.toin_records[-1]
|
| 446 |
+
if latest.retrieval_rate == 0 and latest.compressions > 100:
|
| 447 |
+
recs.append(
|
| 448 |
+
"TOIN has 0% retrieval rate with "
|
| 449 |
+
f"{latest.compressions:,} compressions — review CCR integration"
|
| 450 |
+
)
|
| 451 |
+
|
| 452 |
+
# Check cache_aligner effectiveness from transform records
|
| 453 |
+
for tr in report.transform_records:
|
| 454 |
+
if tr.name == "cache_aligner" and tr.tokens_saved < 10:
|
| 455 |
+
recs.append(
|
| 456 |
+
"cache_aligner saving <10 tokens — "
|
| 457 |
+
"consider disabling (system prompt likely has no dynamic content)"
|
| 458 |
+
)
|
| 459 |
+
break
|
| 460 |
+
|
| 461 |
+
return recs
|
|
@@ -140,6 +140,41 @@ logging.basicConfig(
|
|
| 140 |
)
|
| 141 |
logger = logging.getLogger("headroom.proxy")
|
| 142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
# Maximum request body size (100MB - increased to support image-heavy requests)
|
| 144 |
MAX_REQUEST_BODY_SIZE = 100 * 1024 * 1024
|
| 145 |
|
|
@@ -2055,8 +2090,7 @@ class HeadroomProxy:
|
|
| 2055 |
except Exception:
|
| 2056 |
pass
|
| 2057 |
logger.error(
|
| 2058 |
-
f"CCR: API call failed: {e}, "
|
| 2059 |
-
f"response headers: {resp_headers}"
|
| 2060 |
)
|
| 2061 |
raise
|
| 2062 |
|
|
@@ -2080,7 +2114,9 @@ class HeadroomProxy:
|
|
| 2080 |
try:
|
| 2081 |
ccr_content = json.dumps(final_resp_json).encode()
|
| 2082 |
except (TypeError, ValueError) as json_err:
|
| 2083 |
-
logger.warning(
|
|
|
|
|
|
|
| 2084 |
ccr_content = json.dumps(resp_json).encode()
|
| 2085 |
response = httpx.Response(
|
| 2086 |
status_code=200,
|
|
@@ -2237,15 +2273,21 @@ class HeadroomProxy:
|
|
| 2237 |
)
|
| 2238 |
)
|
| 2239 |
|
| 2240 |
-
#
|
| 2241 |
-
|
| 2242 |
-
|
| 2243 |
-
|
| 2244 |
-
|
| 2245 |
-
|
| 2246 |
-
|
| 2247 |
-
|
| 2248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2249 |
|
| 2250 |
# Remove compression headers since httpx already decompressed the response
|
| 2251 |
response_headers = dict(response.headers)
|
|
@@ -3852,16 +3894,23 @@ class HeadroomProxy:
|
|
| 3852 |
cache_read_tokens = stream_state["cache_read_input_tokens"]
|
| 3853 |
cache_write_tokens = stream_state["cache_creation_input_tokens"]
|
| 3854 |
|
| 3855 |
-
#
|
| 3856 |
-
|
| 3857 |
-
|
| 3858 |
-
|
| 3859 |
-
)
|
| 3860 |
-
|
| 3861 |
-
|
| 3862 |
-
|
| 3863 |
-
|
| 3864 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3865 |
|
| 3866 |
# Normalize input tokens based on provider semantics:
|
| 3867 |
# - Anthropic: input_tokens excludes cache_read (it's separate), pass as-is
|
|
@@ -3913,11 +3962,6 @@ class HeadroomProxy:
|
|
| 3913 |
savings_usd=savings_usd or 0,
|
| 3914 |
)
|
| 3915 |
|
| 3916 |
-
if tokens_saved > 0:
|
| 3917 |
-
logger.info(
|
| 3918 |
-
f"[{request_id}] {model}: saved {tokens_saved:,} tokens (streaming)"
|
| 3919 |
-
)
|
| 3920 |
-
|
| 3921 |
return StreamingResponse(
|
| 3922 |
generate(),
|
| 3923 |
media_type="text/event-stream",
|
|
@@ -4045,10 +4089,17 @@ class HeadroomProxy:
|
|
| 4045 |
)
|
| 4046 |
)
|
| 4047 |
|
| 4048 |
-
|
| 4049 |
-
|
| 4050 |
-
|
| 4051 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4052 |
|
| 4053 |
return StreamingResponse(
|
| 4054 |
generate(),
|
|
|
|
| 140 |
)
|
| 141 |
logger = logging.getLogger("headroom.proxy")
|
| 142 |
|
| 143 |
+
# Always-on file logging to ~/.headroom/logs/ for `headroom perf` analysis
|
| 144 |
+
_HEADROOM_LOG_DIR = Path.home() / ".headroom" / "logs"
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _setup_file_logging() -> None:
|
| 148 |
+
"""Add a RotatingFileHandler to the headroom root logger.
|
| 149 |
+
|
| 150 |
+
Writes to ~/.headroom/logs/proxy.log with automatic rotation:
|
| 151 |
+
- Rotates at 10 MB
|
| 152 |
+
- Keeps 5 backups (~50 MB max)
|
| 153 |
+
"""
|
| 154 |
+
from logging.handlers import RotatingFileHandler
|
| 155 |
+
|
| 156 |
+
try:
|
| 157 |
+
_HEADROOM_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
| 158 |
+
log_path = _HEADROOM_LOG_DIR / "proxy.log"
|
| 159 |
+
handler = RotatingFileHandler(
|
| 160 |
+
log_path,
|
| 161 |
+
maxBytes=10 * 1024 * 1024, # 10 MB
|
| 162 |
+
backupCount=5,
|
| 163 |
+
encoding="utf-8",
|
| 164 |
+
)
|
| 165 |
+
handler.setLevel(logging.INFO)
|
| 166 |
+
handler.setFormatter(
|
| 167 |
+
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
| 168 |
+
)
|
| 169 |
+
# Attach to the headroom root logger so all sub-loggers are captured
|
| 170 |
+
logging.getLogger("headroom").addHandler(handler)
|
| 171 |
+
except OSError:
|
| 172 |
+
# Non-fatal: can't write logs (read-only fs, permissions, etc.)
|
| 173 |
+
pass
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
_setup_file_logging()
|
| 177 |
+
|
| 178 |
# Maximum request body size (100MB - increased to support image-heavy requests)
|
| 179 |
MAX_REQUEST_BODY_SIZE = 100 * 1024 * 1024
|
| 180 |
|
|
|
|
| 2090 |
except Exception:
|
| 2091 |
pass
|
| 2092 |
logger.error(
|
| 2093 |
+
f"CCR: API call failed: {e}, response headers: {resp_headers}"
|
|
|
|
| 2094 |
)
|
| 2095 |
raise
|
| 2096 |
|
|
|
|
| 2114 |
try:
|
| 2115 |
ccr_content = json.dumps(final_resp_json).encode()
|
| 2116 |
except (TypeError, ValueError) as json_err:
|
| 2117 |
+
logger.warning(
|
| 2118 |
+
f"[{request_id}] CCR: JSON serialization failed: {json_err}"
|
| 2119 |
+
)
|
| 2120 |
ccr_content = json.dumps(resp_json).encode()
|
| 2121 |
response = httpx.Response(
|
| 2122 |
status_code=200,
|
|
|
|
| 2273 |
)
|
| 2274 |
)
|
| 2275 |
|
| 2276 |
+
# Structured perf log line for `headroom perf` analysis
|
| 2277 |
+
num_msgs = len(messages)
|
| 2278 |
+
resp_usage = resp_json.get("usage", {}) if resp_json else {}
|
| 2279 |
+
cr = resp_usage.get("cache_read_input_tokens", 0)
|
| 2280 |
+
cw = resp_usage.get("cache_creation_input_tokens", 0)
|
| 2281 |
+
chp = round(cr / (cr + cw) * 100) if (cr + cw) > 0 else 0
|
| 2282 |
+
logger.info(
|
| 2283 |
+
f"[{request_id}] PERF "
|
| 2284 |
+
f"model={model} msgs={num_msgs} "
|
| 2285 |
+
f"tok_before={original_tokens} tok_after={optimized_tokens} "
|
| 2286 |
+
f"tok_saved={tokens_saved} "
|
| 2287 |
+
f"cache_read={cr} cache_write={cw} cache_hit_pct={chp} "
|
| 2288 |
+
f"opt_ms={optimization_latency:.0f} "
|
| 2289 |
+
f"transforms={','.join(transforms_applied) if transforms_applied else 'none'}"
|
| 2290 |
+
)
|
| 2291 |
|
| 2292 |
# Remove compression headers since httpx already decompressed the response
|
| 2293 |
response_headers = dict(response.headers)
|
|
|
|
| 3894 |
cache_read_tokens = stream_state["cache_read_input_tokens"]
|
| 3895 |
cache_write_tokens = stream_state["cache_creation_input_tokens"]
|
| 3896 |
|
| 3897 |
+
# Structured perf log line for `headroom perf` analysis
|
| 3898 |
+
num_msgs = len(body.get("messages", []))
|
| 3899 |
+
cache_hit_pct = (
|
| 3900 |
+
round(cache_read_tokens / (cache_read_tokens + cache_write_tokens) * 100)
|
| 3901 |
+
if (cache_read_tokens + cache_write_tokens) > 0
|
| 3902 |
+
else 0
|
| 3903 |
+
)
|
| 3904 |
+
logger.info(
|
| 3905 |
+
f"[{request_id}] PERF "
|
| 3906 |
+
f"model={model} msgs={num_msgs} "
|
| 3907 |
+
f"tok_before={original_tokens} tok_after={optimized_tokens} "
|
| 3908 |
+
f"tok_saved={tokens_saved} "
|
| 3909 |
+
f"cache_read={cache_read_tokens} cache_write={cache_write_tokens} "
|
| 3910 |
+
f"cache_hit_pct={cache_hit_pct} "
|
| 3911 |
+
f"opt_ms={optimization_latency:.0f} "
|
| 3912 |
+
f"transforms={','.join(transforms_applied) if transforms_applied else 'none'}"
|
| 3913 |
+
)
|
| 3914 |
|
| 3915 |
# Normalize input tokens based on provider semantics:
|
| 3916 |
# - Anthropic: input_tokens excludes cache_read (it's separate), pass as-is
|
|
|
|
| 3962 |
savings_usd=savings_usd or 0,
|
| 3963 |
)
|
| 3964 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3965 |
return StreamingResponse(
|
| 3966 |
generate(),
|
| 3967 |
media_type="text/event-stream",
|
|
|
|
| 4089 |
)
|
| 4090 |
)
|
| 4091 |
|
| 4092 |
+
# Structured perf log line for `headroom perf` analysis
|
| 4093 |
+
num_msgs = len(body.get("messages", []))
|
| 4094 |
+
logger.info(
|
| 4095 |
+
f"[{request_id}] PERF "
|
| 4096 |
+
f"model={model} msgs={num_msgs} "
|
| 4097 |
+
f"tok_before={original_tokens} tok_after={optimized_tokens} "
|
| 4098 |
+
f"tok_saved={tokens_saved} "
|
| 4099 |
+
f"cache_read=0 cache_write=0 cache_hit_pct=0 "
|
| 4100 |
+
f"opt_ms={optimization_latency:.0f} "
|
| 4101 |
+
f"transforms={','.join(transforms_applied) if transforms_applied else 'none'}"
|
| 4102 |
+
)
|
| 4103 |
|
| 4104 |
return StreamingResponse(
|
| 4105 |
generate(),
|
|
@@ -1,12 +1,21 @@
|
|
| 1 |
-
"""Tests for
|
| 2 |
|
| 3 |
from pathlib import Path
|
|
|
|
| 4 |
|
| 5 |
-
from headroom.learn.analyzer import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
from headroom.learn.models import (
|
|
|
|
| 7 |
ErrorCategory,
|
| 8 |
ProjectInfo,
|
|
|
|
| 9 |
SessionData,
|
|
|
|
| 10 |
ToolCall,
|
| 11 |
)
|
| 12 |
|
|
@@ -26,6 +35,7 @@ def _tc(
|
|
| 26 |
is_error: bool = False,
|
| 27 |
error_category: ErrorCategory = ErrorCategory.UNKNOWN,
|
| 28 |
msg_index: int = 0,
|
|
|
|
| 29 |
) -> ToolCall:
|
| 30 |
return ToolCall(
|
| 31 |
name=name,
|
|
@@ -35,314 +45,330 @@ def _tc(
|
|
| 35 |
is_error=is_error,
|
| 36 |
error_category=error_category,
|
| 37 |
msg_index=msg_index,
|
| 38 |
-
output_bytes=len(output),
|
| 39 |
)
|
| 40 |
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
report = analyzer.analyze(_project(), [])
|
| 46 |
-
assert report.total_calls == 0
|
| 47 |
-
assert report.total_failures == 0
|
| 48 |
-
assert report.failure_rate == 0.0
|
| 49 |
|
| 50 |
-
def test_no_failures(self):
|
| 51 |
-
analyzer = FailureAnalyzer()
|
| 52 |
-
sessions = [
|
| 53 |
-
SessionData(
|
| 54 |
-
session_id="s1",
|
| 55 |
-
tool_calls=[_tc(msg_index=i) for i in range(10)],
|
| 56 |
-
)
|
| 57 |
-
]
|
| 58 |
-
report = analyzer.analyze(_project(), sessions)
|
| 59 |
-
assert report.total_calls == 10
|
| 60 |
-
assert report.total_failures == 0
|
| 61 |
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
sessions = [
|
| 65 |
SessionData(
|
| 66 |
-
session_id="
|
| 67 |
-
tool_calls=[
|
| 68 |
-
_tc(msg_index=0),
|
| 69 |
-
_tc(msg_index=1, is_error=True, output="Error: something broke"),
|
| 70 |
-
_tc(msg_index=2),
|
| 71 |
-
],
|
| 72 |
)
|
| 73 |
]
|
| 74 |
-
|
| 75 |
-
assert
|
| 76 |
-
assert
|
| 77 |
-
|
| 78 |
|
| 79 |
-
|
| 80 |
-
def test_detects_wrong_python(self):
|
| 81 |
-
"""Module not found with python3 + successes with uv run → learn correct command."""
|
| 82 |
-
analyzer = FailureAnalyzer()
|
| 83 |
sessions = [
|
| 84 |
SessionData(
|
| 85 |
session_id="s1",
|
| 86 |
tool_calls=[
|
| 87 |
-
# Failures with python3
|
| 88 |
_tc(
|
| 89 |
-
name="
|
| 90 |
-
input_data={"
|
| 91 |
-
output="
|
| 92 |
-
is_error=True,
|
| 93 |
-
error_category=ErrorCategory.MODULE_NOT_FOUND,
|
| 94 |
msg_index=0,
|
| 95 |
),
|
| 96 |
_tc(
|
| 97 |
name="Bash",
|
| 98 |
-
input_data={"command": "python3
|
| 99 |
output="ModuleNotFoundError",
|
| 100 |
is_error=True,
|
| 101 |
error_category=ErrorCategory.MODULE_NOT_FOUND,
|
| 102 |
msg_index=1,
|
| 103 |
),
|
| 104 |
-
# Success with uv run
|
| 105 |
-
_tc(
|
| 106 |
-
name="Bash",
|
| 107 |
-
input_data={"command": "uv run python -c 'import mylib'"},
|
| 108 |
-
output="ok",
|
| 109 |
-
msg_index=2,
|
| 110 |
-
),
|
| 111 |
-
_tc(
|
| 112 |
-
name="Bash",
|
| 113 |
-
input_data={"command": "uv run python -c 'import mylib'"},
|
| 114 |
-
output="ok",
|
| 115 |
-
msg_index=3,
|
| 116 |
-
),
|
| 117 |
-
_tc(
|
| 118 |
-
name="Bash",
|
| 119 |
-
input_data={"command": "uv run python -c 'import mylib'"},
|
| 120 |
-
output="ok",
|
| 121 |
-
msg_index=4,
|
| 122 |
-
),
|
| 123 |
],
|
| 124 |
)
|
| 125 |
]
|
| 126 |
-
|
| 127 |
-
assert
|
| 128 |
-
|
| 129 |
-
assert
|
| 130 |
-
assert "
|
| 131 |
-
assert "python3" in fact.wrong_commands
|
| 132 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
_tc(
|
| 143 |
-
name="Read",
|
| 144 |
-
input_data={"file_path": "/src/missing.py"},
|
| 145 |
-
output="No such file",
|
| 146 |
-
is_error=True,
|
| 147 |
-
error_category=ErrorCategory.FILE_NOT_FOUND,
|
| 148 |
-
msg_index=0,
|
| 149 |
-
),
|
| 150 |
-
],
|
| 151 |
-
),
|
| 152 |
-
SessionData(
|
| 153 |
-
session_id="s2",
|
| 154 |
-
tool_calls=[
|
| 155 |
-
_tc(
|
| 156 |
-
name="Read",
|
| 157 |
-
input_data={"file_path": "/src/missing.py"},
|
| 158 |
-
output="No such file",
|
| 159 |
-
is_error=True,
|
| 160 |
-
error_category=ErrorCategory.FILE_NOT_FOUND,
|
| 161 |
-
msg_index=0,
|
| 162 |
-
),
|
| 163 |
-
],
|
| 164 |
),
|
| 165 |
]
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
assert
|
| 169 |
-
assert "
|
|
|
|
| 170 |
|
| 171 |
-
def
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
tool_calls=[
|
| 178 |
-
_tc(
|
| 179 |
-
name="Read",
|
| 180 |
-
input_data={"file_path": "/src/huge.py"},
|
| 181 |
-
output="file is too large",
|
| 182 |
-
is_error=True,
|
| 183 |
-
error_category=ErrorCategory.FILE_TOO_LARGE,
|
| 184 |
-
msg_index=0,
|
| 185 |
-
),
|
| 186 |
-
_tc(
|
| 187 |
-
name="Read",
|
| 188 |
-
input_data={"file_path": "/src/huge.py"},
|
| 189 |
-
output="file is too large",
|
| 190 |
-
is_error=True,
|
| 191 |
-
error_category=ErrorCategory.FILE_TOO_LARGE,
|
| 192 |
-
msg_index=1,
|
| 193 |
-
),
|
| 194 |
-
],
|
| 195 |
),
|
| 196 |
]
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
assert
|
| 200 |
-
assert "/src/huge.py" in large[0].path
|
| 201 |
|
| 202 |
-
def
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
sessions = [
|
| 206 |
-
SessionData(
|
| 207 |
-
session_id="s1",
|
| 208 |
-
tool_calls=[
|
| 209 |
-
_tc(
|
| 210 |
-
name="Read",
|
| 211 |
-
input_data={"file_path": "/src/one_time.py"},
|
| 212 |
-
output="No such file",
|
| 213 |
-
is_error=True,
|
| 214 |
-
error_category=ErrorCategory.FILE_NOT_FOUND,
|
| 215 |
-
msg_index=0,
|
| 216 |
-
),
|
| 217 |
-
],
|
| 218 |
-
)
|
| 219 |
-
]
|
| 220 |
-
report = analyzer.analyze(_project(), sessions)
|
| 221 |
-
missing = [n for n in report.structure_notes if n.category == "missing_path"]
|
| 222 |
-
assert len(missing) == 0
|
| 223 |
|
| 224 |
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
sessions = [
|
| 230 |
SessionData(
|
| 231 |
session_id="s1",
|
| 232 |
tool_calls=[
|
| 233 |
-
_tc(
|
| 234 |
-
|
| 235 |
-
input_data={"command": "mkdir -p /x"},
|
| 236 |
-
output="auto-denied",
|
| 237 |
-
is_error=True,
|
| 238 |
-
error_category=ErrorCategory.PERMISSION_DENIED,
|
| 239 |
-
msg_index=i,
|
| 240 |
-
)
|
| 241 |
-
for i in range(5)
|
| 242 |
],
|
| 243 |
)
|
| 244 |
]
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
assert
|
| 249 |
-
assert
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
sessions = [
|
| 255 |
SessionData(
|
| 256 |
session_id="s1",
|
| 257 |
-
tool_calls=[
|
| 258 |
-
_tc(
|
| 259 |
-
name="Glob",
|
| 260 |
-
output="No matches",
|
| 261 |
-
is_error=True,
|
| 262 |
-
error_category=ErrorCategory.NO_MATCHES,
|
| 263 |
-
msg_index=0,
|
| 264 |
-
),
|
| 265 |
-
_tc(
|
| 266 |
-
name="Glob",
|
| 267 |
-
output="No matches",
|
| 268 |
-
is_error=True,
|
| 269 |
-
error_category=ErrorCategory.NO_MATCHES,
|
| 270 |
-
msg_index=1,
|
| 271 |
-
),
|
| 272 |
-
_tc(name="Glob", output="found.py", msg_index=2), # Success breaks streak
|
| 273 |
-
],
|
| 274 |
)
|
| 275 |
]
|
| 276 |
-
|
| 277 |
-
assert len(report.retry_patterns) == 0
|
| 278 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
|
| 280 |
-
|
| 281 |
-
def
|
| 282 |
-
"""
|
| 283 |
-
|
| 284 |
-
sessions = [
|
| 285 |
-
SessionData(
|
| 286 |
-
session_id=f"s{i}",
|
| 287 |
-
tool_calls=[
|
| 288 |
-
_tc(
|
| 289 |
-
name="Read",
|
| 290 |
-
input_data={"file_path": "/docs/RESEARCH.md"},
|
| 291 |
-
output="No such file",
|
| 292 |
-
is_error=True,
|
| 293 |
-
error_category=ErrorCategory.FILE_NOT_FOUND,
|
| 294 |
-
msg_index=0,
|
| 295 |
-
),
|
| 296 |
-
],
|
| 297 |
-
)
|
| 298 |
-
for i in range(4)
|
| 299 |
-
]
|
| 300 |
-
report = analyzer.analyze(_project(), sessions)
|
| 301 |
-
assert len(report.cross_session_patterns) >= 1
|
| 302 |
-
assert any("RESEARCH.md" in p for p in report.cross_session_patterns)
|
| 303 |
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
SessionData(
|
| 309 |
-
session_id=f"s{i}",
|
| 310 |
-
tool_calls=[
|
| 311 |
-
_tc(
|
| 312 |
-
name="Read",
|
| 313 |
-
input_data={"file_path": "/rare.py"},
|
| 314 |
-
output="No such file",
|
| 315 |
-
is_error=True,
|
| 316 |
-
error_category=ErrorCategory.FILE_NOT_FOUND,
|
| 317 |
-
msg_index=0,
|
| 318 |
-
),
|
| 319 |
-
],
|
| 320 |
-
)
|
| 321 |
-
for i in range(2)
|
| 322 |
]
|
| 323 |
-
|
| 324 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
|
| 327 |
-
class TestPermissionAnalyzer:
|
| 328 |
-
def test_detects_repeated_denials(self):
|
| 329 |
-
"""Commands denied 3+ times → permission note."""
|
| 330 |
analyzer = FailureAnalyzer()
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
session_id="s1",
|
| 334 |
-
tool_calls=[
|
| 335 |
-
_tc(
|
| 336 |
-
name="Bash",
|
| 337 |
-
input_data={"command": "mkdir -p /x"},
|
| 338 |
-
output="auto-denied",
|
| 339 |
-
is_error=True,
|
| 340 |
-
error_category=ErrorCategory.PERMISSION_DENIED,
|
| 341 |
-
msg_index=i,
|
| 342 |
-
)
|
| 343 |
-
for i in range(4)
|
| 344 |
-
],
|
| 345 |
-
)
|
| 346 |
-
]
|
| 347 |
-
report = analyzer.analyze(_project(), sessions)
|
| 348 |
-
assert len(report.permission_issues) >= 1
|
|
|
|
| 1 |
+
"""Tests for session analyzer — digest builder and LLM-based analysis."""
|
| 2 |
|
| 3 |
from pathlib import Path
|
| 4 |
+
from unittest.mock import MagicMock, patch
|
| 5 |
|
| 6 |
+
from headroom.learn.analyzer import (
|
| 7 |
+
SessionAnalyzer,
|
| 8 |
+
_build_digest,
|
| 9 |
+
_detect_default_model,
|
| 10 |
+
_parse_llm_response,
|
| 11 |
+
)
|
| 12 |
from headroom.learn.models import (
|
| 13 |
+
AnalysisResult,
|
| 14 |
ErrorCategory,
|
| 15 |
ProjectInfo,
|
| 16 |
+
RecommendationTarget,
|
| 17 |
SessionData,
|
| 18 |
+
SessionEvent,
|
| 19 |
ToolCall,
|
| 20 |
)
|
| 21 |
|
|
|
|
| 35 |
is_error: bool = False,
|
| 36 |
error_category: ErrorCategory = ErrorCategory.UNKNOWN,
|
| 37 |
msg_index: int = 0,
|
| 38 |
+
output_bytes: int = 0,
|
| 39 |
) -> ToolCall:
|
| 40 |
return ToolCall(
|
| 41 |
name=name,
|
|
|
|
| 45 |
is_error=is_error,
|
| 46 |
error_category=error_category,
|
| 47 |
msg_index=msg_index,
|
| 48 |
+
output_bytes=output_bytes or len(output),
|
| 49 |
)
|
| 50 |
|
| 51 |
|
| 52 |
+
# =============================================================================
|
| 53 |
+
# Digest Builder Tests
|
| 54 |
+
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
+
class TestDigestBuilder:
|
| 58 |
+
def test_includes_project_info(self):
|
| 59 |
+
project = _project()
|
| 60 |
+
sessions = [SessionData(session_id="s1", tool_calls=[_tc()])]
|
| 61 |
+
digest = _build_digest(project, sessions)
|
| 62 |
+
assert "test-project" in digest
|
| 63 |
+
assert "/tmp/test-project" in digest
|
| 64 |
+
|
| 65 |
+
def test_includes_session_stats(self):
|
| 66 |
sessions = [
|
| 67 |
SessionData(
|
| 68 |
+
session_id="abc123",
|
| 69 |
+
tool_calls=[_tc(msg_index=0), _tc(msg_index=1, is_error=True, output="Error!")],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
)
|
| 71 |
]
|
| 72 |
+
digest = _build_digest(_project(), sessions)
|
| 73 |
+
assert "abc123" in digest
|
| 74 |
+
assert "2 calls" in digest
|
| 75 |
+
assert "1 failure" in digest
|
| 76 |
|
| 77 |
+
def test_includes_tool_call_details(self):
|
|
|
|
|
|
|
|
|
|
| 78 |
sessions = [
|
| 79 |
SessionData(
|
| 80 |
session_id="s1",
|
| 81 |
tool_calls=[
|
|
|
|
| 82 |
_tc(
|
| 83 |
+
name="Read",
|
| 84 |
+
input_data={"file_path": "/src/foo.py"},
|
| 85 |
+
output="contents",
|
|
|
|
|
|
|
| 86 |
msg_index=0,
|
| 87 |
),
|
| 88 |
_tc(
|
| 89 |
name="Bash",
|
| 90 |
+
input_data={"command": "python3 run.py"},
|
| 91 |
output="ModuleNotFoundError",
|
| 92 |
is_error=True,
|
| 93 |
error_category=ErrorCategory.MODULE_NOT_FOUND,
|
| 94 |
msg_index=1,
|
| 95 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
],
|
| 97 |
)
|
| 98 |
]
|
| 99 |
+
digest = _build_digest(_project(), sessions)
|
| 100 |
+
assert "/src/foo.py" in digest
|
| 101 |
+
assert "python3 run.py" in digest
|
| 102 |
+
assert "ERROR" in digest
|
| 103 |
+
assert "ModuleNotFoundError" in digest
|
|
|
|
| 104 |
|
| 105 |
+
def test_includes_user_messages(self):
|
| 106 |
+
tc = _tc(msg_index=0)
|
| 107 |
+
events = [
|
| 108 |
+
SessionEvent(type="tool_call", msg_index=0, tool_call=tc),
|
| 109 |
+
SessionEvent(type="user_message", msg_index=1, text="Use uv run instead"),
|
| 110 |
+
]
|
| 111 |
+
sessions = [SessionData(session_id="s1", tool_calls=[tc], events=events)]
|
| 112 |
+
digest = _build_digest(_project(), sessions)
|
| 113 |
+
assert "USER:" in digest
|
| 114 |
+
assert "Use uv run instead" in digest
|
| 115 |
|
| 116 |
+
def test_includes_subagent_summaries(self):
|
| 117 |
+
events = [
|
| 118 |
+
SessionEvent(
|
| 119 |
+
type="agent_summary",
|
| 120 |
+
msg_index=0,
|
| 121 |
+
agent_tool_count=150,
|
| 122 |
+
agent_tokens=60000,
|
| 123 |
+
agent_prompt="Explore all test files",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
),
|
| 125 |
]
|
| 126 |
+
sessions = [SessionData(session_id="s1", events=events)]
|
| 127 |
+
digest = _build_digest(_project(), sessions)
|
| 128 |
+
assert "SUBAGENT" in digest
|
| 129 |
+
assert "150 tool calls" in digest
|
| 130 |
+
assert "Explore all test files" in digest
|
| 131 |
|
| 132 |
+
def test_includes_interruptions(self):
|
| 133 |
+
events = [
|
| 134 |
+
SessionEvent(
|
| 135 |
+
type="interruption",
|
| 136 |
+
msg_index=0,
|
| 137 |
+
text="[Request interrupted by user]",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
),
|
| 139 |
]
|
| 140 |
+
sessions = [SessionData(session_id="s1", events=events)]
|
| 141 |
+
digest = _build_digest(_project(), sessions)
|
| 142 |
+
assert "INTERRUPTED" in digest
|
|
|
|
| 143 |
|
| 144 |
+
def test_empty_sessions(self):
|
| 145 |
+
digest = _build_digest(_project(), [])
|
| 146 |
+
assert "0 sessions" in digest or "test-project" in digest
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
|
| 149 |
+
# =============================================================================
|
| 150 |
+
# LLM Response Parser Tests
|
| 151 |
+
# =============================================================================
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
class TestLLMResponseParser:
|
| 155 |
+
def test_parses_context_file_rules(self):
|
| 156 |
+
raw = {
|
| 157 |
+
"context_file_rules": [
|
| 158 |
+
{
|
| 159 |
+
"section": "Environment",
|
| 160 |
+
"content": "- Use `uv run python` instead of `python3`",
|
| 161 |
+
"estimated_tokens_saved": 800,
|
| 162 |
+
"evidence_count": 5,
|
| 163 |
+
}
|
| 164 |
+
],
|
| 165 |
+
"memory_file_rules": [],
|
| 166 |
+
}
|
| 167 |
+
recs = _parse_llm_response(raw)
|
| 168 |
+
assert len(recs) == 1
|
| 169 |
+
assert recs[0].target == RecommendationTarget.CONTEXT_FILE
|
| 170 |
+
assert recs[0].section == "Environment"
|
| 171 |
+
assert "uv run python" in recs[0].content
|
| 172 |
+
assert recs[0].estimated_tokens_saved == 800
|
| 173 |
+
assert recs[0].evidence_count == 5
|
| 174 |
+
|
| 175 |
+
def test_parses_memory_file_rules(self):
|
| 176 |
+
raw = {
|
| 177 |
+
"context_file_rules": [],
|
| 178 |
+
"memory_file_rules": [
|
| 179 |
+
{
|
| 180 |
+
"section": "User Preferences",
|
| 181 |
+
"content": "- Do not auto-execute curl commands",
|
| 182 |
+
"estimated_tokens_saved": 500,
|
| 183 |
+
"evidence_count": 3,
|
| 184 |
+
}
|
| 185 |
+
],
|
| 186 |
+
}
|
| 187 |
+
recs = _parse_llm_response(raw)
|
| 188 |
+
assert len(recs) == 1
|
| 189 |
+
assert recs[0].target == RecommendationTarget.MEMORY_FILE
|
| 190 |
+
assert "curl" in recs[0].content
|
| 191 |
+
|
| 192 |
+
def test_sorts_by_token_savings(self):
|
| 193 |
+
raw = {
|
| 194 |
+
"context_file_rules": [
|
| 195 |
+
{
|
| 196 |
+
"section": "Paths",
|
| 197 |
+
"content": "- Use correct paths",
|
| 198 |
+
"estimated_tokens_saved": 200,
|
| 199 |
+
"evidence_count": 2,
|
| 200 |
+
},
|
| 201 |
+
{
|
| 202 |
+
"section": "Environment",
|
| 203 |
+
"content": "- Use uv",
|
| 204 |
+
"estimated_tokens_saved": 1000,
|
| 205 |
+
"evidence_count": 5,
|
| 206 |
+
},
|
| 207 |
+
],
|
| 208 |
+
"memory_file_rules": [],
|
| 209 |
+
}
|
| 210 |
+
recs = _parse_llm_response(raw)
|
| 211 |
+
assert recs[0].estimated_tokens_saved == 1000
|
| 212 |
+
assert recs[1].estimated_tokens_saved == 200
|
| 213 |
+
|
| 214 |
+
def test_handles_missing_fields(self):
|
| 215 |
+
raw = {
|
| 216 |
+
"context_file_rules": [
|
| 217 |
+
{"section": "Env", "content": "- stuff"},
|
| 218 |
+
{"section": "", "content": ""}, # should be skipped
|
| 219 |
+
{"not_a_real_field": True}, # should be skipped
|
| 220 |
+
],
|
| 221 |
+
"memory_file_rules": [],
|
| 222 |
+
}
|
| 223 |
+
recs = _parse_llm_response(raw)
|
| 224 |
+
assert len(recs) == 1
|
| 225 |
+
|
| 226 |
+
def test_handles_empty_response(self):
|
| 227 |
+
recs = _parse_llm_response({})
|
| 228 |
+
assert recs == []
|
| 229 |
+
|
| 230 |
+
def test_handles_non_dict_entries(self):
|
| 231 |
+
raw = {"context_file_rules": ["not a dict", 42], "memory_file_rules": []}
|
| 232 |
+
recs = _parse_llm_response(raw)
|
| 233 |
+
assert recs == []
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
# =============================================================================
|
| 237 |
+
# Full Analyzer Integration Tests (mocked LLM)
|
| 238 |
+
# =============================================================================
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
class TestSessionAnalyzer:
|
| 242 |
+
def test_empty_sessions_no_llm_call(self):
|
| 243 |
+
"""No failures + no events → no LLM call, empty result."""
|
| 244 |
+
analyzer = SessionAnalyzer()
|
| 245 |
+
result = analyzer.analyze(_project(), [])
|
| 246 |
+
assert result.total_calls == 0
|
| 247 |
+
assert result.total_failures == 0
|
| 248 |
+
assert result.recommendations == []
|
| 249 |
+
|
| 250 |
+
@patch("headroom.learn.analyzer._call_llm")
|
| 251 |
+
def test_calls_llm_with_digest(self, mock_call_llm: MagicMock):
|
| 252 |
+
mock_call_llm.return_value = {
|
| 253 |
+
"context_file_rules": [
|
| 254 |
+
{
|
| 255 |
+
"section": "Environment",
|
| 256 |
+
"content": "- Use uv run python",
|
| 257 |
+
"estimated_tokens_saved": 800,
|
| 258 |
+
"evidence_count": 3,
|
| 259 |
+
}
|
| 260 |
+
],
|
| 261 |
+
"memory_file_rules": [],
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
analyzer = SessionAnalyzer()
|
| 265 |
sessions = [
|
| 266 |
SessionData(
|
| 267 |
session_id="s1",
|
| 268 |
tool_calls=[
|
| 269 |
+
_tc(msg_index=0, is_error=True, output="ModuleNotFoundError"),
|
| 270 |
+
_tc(msg_index=1),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
],
|
| 272 |
)
|
| 273 |
]
|
| 274 |
+
result = analyzer.analyze(_project(), sessions)
|
| 275 |
+
|
| 276 |
+
mock_call_llm.assert_called_once()
|
| 277 |
+
assert result.total_calls == 2
|
| 278 |
+
assert result.total_failures == 1
|
| 279 |
+
assert len(result.recommendations) == 1
|
| 280 |
+
assert "uv run python" in result.recommendations[0].content
|
| 281 |
+
|
| 282 |
+
@patch("headroom.learn.analyzer._call_llm")
|
| 283 |
+
def test_handles_llm_failure_gracefully(self, mock_call_llm: MagicMock):
|
| 284 |
+
mock_call_llm.side_effect = RuntimeError("API key not set")
|
| 285 |
+
|
| 286 |
+
analyzer = SessionAnalyzer()
|
| 287 |
sessions = [
|
| 288 |
SessionData(
|
| 289 |
session_id="s1",
|
| 290 |
+
tool_calls=[_tc(msg_index=0, is_error=True, output="error")],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
)
|
| 292 |
]
|
| 293 |
+
result = analyzer.analyze(_project(), sessions)
|
|
|
|
| 294 |
|
| 295 |
+
# Stats should still work, just no recommendations
|
| 296 |
+
assert result.total_calls == 1
|
| 297 |
+
assert result.total_failures == 1
|
| 298 |
+
assert result.recommendations == []
|
| 299 |
|
| 300 |
+
@patch("headroom.learn.analyzer._call_llm")
|
| 301 |
+
def test_passes_events_to_digest(self, mock_call_llm: MagicMock):
|
| 302 |
+
"""User messages and subagent events should appear in the digest."""
|
| 303 |
+
mock_call_llm.return_value = {"context_file_rules": [], "memory_file_rules": []}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
|
| 305 |
+
tc = _tc(msg_index=0, is_error=True, output="error")
|
| 306 |
+
events = [
|
| 307 |
+
SessionEvent(type="tool_call", msg_index=0, tool_call=tc),
|
| 308 |
+
SessionEvent(type="user_message", msg_index=1, text="use venv python"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
]
|
| 310 |
+
sessions = [SessionData(session_id="s1", tool_calls=[tc], events=events)]
|
| 311 |
+
|
| 312 |
+
analyzer = SessionAnalyzer()
|
| 313 |
+
analyzer.analyze(_project(), sessions)
|
| 314 |
+
|
| 315 |
+
# Check that the digest passed to the LLM includes user message
|
| 316 |
+
call_args = mock_call_llm.call_args
|
| 317 |
+
digest = call_args[0][0] # first positional arg
|
| 318 |
+
assert "use venv python" in digest
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
# =============================================================================
|
| 322 |
+
# Model Auto-Detection
|
| 323 |
+
# =============================================================================
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
class TestDetectDefaultModel:
|
| 327 |
+
def test_anthropic_key(self, monkeypatch):
|
| 328 |
+
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
|
| 329 |
+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
| 330 |
+
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
| 331 |
+
assert _detect_default_model() == "claude-sonnet-4-6"
|
| 332 |
|
| 333 |
+
def test_openai_key(self, monkeypatch):
|
| 334 |
+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
| 335 |
+
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
| 336 |
+
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
| 337 |
+
assert _detect_default_model() == "gpt-4o"
|
| 338 |
+
|
| 339 |
+
def test_gemini_key(self, monkeypatch):
|
| 340 |
+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
| 341 |
+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
| 342 |
+
monkeypatch.setenv("GEMINI_API_KEY", "test")
|
| 343 |
+
assert _detect_default_model() == "gemini/gemini-2.0-flash"
|
| 344 |
+
|
| 345 |
+
def test_anthropic_preferred_over_openai(self, monkeypatch):
|
| 346 |
+
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
|
| 347 |
+
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
| 348 |
+
assert _detect_default_model() == "claude-sonnet-4-6"
|
| 349 |
+
|
| 350 |
+
def test_no_keys_raises(self, monkeypatch):
|
| 351 |
+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
| 352 |
+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
| 353 |
+
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
| 354 |
+
import pytest
|
| 355 |
+
|
| 356 |
+
with pytest.raises(RuntimeError, match="No LLM API key found"):
|
| 357 |
+
_detect_default_model()
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
# =============================================================================
|
| 361 |
+
# Legacy Compatibility
|
| 362 |
+
# =============================================================================
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
class TestFailureAnalyzerCompat:
|
| 366 |
+
@patch("headroom.learn.analyzer._call_llm")
|
| 367 |
+
def test_legacy_alias_works(self, mock_call_llm: MagicMock):
|
| 368 |
+
from headroom.learn.analyzer import FailureAnalyzer
|
| 369 |
+
|
| 370 |
+
mock_call_llm.return_value = {"context_file_rules": [], "memory_file_rules": []}
|
| 371 |
|
|
|
|
|
|
|
|
|
|
| 372 |
analyzer = FailureAnalyzer()
|
| 373 |
+
result = analyzer.analyze(_project(), [])
|
| 374 |
+
assert isinstance(result, AnalysisResult)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -3,114 +3,36 @@
|
|
| 3 |
These tests run against actual conversation data on the machine.
|
| 4 |
They verify the full pipeline: scan → analyze → recommend → write.
|
| 5 |
Tests are skipped if the required data directories don't exist.
|
|
|
|
| 6 |
|
| 7 |
Key behaviors tested:
|
| 8 |
-
-
|
| 9 |
-
-
|
| 10 |
-
- Real Codex/Claude Code sessions produce sensible output
|
| 11 |
- Skip logic: no file writes when nothing meaningful is found
|
| 12 |
- Idempotency: running twice produces same output
|
| 13 |
"""
|
| 14 |
|
| 15 |
from __future__ import annotations
|
| 16 |
|
|
|
|
| 17 |
from pathlib import Path
|
|
|
|
| 18 |
|
| 19 |
import pytest
|
| 20 |
|
| 21 |
-
from headroom.learn.analyzer import
|
| 22 |
from headroom.learn.models import (
|
| 23 |
-
AnalysisReport,
|
| 24 |
ProjectInfo,
|
| 25 |
Recommendation,
|
| 26 |
RecommendationTarget,
|
| 27 |
)
|
| 28 |
-
from headroom.learn.writer import ClaudeCodeWriter, CodexWriter
|
| 29 |
|
| 30 |
# =============================================================================
|
| 31 |
-
#
|
| 32 |
# =============================================================================
|
| 33 |
|
| 34 |
|
| 35 |
-
class TestQualityGates:
|
| 36 |
-
"""Verify that weak signals don't generate recommendations."""
|
| 37 |
-
|
| 38 |
-
def test_single_failure_not_enough(self):
|
| 39 |
-
"""One failure shouldn't generate a recommendation (min_evidence=2)."""
|
| 40 |
-
recommender = Recommender(min_evidence=2)
|
| 41 |
-
report = AnalysisReport(
|
| 42 |
-
project=ProjectInfo(name="test", project_path=Path("/tmp"), data_path=Path("/tmp")),
|
| 43 |
-
total_calls=100,
|
| 44 |
-
total_failures=1,
|
| 45 |
-
total_sessions=1,
|
| 46 |
-
)
|
| 47 |
-
# One missing path — below threshold
|
| 48 |
-
from headroom.learn.models import StructureNote
|
| 49 |
-
|
| 50 |
-
report.structure_notes = [
|
| 51 |
-
StructureNote(
|
| 52 |
-
category="missing_path",
|
| 53 |
-
path="/src/foo.py",
|
| 54 |
-
note="not found",
|
| 55 |
-
evidence_count=1,
|
| 56 |
-
)
|
| 57 |
-
]
|
| 58 |
-
recs = recommender.recommend(report)
|
| 59 |
-
assert recs == []
|
| 60 |
-
|
| 61 |
-
def test_low_total_evidence_skipped(self):
|
| 62 |
-
"""Even if individual recs pass, if total evidence is too low, skip all."""
|
| 63 |
-
recommender = Recommender(min_evidence=1, min_total_evidence=10)
|
| 64 |
-
report = AnalysisReport(
|
| 65 |
-
project=ProjectInfo(name="test", project_path=Path("/tmp"), data_path=Path("/tmp")),
|
| 66 |
-
total_calls=100,
|
| 67 |
-
total_failures=2,
|
| 68 |
-
total_sessions=1,
|
| 69 |
-
)
|
| 70 |
-
from headroom.learn.models import StructureNote
|
| 71 |
-
|
| 72 |
-
report.structure_notes = [
|
| 73 |
-
StructureNote(category="missing_path", path="/a.py", note="x", evidence_count=2)
|
| 74 |
-
]
|
| 75 |
-
recs = recommender.recommend(report)
|
| 76 |
-
assert recs == [] # Total evidence=2 < min_total_evidence=10
|
| 77 |
-
|
| 78 |
-
def test_sufficient_evidence_passes(self):
|
| 79 |
-
"""Enough evidence generates recommendations."""
|
| 80 |
-
recommender = Recommender(min_evidence=2, min_total_evidence=3)
|
| 81 |
-
report = AnalysisReport(
|
| 82 |
-
project=ProjectInfo(name="test", project_path=Path("/tmp"), data_path=Path("/tmp")),
|
| 83 |
-
total_calls=100,
|
| 84 |
-
total_failures=10,
|
| 85 |
-
total_sessions=5,
|
| 86 |
-
)
|
| 87 |
-
from headroom.learn.models import StructureNote
|
| 88 |
-
|
| 89 |
-
report.structure_notes = [
|
| 90 |
-
StructureNote(
|
| 91 |
-
category="large_file",
|
| 92 |
-
path="/big.py",
|
| 93 |
-
note="too big",
|
| 94 |
-
evidence_count=5,
|
| 95 |
-
sessions_seen=3,
|
| 96 |
-
),
|
| 97 |
-
]
|
| 98 |
-
recs = recommender.recommend(report)
|
| 99 |
-
assert len(recs) >= 1
|
| 100 |
-
|
| 101 |
-
def test_no_failures_no_recommendations(self):
|
| 102 |
-
"""Zero failures = zero recommendations, no files touched."""
|
| 103 |
-
recommender = Recommender()
|
| 104 |
-
report = AnalysisReport(
|
| 105 |
-
project=ProjectInfo(name="test", project_path=Path("/tmp"), data_path=Path("/tmp")),
|
| 106 |
-
total_calls=500,
|
| 107 |
-
total_failures=0,
|
| 108 |
-
total_sessions=10,
|
| 109 |
-
)
|
| 110 |
-
recs = recommender.recommend(report)
|
| 111 |
-
assert recs == []
|
| 112 |
-
|
| 113 |
-
|
| 114 |
class TestSkipWriteLogic:
|
| 115 |
"""Verify files are NOT written when there's nothing to write."""
|
| 116 |
|
|
@@ -180,10 +102,6 @@ class TestFalsePositiveFiltering:
|
|
| 180 |
|
| 181 |
# Normal code output that happens to contain "error" in identifiers
|
| 182 |
assert not is_error_content("def handle_error(e):\n print('ok')")
|
| 183 |
-
# Normal sed output with error handling code in the file content
|
| 184 |
-
assert not is_error_content(
|
| 185 |
-
' return fmt.Errorf("connection failed")\n log.Print("ok")'
|
| 186 |
-
)
|
| 187 |
|
| 188 |
def test_real_error_detected(self):
|
| 189 |
"""Actual errors should be detected."""
|
|
@@ -203,6 +121,7 @@ class TestFalsePositiveFiltering:
|
|
| 203 |
|
| 204 |
CLAUDE_DIR = Path.home() / ".claude" / "projects"
|
| 205 |
CODEX_DIR = Path.home() / ".codex" / "sessions"
|
|
|
|
| 206 |
|
| 207 |
|
| 208 |
@pytest.mark.skipif(not CLAUDE_DIR.exists(), reason="No Claude Code data")
|
|
@@ -219,30 +138,37 @@ class TestClaudeCodeIntegration:
|
|
| 219 |
assert p.name
|
| 220 |
assert p.data_path.exists()
|
| 221 |
|
| 222 |
-
def
|
| 223 |
-
"""
|
| 224 |
from headroom.learn.scanner import ClaudeCodeScanner
|
| 225 |
|
| 226 |
scanner = ClaudeCodeScanner()
|
| 227 |
projects = scanner.discover_projects()
|
| 228 |
-
|
| 229 |
-
# Find a project with the most sessions
|
| 230 |
best = max(projects, key=lambda p: len(list(p.data_path.glob("*.jsonl"))))
|
| 231 |
-
|
| 232 |
sessions = scanner.scan_project(best)
|
| 233 |
assert len(sessions) > 0
|
| 234 |
|
| 235 |
-
|
| 236 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
# Failure rate should be realistic (0-30%)
|
| 241 |
-
assert 0 <= report.failure_rate <= 0.5
|
| 242 |
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
|
| 247 |
def test_dry_run_writes_nothing(self):
|
| 248 |
"""Dry run should never create files."""
|
|
@@ -252,18 +178,29 @@ class TestClaudeCodeIntegration:
|
|
| 252 |
projects = scanner.discover_projects()
|
| 253 |
best = max(projects, key=lambda p: len(list(p.data_path.glob("*.jsonl"))))
|
| 254 |
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
|
| 268 |
|
| 269 |
@pytest.mark.skipif(not CODEX_DIR.exists(), reason="No Codex data")
|
|
@@ -287,50 +224,25 @@ class TestCodexIntegration:
|
|
| 287 |
|
| 288 |
assert len(sessions) > 0
|
| 289 |
|
| 290 |
-
analyzer = FailureAnalyzer()
|
| 291 |
-
report = analyzer.analyze(projects[0], sessions)
|
| 292 |
-
|
| 293 |
-
assert report.total_calls > 0
|
| 294 |
# Codex has only Bash tool (shell)
|
| 295 |
all_tools = {tc.name for s in sessions for tc in s.tool_calls}
|
| 296 |
assert "Bash" in all_tools
|
| 297 |
|
| 298 |
-
|
| 299 |
-
assert 0 <= report.failure_rate <= 0.5
|
| 300 |
-
|
| 301 |
-
def test_quality_gate_filters_noise(self):
|
| 302 |
-
"""Codex has false positive 'runtime_error' from sed output.
|
| 303 |
-
Quality gate should prevent these from generating weak recommendations."""
|
| 304 |
-
from headroom.learn.scanner import CodexScanner
|
| 305 |
-
|
| 306 |
-
scanner = CodexScanner()
|
| 307 |
-
projects = scanner.discover_projects()
|
| 308 |
-
sessions = scanner.scan_project(projects[0])
|
| 309 |
-
|
| 310 |
-
report = FailureAnalyzer().analyze(projects[0], sessions)
|
| 311 |
-
# Use strict quality gates
|
| 312 |
-
recommender = Recommender(min_evidence=5, min_total_evidence=10)
|
| 313 |
-
recs = recommender.recommend(report)
|
| 314 |
-
|
| 315 |
-
# Every recommendation should have meaningful evidence
|
| 316 |
-
for rec in recs:
|
| 317 |
-
assert rec.evidence_count >= 5
|
| 318 |
-
assert rec.confidence >= 0.3
|
| 319 |
-
|
| 320 |
-
def test_codex_writer_targets_agents_md(self):
|
| 321 |
"""Codex writer should target AGENTS.md, not CLAUDE.md."""
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
|
|
|
|
|
|
| 330 |
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
assert "CLAUDE.md" not in fp.name
|
|
|
|
| 3 |
These tests run against actual conversation data on the machine.
|
| 4 |
They verify the full pipeline: scan → analyze → recommend → write.
|
| 5 |
Tests are skipped if the required data directories don't exist.
|
| 6 |
+
The LLM-based analyzer tests require ANTHROPIC_API_KEY.
|
| 7 |
|
| 8 |
Key behaviors tested:
|
| 9 |
+
- Empty recommendations don't create files
|
| 10 |
+
- Real Codex/Claude Code sessions can be scanned
|
|
|
|
| 11 |
- Skip logic: no file writes when nothing meaningful is found
|
| 12 |
- Idempotency: running twice produces same output
|
| 13 |
"""
|
| 14 |
|
| 15 |
from __future__ import annotations
|
| 16 |
|
| 17 |
+
import os
|
| 18 |
from pathlib import Path
|
| 19 |
+
from unittest.mock import patch
|
| 20 |
|
| 21 |
import pytest
|
| 22 |
|
| 23 |
+
from headroom.learn.analyzer import SessionAnalyzer
|
| 24 |
from headroom.learn.models import (
|
|
|
|
| 25 |
ProjectInfo,
|
| 26 |
Recommendation,
|
| 27 |
RecommendationTarget,
|
| 28 |
)
|
| 29 |
+
from headroom.learn.writer import ClaudeCodeWriter, CodexWriter
|
| 30 |
|
| 31 |
# =============================================================================
|
| 32 |
+
# Writer Tests (no LLM needed)
|
| 33 |
# =============================================================================
|
| 34 |
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
class TestSkipWriteLogic:
|
| 37 |
"""Verify files are NOT written when there's nothing to write."""
|
| 38 |
|
|
|
|
| 102 |
|
| 103 |
# Normal code output that happens to contain "error" in identifiers
|
| 104 |
assert not is_error_content("def handle_error(e):\n print('ok')")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
def test_real_error_detected(self):
|
| 107 |
"""Actual errors should be detected."""
|
|
|
|
| 121 |
|
| 122 |
CLAUDE_DIR = Path.home() / ".claude" / "projects"
|
| 123 |
CODEX_DIR = Path.home() / ".codex" / "sessions"
|
| 124 |
+
HAS_API_KEY = bool(os.environ.get("ANTHROPIC_API_KEY"))
|
| 125 |
|
| 126 |
|
| 127 |
@pytest.mark.skipif(not CLAUDE_DIR.exists(), reason="No Claude Code data")
|
|
|
|
| 138 |
assert p.name
|
| 139 |
assert p.data_path.exists()
|
| 140 |
|
| 141 |
+
def test_scanner_extracts_events(self):
|
| 142 |
+
"""Scanner should extract events including user messages."""
|
| 143 |
from headroom.learn.scanner import ClaudeCodeScanner
|
| 144 |
|
| 145 |
scanner = ClaudeCodeScanner()
|
| 146 |
projects = scanner.discover_projects()
|
|
|
|
|
|
|
| 147 |
best = max(projects, key=lambda p: len(list(p.data_path.glob("*.jsonl"))))
|
|
|
|
| 148 |
sessions = scanner.scan_project(best)
|
| 149 |
assert len(sessions) > 0
|
| 150 |
|
| 151 |
+
# At least some sessions should have events
|
| 152 |
+
sessions_with_events = [s for s in sessions if s.events]
|
| 153 |
+
assert len(sessions_with_events) > 0
|
| 154 |
+
|
| 155 |
+
@pytest.mark.skipif(not HAS_API_KEY, reason="No ANTHROPIC_API_KEY")
|
| 156 |
+
def test_full_pipeline_produces_output(self):
|
| 157 |
+
"""Scan → analyze on real data produces valid output."""
|
| 158 |
+
from headroom.learn.scanner import ClaudeCodeScanner
|
| 159 |
+
|
| 160 |
+
scanner = ClaudeCodeScanner()
|
| 161 |
+
projects = scanner.discover_projects()
|
| 162 |
+
best = max(projects, key=lambda p: len(list(p.data_path.glob("*.jsonl"))))
|
| 163 |
+
sessions = scanner.scan_project(best)
|
| 164 |
+
assert len(sessions) > 0
|
| 165 |
|
| 166 |
+
analyzer = SessionAnalyzer()
|
| 167 |
+
result = analyzer.analyze(best, sessions)
|
|
|
|
|
|
|
| 168 |
|
| 169 |
+
assert result.total_calls > 0
|
| 170 |
+
assert result.total_sessions > 0
|
| 171 |
+
assert 0 <= result.failure_rate <= 0.5
|
| 172 |
|
| 173 |
def test_dry_run_writes_nothing(self):
|
| 174 |
"""Dry run should never create files."""
|
|
|
|
| 178 |
projects = scanner.discover_projects()
|
| 179 |
best = max(projects, key=lambda p: len(list(p.data_path.glob("*.jsonl"))))
|
| 180 |
|
| 181 |
+
# Use mock LLM to avoid needing API key
|
| 182 |
+
mock_response = {
|
| 183 |
+
"context_file_rules": [
|
| 184 |
+
{
|
| 185 |
+
"section": "Test",
|
| 186 |
+
"content": "- test rule",
|
| 187 |
+
"estimated_tokens_saved": 100,
|
| 188 |
+
"evidence_count": 2,
|
| 189 |
+
}
|
| 190 |
+
],
|
| 191 |
+
"memory_file_rules": [],
|
| 192 |
+
}
|
| 193 |
+
with patch("headroom.learn.analyzer._call_llm", return_value=mock_response):
|
| 194 |
+
sessions = scanner.scan_project(best)
|
| 195 |
+
result = SessionAnalyzer().analyze(best, sessions)
|
| 196 |
+
recs = result.recommendations
|
| 197 |
+
|
| 198 |
+
writer = ClaudeCodeWriter()
|
| 199 |
+
write_result = writer.write(recs, best, dry_run=True)
|
| 200 |
+
|
| 201 |
+
assert write_result.dry_run is True
|
| 202 |
+
for fp in write_result.files_written:
|
| 203 |
+
assert "CLAUDE.md" in fp.name or "MEMORY.md" in fp.name
|
| 204 |
|
| 205 |
|
| 206 |
@pytest.mark.skipif(not CODEX_DIR.exists(), reason="No Codex data")
|
|
|
|
| 224 |
|
| 225 |
assert len(sessions) > 0
|
| 226 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
# Codex has only Bash tool (shell)
|
| 228 |
all_tools = {tc.name for s in sessions for tc in s.tool_calls}
|
| 229 |
assert "Bash" in all_tools
|
| 230 |
|
| 231 |
+
def test_codex_writer_targets_agents_md(self, tmp_path):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
"""Codex writer should target AGENTS.md, not CLAUDE.md."""
|
| 233 |
+
proj = ProjectInfo(name="codex-test", project_path=tmp_path, data_path=tmp_path)
|
| 234 |
+
recs = [
|
| 235 |
+
Recommendation(
|
| 236 |
+
target=RecommendationTarget.CONTEXT_FILE,
|
| 237 |
+
section="Commands",
|
| 238 |
+
content="- Use npm run test",
|
| 239 |
+
confidence=0.9,
|
| 240 |
+
evidence_count=5,
|
| 241 |
+
)
|
| 242 |
+
]
|
| 243 |
|
| 244 |
+
writer = CodexWriter()
|
| 245 |
+
result = writer.write(recs, proj, dry_run=True)
|
| 246 |
+
for fp in result.files_written:
|
| 247 |
+
assert fp.name in ("AGENTS.md", "instructions.md")
|
| 248 |
+
assert "CLAUDE.md" not in fp.name
|
|
|