Spaces:
Build error
Build error
Download headroom/learn/analyzer.py from minhtudragon/headroom_3: direct link, hf CLI and curl.
- Browser
- Download file 14.3 kB
-
https://huggingface.co/spaces/minhtudragon/headroom_3/resolve/823b8dc3bb2c57bc3f7b6cabcbacaa82ef77d077/headroom/learn/analyzer.py
- Command line
-
hf download hf://spaces/minhtudragon/headroom_3@823b8dc3bb2c57bc3f7b6cabcbacaa82ef77d077/headroom/learn/analyzer.py
-
curl -L -o analyzer.py https://huggingface.co/spaces/minhtudragon/headroom_3/resolve/823b8dc3bb2c57bc3f7b6cabcbacaa82ef77d077/headroom/learn/analyzer.py
14.3 kB
| """Session analysis via LLM β replaces all regex/heuristic analysis. | |
| Pipeline: Scanner (events) β Digest Builder β LLM β Recommendations | |
| No regex patterns, no static lookback windows, no hardcoded heuristics. | |
| A single LLM call understands the full conversation context and produces | |
| structured recommendations for CLAUDE.md / MEMORY.md. | |
| Supports any LLM provider via LiteLLM: Anthropic, OpenAI, Google, Bedrock, | |
| Ollama, and 100+ others. Auto-detects the best available model from env vars. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import logging | |
| import os | |
| from .models import ( | |
| AnalysisResult, | |
| ProjectInfo, | |
| Recommendation, | |
| RecommendationTarget, | |
| SessionData, | |
| SessionEvent, | |
| ToolCall, | |
| ) | |
| logger = logging.getLogger(__name__) | |
| # Default models by provider (checked in order) | |
| _MODEL_DEFAULTS: list[tuple[str, str]] = [ | |
| ("ANTHROPIC_API_KEY", "claude-sonnet-4-6"), | |
| ("OPENAI_API_KEY", "gpt-4o"), | |
| ("GEMINI_API_KEY", "gemini/gemini-2.0-flash"), | |
| ] | |
| _MAX_DIGEST_TOKENS = 80_000 # Budget for the digest (leave room for prompt + output) | |
| def _detect_default_model() -> str: | |
| """Pick the best available model based on which API keys are set.""" | |
| for env_var, model in _MODEL_DEFAULTS: | |
| if os.environ.get(env_var): | |
| return model | |
| raise RuntimeError( | |
| "No LLM API key found. headroom learn needs one of:\n" | |
| " export ANTHROPIC_API_KEY=sk-ant-... β uses claude-sonnet-4-6\n" | |
| " export OPENAI_API_KEY=sk-... β uses gpt-4o\n" | |
| " export GEMINI_API_KEY=... β uses gemini-2.0-flash\n" | |
| "Or specify a model directly: headroom learn --model <litellm-model-name>" | |
| ) | |
| class SessionAnalyzer: | |
| """Analyzes session data via LLM to produce actionable recommendations. | |
| Uses LiteLLM for provider-agnostic access to 100+ models. | |
| Auto-detects the best available model from environment API keys. | |
| """ | |
| def __init__(self, model: str | None = None): | |
| self.model = model | |
| def analyze(self, project: ProjectInfo, sessions: list[SessionData]) -> AnalysisResult: | |
| """Analyze sessions and produce recommendations via LLM.""" | |
| all_calls = [tc for s in sessions for tc in s.tool_calls] | |
| failed_calls = [tc for tc in all_calls if tc.is_error] | |
| result = AnalysisResult( | |
| project=project, | |
| total_sessions=len(sessions), | |
| total_calls=len(all_calls), | |
| total_failures=len(failed_calls), | |
| ) | |
| if not failed_calls and not any(s.events for s in sessions): | |
| return result | |
| # Build compact digest of all sessions | |
| digest = _build_digest(project, sessions) | |
| # Resolve model (auto-detect if not specified) | |
| model = self.model or _detect_default_model() | |
| # Call LLM for analysis | |
| try: | |
| raw = _call_llm(digest, model) | |
| result.recommendations = _parse_llm_response(raw) | |
| except Exception as e: | |
| logger.warning("LLM analysis failed: %s", e) | |
| # Return result with stats but no recommendations | |
| return result | |
| # ============================================================================= | |
| # Digest Builder β compact text representation of session events | |
| # ============================================================================= | |
| def _build_digest(project: ProjectInfo, sessions: list[SessionData]) -> str: | |
| """Build a token-efficient text digest of all session events. | |
| The digest includes: | |
| - Project context | |
| - Per-session summaries with condensed event streams | |
| - Error outputs (truncated), success indicators, user messages | |
| """ | |
| lines: list[str] = [] | |
| # Project header | |
| lines.append(f"Project: {project.name} ({project.project_path})") | |
| total_calls = sum(len(s.tool_calls) for s in sessions) | |
| total_failures = sum(s.failure_count for s in sessions) | |
| total_tokens_in = sum(s.total_input_tokens for s in sessions) | |
| total_tokens_out = sum(s.total_output_tokens for s in sessions) | |
| lines.append( | |
| f"Total: {len(sessions)} sessions, {total_calls} tool calls, " | |
| f"{total_failures} failures ({total_failures / total_calls:.1%})" | |
| if total_calls | |
| else f"Total: {len(sessions)} sessions, 0 tool calls" | |
| ) | |
| if total_tokens_in: | |
| lines.append(f"Tokens used: {total_tokens_in:,} in / {total_tokens_out:,} out") | |
| lines.append("") | |
| # Budget tracking β stop adding events when we approach the limit | |
| # Rough estimate: 4 chars per token | |
| char_budget = _MAX_DIGEST_TOKENS * 4 | |
| chars_used = sum(len(ln) for ln in lines) | |
| for session in sessions: | |
| if chars_used > char_budget: | |
| lines.append( | |
| f"... (remaining {len(sessions) - sessions.index(session)} sessions truncated)" | |
| ) | |
| break | |
| session_header = ( | |
| f"=== Session {session.session_id[:12]} " | |
| f"({len(session.tool_calls)} calls, {session.failure_count} failures" | |
| ) | |
| if session.total_input_tokens: | |
| session_header += f", {session.total_input_tokens:,} input tokens" | |
| session_header += ") ===" | |
| lines.append(session_header) | |
| chars_used += len(session_header) | |
| # Use events if available (richer context), fall back to tool_calls | |
| if session.events: | |
| for event in session.events: | |
| if chars_used > char_budget: | |
| lines.append(" ... (remaining events truncated)") | |
| break | |
| event_line = _format_event(event) | |
| if event_line: | |
| lines.append(event_line) | |
| chars_used += len(event_line) | |
| else: | |
| for tc in session.tool_calls: | |
| if chars_used > char_budget: | |
| lines.append(" ... (remaining calls truncated)") | |
| break | |
| tc_line = _format_tool_call(tc) | |
| lines.append(tc_line) | |
| chars_used += len(tc_line) | |
| lines.append("") | |
| return "\n".join(lines) | |
| def _format_event(event: SessionEvent) -> str | None: | |
| """Format a single event into a compact digest line.""" | |
| if event.type == "tool_call" and event.tool_call: | |
| return _format_tool_call(event.tool_call) | |
| if event.type == "user_message" and event.text.strip(): | |
| text = event.text.strip()[:300] | |
| return f' [{event.msg_index}] USER: "{text}"' | |
| if event.type == "interruption": | |
| return f" [{event.msg_index}] INTERRUPTED: {event.text[:150]}" | |
| if event.type == "agent_summary": | |
| return ( | |
| f" [{event.msg_index}] SUBAGENT: {event.agent_tool_count} tool calls, " | |
| f"{event.agent_tokens:,} tokens, {event.agent_duration_ms / 1000:.1f}s " | |
| f'β prompt: "{event.agent_prompt[:100]}"' | |
| ) | |
| return None | |
| def _format_tool_call(tc: ToolCall) -> str: | |
| """Format a single tool call into a compact digest line.""" | |
| status = "ERROR" if tc.is_error else "OK" | |
| error_cat = f"({tc.error_category.value})" if tc.is_error else "" | |
| # Input summary | |
| input_str = tc.input_summary[:120] | |
| if tc.is_error: | |
| # Include truncated error output for failures | |
| output_preview = tc.output[:200].replace("\n", " ").strip() | |
| return f" [{tc.msg_index}] {tc.name}: {input_str} β {status}{error_cat}: {output_preview}" | |
| else: | |
| # Just indicate success with size | |
| size = f"({tc.output_bytes} bytes)" if tc.output_bytes > 0 else "" | |
| return f" [{tc.msg_index}] {tc.name}: {input_str} β {status} {size}" | |
| # ============================================================================= | |
| # LLM Call β Sonnet 4.6 with structured output | |
| # ============================================================================= | |
| _SYSTEM_PROMPT = """\ | |
| You are an expert at analyzing coding agent sessions to extract actionable patterns. | |
| You will receive a digest of tool call sessions from a coding agent (Claude Code, Codex, etc.). | |
| Your job is to identify patterns that, if documented, would PREVENT TOKEN WASTE in future sessions. | |
| Focus on: | |
| 1. **Environment rules** β what runtime commands work vs fail (e.g., "use uv run python, not python3") | |
| 2. **File structure facts** β known large files, correct paths, search scopes | |
| 3. **User preferences** β things the user corrected, rejected, or explicitly requested | |
| 4. **Failure patterns** β repeated failures that could be prevented with upfront knowledge | |
| 5. **Workflow rules** β subagent guidance, command execution preferences | |
| 6. **Token waste hotspots** β patterns that waste the most tokens (re-reads, wrong paths, retries) | |
| Rules: | |
| - Only include patterns with CLEAR evidence from the data (2+ occurrences or explicit user direction) | |
| - Every recommendation must be specific and actionable (not "be careful" but "use X instead of Y") | |
| - Estimate tokens saved per recommendation (how many tokens would be saved per session if this rule existed) | |
| - Separate stable project facts (CONTEXT_FILE) from evolving preferences (MEMORY_FILE) | |
| - CONTEXT_FILE rules go in CLAUDE.md/AGENTS.md β they are project-level, stable facts | |
| - MEMORY_FILE rules go in MEMORY.md β they are session-level, evolving preferences | |
| - Keep recommendations concise β each should be 1-3 lines of markdown | |
| - Do NOT produce tautological rules (e.g., "use python3 not python3") | |
| - Do NOT produce rules about things that only happened once (transient errors) | |
| Return ONLY valid JSON matching this schema β no other text: | |
| { | |
| "context_file_rules": [ | |
| { | |
| "section": "string β section heading (e.g., 'Environment', 'File Paths', 'Commands')", | |
| "content": "string β markdown content, 1-3 bullet points", | |
| "estimated_tokens_saved": "integer β tokens saved per session if rule existed", | |
| "evidence_count": "integer β number of occurrences supporting this rule" | |
| } | |
| ], | |
| "memory_file_rules": [ | |
| { | |
| "section": "string β section heading", | |
| "content": "string β markdown content, 1-3 bullet points", | |
| "estimated_tokens_saved": "integer", | |
| "evidence_count": "integer" | |
| } | |
| ] | |
| } | |
| """ | |
| def _call_llm(digest: str, model: str) -> dict: | |
| """Call LLM with the session digest and return parsed JSON. | |
| Uses LiteLLM for provider-agnostic access. The model string determines | |
| the provider: "claude-*" β Anthropic, "gpt-*" β OpenAI, "gemini/*" β Google, etc. | |
| """ | |
| import litellm | |
| # Suppress LiteLLM's verbose logging | |
| litellm.suppress_debug_info = True | |
| # For Anthropic models, bypass ANTHROPIC_BASE_URL which may point to | |
| # the user's local headroom proxy | |
| api_base = None | |
| if model.startswith("claude"): | |
| api_base = "https://api.anthropic.com" | |
| response = litellm.completion( | |
| model=model, | |
| messages=[ | |
| {"role": "system", "content": _SYSTEM_PROMPT}, | |
| { | |
| "role": "user", | |
| "content": ( | |
| "Analyze these coding agent sessions and return JSON recommendations:\n\n" | |
| + digest | |
| ), | |
| }, | |
| ], | |
| max_tokens=4096, | |
| api_base=api_base, | |
| ) | |
| # Extract text from response | |
| text = response.choices[0].message.content or "" | |
| # Parse JSON β handle both raw JSON and ```json fenced blocks | |
| text = text.strip() | |
| if text.startswith("```"): | |
| lines = text.split("\n") | |
| lines = [ln for ln in lines[1:] if not ln.strip().startswith("```")] | |
| text = "\n".join(lines) | |
| result: dict = json.loads(text) | |
| return result | |
| # ============================================================================= | |
| # Response Parser β LLM JSON β Recommendation list | |
| # ============================================================================= | |
| def _parse_llm_response(raw: dict) -> list[Recommendation]: | |
| """Convert LLM structured output into Recommendation objects.""" | |
| recommendations: list[Recommendation] = [] | |
| for rule in raw.get("context_file_rules", []): | |
| if not isinstance(rule, dict): | |
| continue | |
| section = rule.get("section", "").strip() | |
| content = rule.get("content", "").strip() | |
| if not section or not content: | |
| continue | |
| recommendations.append( | |
| Recommendation( | |
| target=RecommendationTarget.CONTEXT_FILE, | |
| section=section, | |
| content=content, | |
| confidence=0.9, | |
| evidence_count=_safe_int(rule.get("evidence_count", 1)), | |
| estimated_tokens_saved=_safe_int(rule.get("estimated_tokens_saved", 0)), | |
| ) | |
| ) | |
| for rule in raw.get("memory_file_rules", []): | |
| if not isinstance(rule, dict): | |
| continue | |
| section = rule.get("section", "").strip() | |
| content = rule.get("content", "").strip() | |
| if not section or not content: | |
| continue | |
| recommendations.append( | |
| Recommendation( | |
| target=RecommendationTarget.MEMORY_FILE, | |
| section=section, | |
| content=content, | |
| confidence=0.7, | |
| evidence_count=_safe_int(rule.get("evidence_count", 1)), | |
| estimated_tokens_saved=_safe_int(rule.get("estimated_tokens_saved", 0)), | |
| ) | |
| ) | |
| # Sort by estimated token savings | |
| recommendations.sort(key=lambda r: r.estimated_tokens_saved, reverse=True) | |
| return recommendations | |
| def _safe_int(val: object) -> int: | |
| """Safely convert a value to int.""" | |
| if isinstance(val, int): | |
| return val | |
| if isinstance(val, (float, str)): | |
| try: | |
| return int(val) | |
| except (ValueError, TypeError): | |
| return 0 | |
| return 0 | |
| # ============================================================================= | |
| # Legacy compatibility alias | |
| # ============================================================================= | |
| class FailureAnalyzer: | |
| """Legacy alias for SessionAnalyzer β used by existing CLI code.""" | |
| def __init__(self) -> None: | |
| self._analyzer = SessionAnalyzer() | |
| def analyze(self, project: ProjectInfo, sessions: list[SessionData]) -> AnalysisResult: | |
| return self._analyzer.analyze(project, sessions) | |