chopratejas commited on
Commit
62d80bc
·
1 Parent(s): c8388c1

feat: add `headroom perf` CLI and rewrite `headroom learn` to use LLM analysis

Browse files

Proxy 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 CHANGED
@@ -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 (wrong paths,
101
- missing modules, stubborn retries) and generates context that prevents
102
- them from recurring.
103
 
104
  Supports multiple coding agents: Claude Code, Codex, Gemini CLI.
105
- Auto-detects which agents have data on your machine by default.
106
 
107
  \b
108
  Examples:
109
- headroom learn # Auto-detect agent, analyze current project
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 FailureAnalyzer
116
 
117
- analyzer = FailureAnalyzer()
 
 
 
 
 
 
 
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
- from ..learn.writer import Recommender
175
-
176
- recommender = Recommender()
177
- report = analyzer.analyze(proj, sessions)
178
  total_projects += 1
179
- total_failures += report.total_failures
180
 
181
  click.echo(
182
- f"\n Sessions: {report.total_sessions} | "
183
- f"Calls: {report.total_calls} | "
184
- f"Failures: {report.total_failures} ({report.failure_rate:.1%}) | "
185
- f"Corrections: {len(report.corrections)}"
186
  )
187
 
188
- if report.failure_rate == 0:
189
- click.echo(" No failures found.")
190
  continue
191
 
192
- recommendations = recommender.recommend(report)
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
headroom/cli/main.py CHANGED
@@ -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
 
headroom/cli/perf.py ADDED
@@ -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))
headroom/learn/__init__.py CHANGED
@@ -1,17 +1,12 @@
1
- """Headroom Learn — offline failure learning for coding agents.
2
 
3
- Analyzes conversation logs to find tool call failure patterns and generates
4
- context (CLAUDE.md, MEMORY.md, .cursorrules, etc.) that prevents future failures.
 
5
 
6
  Architecture:
7
- Scanner (adapter) → Analyzer (generic) → Writer (adapter)
8
- ├── ClaudeCodeScanner ├── EnvironmentAnalyzer ├── ClaudeCodeWriter
9
- ── CursorScanner ├── StructureAnalyzer ├── CursorWriter
10
- └── GenericScanner ├── RetryAnalyzer └── GenericWriter
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
  """
headroom/learn/analyzer.py CHANGED
@@ -1,640 +1,390 @@
1
- """Failure analyzers with success correlation.
2
 
3
- The core insight: don't just catalog failures find what SUCCEEDED after
4
- each failure. The diff between failed input and successful input is the
5
- actual learning.
6
 
7
- All analyzers work on normalized ToolCall sequences. They are tool-agnostic:
8
- same analysis works for Claude Code, Cursor, Codex, or any agent with tool calls.
 
 
 
 
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
- AnalysisReport,
19
- CommandPattern,
20
- Correction,
21
- EnvironmentFact,
22
- ErrorCategory,
23
  ProjectInfo,
24
- RetryPattern,
 
25
  SessionData,
26
- StructureNote,
27
  ToolCall,
28
  )
29
 
30
- # How many messages ahead to look for a success after a failure
31
- _CORRECTION_WINDOW = 10
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
- class FailureAnalyzer:
35
- """Runs all analyzers on tool call data and produces an AnalysisReport."""
36
 
37
- def analyze(self, project: ProjectInfo, sessions: list[SessionData]) -> AnalysisReport:
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- report = AnalysisReport(
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
- # Phase 1: Extract failure→success corrections (the core learning)
50
- report.corrections = _extract_corrections(sessions)
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
- # Success Correlation: The Core Learning Primitive
65
- # =============================================================================
66
 
 
 
 
 
 
 
 
67
 
68
- def _extract_corrections(sessions: list[SessionData]) -> list[Correction]:
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
- # Environment Analyzer (uses corrections for python/build tool detection)
108
  # =============================================================================
109
 
110
 
111
- def _analyze_environment(sessions: list[SessionData]) -> list[EnvironmentFact]:
112
- """Detect which runtime commands work vs fail."""
113
- facts: list[EnvironmentFact] = []
114
 
115
- python_failures: Counter[str] = Counter()
116
- python_successes: Counter[str] = Counter()
117
- python_sessions: dict[str, set[str]] = defaultdict(set)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
 
119
  for session in sessions:
120
- for tc in session.tool_calls:
121
- if tc.name not in ("Bash", "bash"):
122
- continue
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
- return facts
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
 
 
159
 
160
- # =============================================================================
161
- # Structure Analyzer (uses corrections to learn correct paths)
162
- # =============================================================================
163
 
164
 
165
- def _analyze_structure(
166
- sessions: list[SessionData], corrections: list[Correction]
167
- ) -> list[StructureNote]:
168
- """Find file structure issues and learn correct paths from corrections."""
169
- notes: list[StructureNote] = []
170
 
171
- # 1. Path corrections: wrong path → correct path (from success correlation)
172
- path_corrections: dict[str, Counter[str]] = defaultdict(Counter)
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
- # 2. Grep scope corrections: narrow path → broader path worked
204
- scope_corrections: dict[str, Counter[str]] = defaultdict(Counter)
205
- for c in corrections:
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
- # 3. Large files (from raw failures, no correction needed)
226
- large_files: Counter[str] = Counter()
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
- # 4. Persistent missing paths (no correction found — file truly doesn't exist)
255
- missing_no_correction: Counter[str] = Counter()
256
- missing_sessions: dict[str, set[str]] = defaultdict(set)
257
- corrected_paths = set(path_corrections.keys())
258
- for session in sessions:
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 notes
285
 
286
 
287
- # =============================================================================
288
- # Command Pattern Analyzer (uses corrections to learn command patterns)
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
- # Build failures: learn what command form works
339
- build_fails = by_category.get(ErrorCategory.BUILD_FAILURE, [])
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
- # Module not found: learn correct python invocation
355
- module_fails = by_category.get(ErrorCategory.MODULE_NOT_FOUND, [])
356
- if module_fails:
357
- wrong_pythons: Counter[str] = Counter()
358
- correct_pythons: Counter[str] = Counter()
359
- for c in module_fails:
360
- wp = _extract_python_command(c.failed_input.get("command", ""))
361
- cp = _extract_python_command(c.success_input.get("command", ""))
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
- # Retry Analyzer (uses corrections to provide specific suggestions)
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
- for session in sessions:
413
- streak: dict[str, list[ToolCall]] = defaultdict(list)
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
- return patterns
450
-
451
-
452
- def _build_specific_suggestion(
453
- tool: str, error_category: str, corrections: list[Correction]
454
- ) -> str:
455
- """Build a specific suggestion from actual corrections, not generic advice."""
456
- if not corrections:
457
- # No corrections available use tool+error specific defaults
458
- return _default_suggestion(tool, error_category)
459
-
460
- # Summarize what corrections tell us
461
- if tool in ("Read", "read") and error_category == "file_not_found":
462
- examples = []
463
- for c in corrections[:3]:
464
- wrong = _shorten_path(c.failed_input.get("file_path", ""))
465
- right = _shorten_path(c.success_input.get("file_path", ""))
466
- if wrong and right:
467
- examples.append(f"`{wrong}` → `{right}`")
468
- if examples:
469
- return "Use Glob to discover actual path. Known corrections: " + "; ".join(examples)
470
-
471
- if tool in ("Grep", "grep"):
472
- # Summarize scope corrections
473
- scopes = set()
474
- for c in corrections[:5]:
475
- right_path = c.success_input.get("path", "")
476
- if right_path:
477
- scopes.add(_shorten_path(right_path))
478
- if scopes:
479
- return f"Scope searches to: {', '.join(sorted(scopes)[:3])}"
480
-
481
- if tool in ("Bash", "bash") and error_category == "user_rejected":
482
- return "User prefers to run this command themselves. Show the command, don't execute it."
483
-
484
- if tool in ("Bash", "bash") and error_category == "module_not_found":
485
- correct_cmds = set()
486
- for c in corrections[:5]:
487
- prefix = _extract_python_command(c.success_input.get("command", ""))
488
- if prefix:
489
- correct_cmds.add(prefix)
490
- if correct_cmds:
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
- # Permission Analyzer
520
  # =============================================================================
521
 
522
 
523
- def _analyze_permissions(sessions: list[SessionData]) -> list[str]:
524
- """Find commands repeatedly denied with specific advice."""
525
- denied: Counter[str] = Counter()
526
- denied_cmds: dict[str, str] = {} # key → full command example
527
 
528
- for session in sessions:
529
- for tc in session.tool_calls:
530
- if not tc.is_error:
531
- continue
532
- if tc.error_category not in (
533
- ErrorCategory.PERMISSION_DENIED,
534
- ErrorCategory.USER_REJECTED,
535
- ):
536
- continue
537
- sig = _extract_command_signature(
538
- tc.input_data.get("command", "")
539
- if tc.name in ("Bash", "bash")
540
- else tc.input_summary
 
 
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
- # Cross-Session Analyzer
559
- # =============================================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
560
 
 
 
561
 
562
- def _analyze_cross_session(sessions: list[SessionData]) -> list[str]:
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
- return cross_session
 
 
 
 
 
 
 
 
 
584
 
585
 
586
  # =============================================================================
587
- # Helpers
588
  # =============================================================================
589
 
590
 
591
- _PYTHON_CMD_RE = re.compile(
592
- r"^((?:source\s+\S+\s*&&\s*)?(?:\.venv/bin/)?(?:python3?|uv run python|uv run|/opt/nflx/python))"
593
- r"(?:\s|$|-)" # Must be followed by space, end, or dash (python3 -c, python -)
594
- )
595
 
 
 
596
 
597
- def _extract_python_command(cmd: str) -> str | None:
598
- """Extract the python invocation prefix from a command."""
599
- cmd = cmd.strip()
600
- if "&&" in cmd:
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)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
headroom/learn/models.py CHANGED
@@ -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 AnalysisReport:
220
- """Complete output of failure analysis for a project."""
221
 
222
  project: ProjectInfo
 
223
  total_calls: int = 0
224
  total_failures: int = 0
225
- total_sessions: int = 0
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:
headroom/learn/scanner.py CHANGED
@@ -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(d, tool_uses, tool_calls, msg_index)
 
 
 
233
 
234
  except (OSError, UnicodeDecodeError) as e:
235
  logger.debug("Failed to read %s: %s", jsonl_path, e)
236
  return None
237
 
238
- return SessionData(session_id=session_id, tool_calls=tool_calls)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- tool_calls.append(
292
- ToolCall(
293
- name=name,
294
- tool_call_id=tc_id,
295
- input_data=inp,
296
- output=result_content,
297
- is_error=is_err,
298
- error_category=error_cat,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  msg_index=msg_index,
300
- output_bytes=len(result_content.encode("utf-8")),
 
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:
headroom/learn/writer.py CHANGED
@@ -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 = self._build_section(context_recs)
301
- full_content = self._merge_into_file(claude_md_path, section_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 = self._build_section(memory_recs)
312
- full_content = self._merge_into_file(memory_path, section_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 = self._build_section(context_recs)
391
- full_content = self._merge_into_file(agents_md, section_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 = self._build_section(memory_recs)
400
- full_content = self._merge_into_file(instructions_md, section_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
- # Gemini has no memory file — all recommendations go to GEMINI.md
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 = self._build_section(all_recs)
460
- full_content = self._merge_into_file(gemini_md, section_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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
headroom/perf/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ """Headroom performance analysis.
2
+
3
+ Parse proxy logs and surface actionable insights via `headroom perf`.
4
+ """
headroom/perf/analyzer.py ADDED
@@ -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
headroom/proxy/server.py CHANGED
@@ -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(f"[{request_id}] CCR: JSON serialization failed: {json_err}")
 
 
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
- # Log to console
2241
- if tokens_saved > 0:
2242
- logger.info(
2243
- f"[{request_id}] {model}: {original_tokens:,} → {optimized_tokens:,} "
2244
- f"(saved {tokens_saved:,} tokens, ${savings_usd:.4f})"
2245
- if savings_usd
2246
- else f"[{request_id}] {model}: {original_tokens:,} → {optimized_tokens:,} "
2247
- f"(saved {tokens_saved:,} tokens)"
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
- # INFO logging for token tracking (temporary for debugging)
3856
- if api_input_tokens is None:
3857
- logger.info(
3858
- f"[{request_id}] No input_tokens from API, using estimate: {optimized_tokens}"
3859
- )
3860
- else:
3861
- logger.info(
3862
- f"[{request_id}] Final tokens: input={api_input_tokens}, "
3863
- f"cache_read={cache_read_tokens}, cache_write={cache_write_tokens}"
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
- if tokens_saved > 0:
4049
- logger.info(
4050
- f"[{request_id}] Bedrock {model}: saved {tokens_saved:,} tokens (streaming)"
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(),
tests/test_learn/test_analyzer.py CHANGED
@@ -1,12 +1,21 @@
1
- """Tests for failure analyzersgeneric tool call pattern recognition."""
2
 
3
  from pathlib import Path
 
4
 
5
- from headroom.learn.analyzer import FailureAnalyzer
 
 
 
 
 
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
- class TestAnalyzerBasics:
43
- def test_empty_sessions(self):
44
- analyzer = FailureAnalyzer()
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
- def test_basic_failure_counting(self):
63
- analyzer = FailureAnalyzer()
 
 
 
 
 
 
 
64
  sessions = [
65
  SessionData(
66
- session_id="s1",
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
- report = analyzer.analyze(_project(), sessions)
75
- assert report.total_calls == 3
76
- assert report.total_failures == 1
77
-
78
 
79
- class TestEnvironmentAnalyzer:
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="Bash",
90
- input_data={"command": "python3 -c 'import mylib'"},
91
- output="ModuleNotFoundError: No module named 'mylib'",
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 -c 'import mylib'"},
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
- report = analyzer.analyze(_project(), sessions)
127
- assert len(report.environment_facts) >= 1
128
- fact = report.environment_facts[0]
129
- assert fact.category == "python"
130
- assert "uv run" in fact.correct_command
131
- assert "python3" in fact.wrong_commands
132
 
 
 
 
 
 
 
 
 
 
 
133
 
134
- class TestStructureAnalyzer:
135
- def test_detects_missing_paths(self):
136
- """Files that repeatedly fail Read → learned as missing."""
137
- analyzer = FailureAnalyzer()
138
- sessions = [
139
- SessionData(
140
- session_id="s1",
141
- tool_calls=[
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
- report = analyzer.analyze(_project(), sessions)
167
- missing = [n for n in report.structure_notes if n.category == "missing_path"]
168
- assert len(missing) >= 1
169
- assert "/src/missing.py" in missing[0].path
 
170
 
171
- def test_detects_large_files(self):
172
- """Files that repeatedly trigger too-large errors → learned."""
173
- analyzer = FailureAnalyzer()
174
- sessions = [
175
- SessionData(
176
- session_id="s1",
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
- report = analyzer.analyze(_project(), sessions)
198
- large = [n for n in report.structure_notes if n.category == "large_file"]
199
- assert len(large) >= 1
200
- assert "/src/huge.py" in large[0].path
201
 
202
- def test_single_occurrence_not_reported(self):
203
- """A single file_not_found shouldn't be reported (might be transient)."""
204
- analyzer = FailureAnalyzer()
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
- class TestRetryAnalyzer:
226
- def test_detects_stubborn_retries(self):
227
- """Same tool failing 3+ times in a row → retry pattern."""
228
- analyzer = FailureAnalyzer()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  sessions = [
230
  SessionData(
231
  session_id="s1",
232
  tool_calls=[
233
- _tc(
234
- name="Bash",
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
- report = analyzer.analyze(_project(), sessions)
246
- assert len(report.retry_patterns) >= 1
247
- pattern = report.retry_patterns[0]
248
- assert pattern.tool_name == "Bash"
249
- assert pattern.max_retries_seen >= 5
250
-
251
- def test_two_failures_not_stubborn(self):
252
- """Only 2 failures shouldn't trigger a retry pattern (threshold is 3)."""
253
- analyzer = FailureAnalyzer()
 
 
 
 
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
- report = analyzer.analyze(_project(), sessions)
277
- assert len(report.retry_patterns) == 0
278
 
 
 
 
 
279
 
280
- class TestCrossSessionAnalyzer:
281
- def test_cross_session_pattern(self):
282
- """Same failure in 3+ sessions cross-session pattern."""
283
- analyzer = FailureAnalyzer()
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
- def test_two_sessions_not_enough(self):
305
- """Only 2 sessions shouldn't trigger cross-session (threshold is 3)."""
306
- analyzer = FailureAnalyzer()
307
- sessions = [
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
- report = analyzer.analyze(_project(), sessions)
324
- assert len(report.cross_session_patterns) == 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
 
327
- class TestPermissionAnalyzer:
328
- def test_detects_repeated_denials(self):
329
- """Commands denied 3+ times → permission note."""
330
  analyzer = FailureAnalyzer()
331
- sessions = [
332
- SessionData(
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 analyzerdigest 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)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_learn/test_integration.py CHANGED
@@ -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
- - Quality gates prevent writing when evidence is weak
9
- - False positives don't generate recommendations
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 FailureAnalyzer
22
  from headroom.learn.models import (
23
- AnalysisReport,
24
  ProjectInfo,
25
  Recommendation,
26
  RecommendationTarget,
27
  )
28
- from headroom.learn.writer import ClaudeCodeWriter, CodexWriter, Recommender
29
 
30
  # =============================================================================
31
- # Quality Gate Tests (synthetic data, always run)
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 test_full_pipeline_produces_output(self):
223
- """Scan analyze recommend on real data produces valid output."""
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
- analyzer = FailureAnalyzer()
236
- report = analyzer.analyze(best, sessions)
 
 
 
 
 
 
 
 
 
 
 
 
237
 
238
- assert report.total_calls > 0
239
- assert report.total_sessions > 0
240
- # Failure rate should be realistic (0-30%)
241
- assert 0 <= report.failure_rate <= 0.5
242
 
243
- # Corrections should be found if there are failures
244
- if report.total_failures > 10:
245
- assert len(report.corrections) > 0
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
- sessions = scanner.scan_project(best)
256
- report = FailureAnalyzer().analyze(best, sessions)
257
- recs = Recommender().recommend(report)
258
-
259
- writer = ClaudeCodeWriter()
260
- result = writer.write(recs, best, dry_run=True)
261
-
262
- assert result.dry_run is True
263
- # Verify no new files were created (check a temp path won't exist)
264
- for fp in result.files_written:
265
- # Files should only be in expected locations
266
- assert "CLAUDE.md" in fp.name or "MEMORY.md" in fp.name
 
 
 
 
 
 
 
 
 
 
 
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
- # Failure rate should be realistic
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
- from headroom.learn.scanner import CodexScanner
323
-
324
- scanner = CodexScanner()
325
- projects = scanner.discover_projects()
326
- sessions = scanner.scan_project(projects[0])
327
-
328
- report = FailureAnalyzer().analyze(projects[0], sessions)
329
- recs = Recommender().recommend(report)
 
 
330
 
331
- if recs:
332
- writer = CodexWriter()
333
- result = writer.write(recs, projects[0], dry_run=True)
334
- for fp in result.files_written:
335
- assert fp.name in ("AGENTS.md", "instructions.md")
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