0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
890 lines
34 KiB
Python
890 lines
34 KiB
Python
"""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.
|
||
Also supports CLI-based backends (claude, gemini, codex) for subscription
|
||
users without raw API keys.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import queue
|
||
import shutil
|
||
import subprocess
|
||
import threading
|
||
import time
|
||
import typing
|
||
|
||
from headroom._subprocess import Popen, run
|
||
|
||
from .loops import LoopPattern, apply_loop_weighting, detect_loops, format_loops_for_digest
|
||
from .models import (
|
||
AnalysisResult,
|
||
ProjectInfo,
|
||
Recommendation,
|
||
RecommendationTarget,
|
||
SessionData,
|
||
SessionEvent,
|
||
ToolCall,
|
||
)
|
||
from .writer import extract_marker_block
|
||
|
||
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-flash-latest"),
|
||
]
|
||
|
||
_MAX_DIGEST_TOKENS = 80_000 # Budget for the digest (leave room for prompt + output)
|
||
|
||
# CLI tools to try when no API key is set (checked in order).
|
||
# Each entry: (binary_name, model_identifier, command_prefix). The claude-cli
|
||
# command uses stream-json output so the analyzer can detect progress and
|
||
# enforce an idle (rather than wall-clock-only) timeout — see _call_cli_llm.
|
||
_CLI_BACKENDS: list[tuple[str, str, list[str]]] = [
|
||
("claude", "claude-cli", ["claude", "-p", "--output-format", "stream-json", "--verbose"]),
|
||
("gemini", "gemini-cli", ["gemini", "-p"]),
|
||
("codex", "codex-cli", ["codex", "exec"]),
|
||
]
|
||
|
||
# Set of valid CLI model identifiers, derived from _CLI_BACKENDS.
|
||
_CLI_MODEL_IDS: set[str] = {model for _, model, _ in _CLI_BACKENDS}
|
||
|
||
_USER_PROMPT_PREFIX = "Analyze these coding agent sessions and return JSON recommendations:\n\n" # Shared by _call_cli_llm and _call_llm
|
||
_MAX_SNIPPET_LEN = 2000 # Max chars of CLI output (stdout/stderr) in error messages
|
||
# Hard wall-clock cap for CLI backends (seconds). Override with
|
||
# HEADROOM_LEARN_CLI_TIMEOUT_SECS for slow networks or large digests.
|
||
_CLI_TIMEOUT = 300
|
||
# Idle cap (seconds) for streaming claude-cli: kill if no output arrives for
|
||
# this long. Lets us catch genuine hangs quickly while letting long-but-active
|
||
# analyses run to completion. Override with HEADROOM_LEARN_CLI_IDLE_TIMEOUT_SECS.
|
||
_CLI_IDLE_TIMEOUT = 60
|
||
|
||
|
||
def _resolve_windows_cli_shim(cmd: list[str]) -> list[str] | None:
|
||
"""Resolve an npm-installed CLI shim to its real executable on Windows.
|
||
|
||
``subprocess`` launches via ``CreateProcess`` on Windows, which — unlike a
|
||
shell — does not apply the ``PATHEXT`` extension search. An npm-installed
|
||
CLI's PATH entry is usually a ``.cmd``/``.bat`` shim, so the bare command
|
||
name raises ``FileNotFoundError`` even though ``shutil.which`` (which does
|
||
apply ``PATHEXT``) resolves it fine. Re-resolve through ``shutil.which``
|
||
and retry with the resolved path.
|
||
"""
|
||
if os.name != "nt":
|
||
return None
|
||
resolved = shutil.which(cmd[0])
|
||
if resolved is None:
|
||
return None
|
||
return [resolved, *cmd[1:]]
|
||
|
||
|
||
def _resolve_timeout_secs(env_var: str, default: int) -> int:
|
||
"""Resolve a positive-integer timeout from *env_var* or fall back to *default*.
|
||
|
||
Invalid or non-positive values are logged and ignored so a typo in env
|
||
config can't accidentally disable the timeout.
|
||
"""
|
||
raw = os.environ.get(env_var)
|
||
if raw is None or raw == "":
|
||
return default
|
||
try:
|
||
value = int(raw)
|
||
except ValueError:
|
||
logger.warning("Invalid %s=%r — using default %ds", env_var, raw, default)
|
||
return default
|
||
if value <= 0:
|
||
logger.warning(
|
||
"Invalid %s=%r (must be positive) — using default %ds", env_var, raw, default
|
||
)
|
||
return default
|
||
return value
|
||
|
||
|
||
def _detect_default_model() -> str:
|
||
"""Pick the best available model based on API keys, env config, or CLI tools.
|
||
|
||
Priority order:
|
||
1. API key present → use corresponding LiteLLM model
|
||
2. HEADROOM_LEARN_CLI env var → use specified CLI backend
|
||
3. Auto-detect installed CLI tools (claude > gemini > codex)
|
||
4. Raise RuntimeError with setup instructions
|
||
"""
|
||
# 1. API key detection (existing behavior)
|
||
for env_var, model in _MODEL_DEFAULTS:
|
||
if os.environ.get(env_var):
|
||
return model
|
||
|
||
# 2. Explicit CLI selection via environment variable
|
||
cli_override = os.environ.get("HEADROOM_LEARN_CLI")
|
||
if cli_override:
|
||
for cli_name, model, _cmd in _CLI_BACKENDS:
|
||
if cli_name == cli_override:
|
||
logger.info("HEADROOM_LEARN_CLI=%s — using %s CLI backend", cli_override, cli_name)
|
||
return model
|
||
valid = ", ".join(name for name, _, _ in _CLI_BACKENDS)
|
||
raise ValueError(
|
||
f"HEADROOM_LEARN_CLI={cli_override!r} is not a supported CLI. Valid values: {valid}"
|
||
)
|
||
|
||
# 3. Auto-detect installed CLI tools
|
||
for cli_name, model, _cmd in _CLI_BACKENDS:
|
||
if shutil.which(cli_name):
|
||
logger.info("No API key found — auto-detected %s CLI as LLM backend", cli_name)
|
||
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-flash-latest\n"
|
||
"Or set HEADROOM_LEARN_CLI to a coding agent CLI (claude, gemini, codex).\n"
|
||
"Or install one of those CLIs for auto-detection.\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),
|
||
)
|
||
|
||
# Detect loops up front: an RTK re-fetch loop has NO failed calls
|
||
# (each truncated command succeeds), so it must be a first-class reason
|
||
# to analyze — otherwise the guard below would skip the most expensive
|
||
# waste pattern whenever a session has no failures and no events.
|
||
loops = detect_loops(sessions)
|
||
|
||
if not failed_calls and not loops and not any(s.events for s in sessions):
|
||
return result
|
||
|
||
# Build compact digest of all sessions, leading with detected loops.
|
||
digest = _build_digest(project, sessions, loops=loops)
|
||
|
||
# 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)
|
||
# Weight loop guardrails above one-off rules using MEASURED waste.
|
||
apply_loop_weighting(result.recommendations, loops)
|
||
result.recommendations.sort(key=lambda r: r.estimated_tokens_saved, reverse=True)
|
||
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_prior_patterns_section(project: ProjectInfo) -> str:
|
||
"""Format the current marker blocks from CLAUDE.md / MEMORY.md for the LLM.
|
||
|
||
Returns "" when neither file exists nor contains a marker block. When at
|
||
least one file has a block, returns a header + labeled raw blocks so the
|
||
LLM can treat them as the starting baseline. See the "Prior Learned
|
||
Patterns" rule in _SYSTEM_PROMPT for the contract with the model.
|
||
"""
|
||
parts: list[tuple[str, str]] = [] # (label, block)
|
||
candidates = (
|
||
("CLAUDE.md (CONTEXT_FILE, project-level stable facts)", project.context_file),
|
||
("MEMORY.md (MEMORY_FILE, session-level evolving preferences)", project.memory_file),
|
||
)
|
||
for label, path in candidates:
|
||
if path is None or not path.exists():
|
||
continue
|
||
block = extract_marker_block(path.read_text(encoding="utf-8", errors="replace"))
|
||
if block:
|
||
parts.append((label, block))
|
||
|
||
if not parts:
|
||
return ""
|
||
|
||
lines = [
|
||
"=== Prior Learned Patterns ===",
|
||
(
|
||
f"These patterns are currently written to {project.name}'s context "
|
||
f"files. They are your starting baseline — see the 'Prior Learned "
|
||
f"Patterns' rule in the system prompt for how to integrate them."
|
||
),
|
||
"",
|
||
]
|
||
for label, block in parts:
|
||
lines.append(f"--- From {label} ---")
|
||
lines.append(block)
|
||
lines.append("")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _build_digest(
|
||
project: ProjectInfo,
|
||
sessions: list[SessionData],
|
||
loops: list[LoopPattern] | None = None,
|
||
) -> str:
|
||
"""Build a token-efficient text digest of all session events.
|
||
|
||
The digest includes:
|
||
- Project context
|
||
- Detected loops (highest priority) — repeated patterns + measured waste
|
||
- Prior learned patterns (if any) from CLAUDE.md / MEMORY.md
|
||
- Per-session summaries with condensed event streams
|
||
- Error outputs (truncated), success indicators, user messages
|
||
|
||
``loops`` is computed by the caller (``SessionAnalyzer.analyze``) and passed
|
||
in to avoid detecting twice; when omitted it is detected here so callers
|
||
that build a digest directly still surface loops.
|
||
"""
|
||
if loops is None:
|
||
loops = detect_loops(sessions)
|
||
|
||
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("")
|
||
|
||
# Detected loops first — the most expensive waste pattern, so the LLM sees
|
||
# it before the (budget-truncatable) per-session event stream.
|
||
loop_section = format_loops_for_digest(loops)
|
||
if loop_section:
|
||
lines.append(loop_section)
|
||
|
||
# Prior learned patterns (if any) — gives the LLM the current baseline so
|
||
# it can produce complete updated sections instead of condensed deltas.
|
||
prior_section = _build_prior_patterns_section(project)
|
||
if prior_section:
|
||
lines.append(prior_section)
|
||
|
||
# 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 (in priority order):
|
||
1. **Loops (HIGHEST PRIORITY)** — patterns that REPEATED within a session. If the
|
||
digest has a "Detected Loops" section, every loop there MUST get a guardrail
|
||
rule, because loop waste scales with repetition. This includes RTK re-fetch
|
||
loops: a command whose output was truncated, so the agent re-ran variants of
|
||
it to fetch more. The fix names the command and prescribes getting the full
|
||
output up front (e.g., "read the whole file" / "raise the output limit for X").
|
||
2. **Environment rules** — what runtime commands work vs fail (e.g., "use uv run python, not python3")
|
||
3. **File structure facts** — known large files, correct paths, search scopes
|
||
4. **User preferences** — things the user corrected, rejected, or explicitly requested
|
||
5. **Failure patterns** — repeated failures that could be prevented with upfront knowledge
|
||
6. **Workflow rules** — subagent guidance, command execution preferences
|
||
7. **Token waste hotspots** — patterns that waste the most tokens (re-reads, wrong paths, retries)
|
||
|
||
Rules:
|
||
- A loop in the "Detected Loops" section is sufficient evidence on its own — emit
|
||
its guardrail even if it appears only once as a loop, and set its
|
||
estimated_tokens_saved to at least the measured wasted tokens reported there.
|
||
- 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)
|
||
|
||
Prior Learned Patterns:
|
||
- The input may contain a "Prior Learned Patterns" section showing what is
|
||
already written to the project's CLAUDE.md / MEMORY.md. Treat those as the
|
||
starting baseline for your analysis.
|
||
- When you re-emit a section heading that appears in the prior block, your
|
||
output REPLACES that prior section wholesale — so your section must be the
|
||
COMPLETE updated version:
|
||
* Preserve prior bullets that remain accurate (copy them forward)
|
||
* Revise bullets when new evidence refines them (merge, don't duplicate)
|
||
* Drop a prior bullet only when contradicted by clear new evidence
|
||
- Sections from prior runs that you do NOT re-emit are preserved automatically
|
||
by the writer, so focus only on sections where you have something to add or
|
||
change. Do NOT re-emit a prior section just to echo it verbatim — that wastes
|
||
output tokens without changing the outcome.
|
||
- Do NOT write bullets that reference prior siblings you are about to drop
|
||
(e.g., "X is ALSO large — same rule as Y, Z") unless Y and Z are also present
|
||
in your current output or preserved in the prior block.
|
||
|
||
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 _strip_fenced_json(raw: str) -> dict:
|
||
"""Strip optional markdown fences and parse JSON.
|
||
|
||
Handles raw JSON and fenced code blocks (e.g. ```json ... ```), including
|
||
the case where the model prefixes prose before the fence (e.g. "Here is the
|
||
JSON:") despite being told to return JSON only. Between the first opening
|
||
fence and last closing fence is preferred, preserving any triple-backtick
|
||
content inside the JSON payload; a first-``{`` / last-``}`` slice is the
|
||
final fallback.
|
||
|
||
Args:
|
||
raw: Raw text output from an LLM, possibly wrapped in markdown fences
|
||
and/or preceded by explanatory prose.
|
||
|
||
Returns:
|
||
Parsed JSON as a dictionary.
|
||
|
||
Raises:
|
||
json.JSONDecodeError: If no candidate parses as a JSON object.
|
||
"""
|
||
text = raw.strip()
|
||
|
||
candidates: list[str] = []
|
||
# 1. Fenced block located anywhere (tolerates a prose preamble before it).
|
||
lines = text.split("\n")
|
||
fence_idxs = [i for i, ln in enumerate(lines) if ln.strip().startswith("```")]
|
||
if len(fence_idxs) >= 2:
|
||
candidates.append("\n".join(lines[fence_idxs[0] + 1 : fence_idxs[-1]]))
|
||
elif len(fence_idxs) == 1:
|
||
candidates.append("\n".join(lines[fence_idxs[0] + 1 :]))
|
||
# 2. The whole text as-is (the common raw-JSON case).
|
||
candidates.append(text)
|
||
# 3. First-``{`` .. last-``}`` slice (prose on both sides, no fence).
|
||
start, end = text.find("{"), text.rfind("}")
|
||
if start != -1 and end > start:
|
||
candidates.append(text[start : end + 1])
|
||
|
||
for candidate in candidates:
|
||
try:
|
||
parsed = json.loads(candidate)
|
||
except json.JSONDecodeError:
|
||
continue
|
||
if isinstance(parsed, dict):
|
||
return parsed
|
||
|
||
# Nothing parsed as an object: re-raise the natural error on the raw text
|
||
# so callers see a JSONDecodeError, preserving the documented contract.
|
||
result: dict = json.loads(text)
|
||
return result
|
||
|
||
|
||
def _call_cli_llm(digest: str, model: str) -> dict:
|
||
"""Call a locally installed CLI tool as the LLM backend.
|
||
|
||
Enables keyless usage for subscription-based CLI tools that handle
|
||
their own OAuth authentication. The prompt is passed via stdin to avoid
|
||
OS ``ARG_MAX`` limits and argument-injection risks.
|
||
|
||
CLI invocations:
|
||
claude-cli → claude -p --output-format stream-json --verbose (idle-timeout)
|
||
gemini-cli → gemini -p (wall-clock timeout)
|
||
codex-cli → codex exec (wall-clock timeout)
|
||
|
||
The claude-cli path streams JSON events, letting the analyzer kill genuine
|
||
hangs while letting long-but-active analyses run to completion.
|
||
|
||
Args:
|
||
digest: Token-efficient session digest to analyze.
|
||
model: CLI model identifier (e.g. ``claude-cli``).
|
||
|
||
Returns:
|
||
Parsed JSON recommendations from the CLI tool.
|
||
|
||
Raises:
|
||
ValueError: If *model* is not a known CLI backend.
|
||
RuntimeError: If the CLI is not installed, exits non-zero, or times out.
|
||
"""
|
||
cmd: list[str] | None = None
|
||
for _name, model_name, cmd_parts in _CLI_BACKENDS:
|
||
if model_name == model:
|
||
cmd = cmd_parts
|
||
break
|
||
if cmd is None:
|
||
raise ValueError(f"Unknown CLI model: {model}")
|
||
|
||
prompt = _SYSTEM_PROMPT + "\n\n" + _USER_PROMPT_PREFIX + digest
|
||
hard_cap = _resolve_timeout_secs("HEADROOM_LEARN_CLI_TIMEOUT_SECS", _CLI_TIMEOUT)
|
||
|
||
if model == "claude-cli":
|
||
idle_cap = _resolve_timeout_secs("HEADROOM_LEARN_CLI_IDLE_TIMEOUT_SECS", _CLI_IDLE_TIMEOUT)
|
||
return _call_claude_cli_streaming(cmd, prompt, hard_cap=hard_cap, idle_cap=idle_cap)
|
||
|
||
try:
|
||
result = run(
|
||
cmd,
|
||
input=prompt,
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=hard_cap,
|
||
)
|
||
except FileNotFoundError:
|
||
shim_cmd = _resolve_windows_cli_shim(cmd)
|
||
if shim_cmd is None:
|
||
raise RuntimeError(
|
||
f"`{cmd[0]}` not found in PATH. Install it or use a different backend "
|
||
"with --model <litellm-model-name>."
|
||
) from None
|
||
cmd = shim_cmd
|
||
try:
|
||
result = run(cmd, input=prompt, capture_output=True, text=True, timeout=hard_cap)
|
||
except FileNotFoundError:
|
||
raise RuntimeError(
|
||
f"`{cmd[0]}` not found in PATH. Install it or use a different backend "
|
||
"with --model <litellm-model-name>."
|
||
) from None
|
||
except subprocess.TimeoutExpired:
|
||
raise RuntimeError(
|
||
f"`{' '.join(cmd)}` did not respond within {hard_cap}s. "
|
||
"Check network connectivity, raise HEADROOM_LEARN_CLI_TIMEOUT_SECS, "
|
||
"or try a different backend with --model <litellm-model-name>."
|
||
) from None
|
||
|
||
if result.returncode != 0:
|
||
stderr_snippet = (result.stderr or "")[:_MAX_SNIPPET_LEN]
|
||
raise RuntimeError(
|
||
f"`{' '.join(cmd)}` failed (exit {result.returncode}):\n{stderr_snippet}"
|
||
)
|
||
|
||
# Log stderr warnings even on success (auth refreshes, deprecation notices).
|
||
if result.stderr and result.stderr.strip():
|
||
logger.debug("CLI stderr (exit 0): %s", result.stderr[:_MAX_SNIPPET_LEN])
|
||
|
||
try:
|
||
return _strip_fenced_json(result.stdout)
|
||
except json.JSONDecodeError as exc:
|
||
stdout_snippet = (result.stdout or "")[:_MAX_SNIPPET_LEN]
|
||
raise RuntimeError(
|
||
f"`{' '.join(cmd)}` returned unparseable output. "
|
||
f"First {_MAX_SNIPPET_LEN} chars:\n{stdout_snippet}"
|
||
) from exc
|
||
|
||
|
||
def _call_claude_cli_streaming(
|
||
cmd: list[str], prompt: str, *, hard_cap: int, idle_cap: int
|
||
) -> dict:
|
||
"""Run claude-cli with stream-json output and an idle-timeout watchdog.
|
||
|
||
Each line of stdout is one JSON event from claude (system/assistant/user/
|
||
result). Any line resets the idle deadline. The process is killed if no
|
||
output arrives for *idle_cap* seconds, or if total elapsed exceeds
|
||
*hard_cap* seconds. The final ``type:"result"`` event carries the assistant
|
||
response, which is then parsed as JSON.
|
||
|
||
Threads (rather than ``select``) drain stdout/stderr so the watchdog works
|
||
on Windows too, where ``select`` does not support pipe handles.
|
||
"""
|
||
|
||
def _popen(cmd: list[str]) -> subprocess.Popen:
|
||
return Popen(
|
||
cmd,
|
||
stdin=subprocess.PIPE,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.PIPE,
|
||
text=True,
|
||
bufsize=1, # line-buffered
|
||
)
|
||
|
||
try:
|
||
proc = _popen(cmd)
|
||
except FileNotFoundError:
|
||
shim_cmd = _resolve_windows_cli_shim(cmd)
|
||
if shim_cmd is None:
|
||
raise RuntimeError(
|
||
f"`{cmd[0]}` not found in PATH. Install it or use a different backend "
|
||
"with --model <litellm-model-name>."
|
||
) from None
|
||
cmd = shim_cmd
|
||
try:
|
||
proc = _popen(cmd)
|
||
except FileNotFoundError:
|
||
raise RuntimeError(
|
||
f"`{cmd[0]}` not found in PATH. Install it or use a different backend "
|
||
"with --model <litellm-model-name>."
|
||
) from None
|
||
|
||
assert proc.stdin is not None and proc.stdout is not None and proc.stderr is not None
|
||
try:
|
||
proc.stdin.write(prompt)
|
||
finally:
|
||
try:
|
||
proc.stdin.close()
|
||
except BrokenPipeError: # pragma: no cover — defensive, claude exits before stdin drain
|
||
pass
|
||
|
||
events: queue.Queue[tuple[str, str | None]] = queue.Queue()
|
||
|
||
def _pump(stream: typing.IO[str], tag: str) -> None:
|
||
try:
|
||
for line in stream:
|
||
events.put((tag, line))
|
||
except Exception as exc: # pragma: no cover — defensive
|
||
logger.debug("stream pump (%s) errored: %s", tag, exc)
|
||
finally:
|
||
events.put((tag, None)) # EOF marker
|
||
|
||
threading.Thread(target=_pump, args=(proc.stdout, "stdout"), daemon=True).start()
|
||
threading.Thread(target=_pump, args=(proc.stderr, "stderr"), daemon=True).start()
|
||
|
||
start = time.monotonic()
|
||
last_activity = start
|
||
stdout_lines: list[str] = []
|
||
stderr_lines: list[str] = []
|
||
final_result: str | None = None
|
||
eofs = 0
|
||
|
||
def _kill(reason: str) -> None:
|
||
proc.kill()
|
||
try:
|
||
proc.wait(timeout=5)
|
||
except (
|
||
subprocess.TimeoutExpired
|
||
): # pragma: no cover — defensive, kill normally returns fast
|
||
pass
|
||
logger.debug("claude-cli killed: %s", reason)
|
||
|
||
while eofs < 2:
|
||
elapsed = time.monotonic() - start
|
||
if elapsed > hard_cap:
|
||
_kill(f"hard cap {hard_cap}s exceeded")
|
||
raise RuntimeError(
|
||
f"`{' '.join(cmd)}` exceeded the {hard_cap}s hard cap. "
|
||
"Raise HEADROOM_LEARN_CLI_TIMEOUT_SECS for slower networks or "
|
||
"larger digests, or try a different backend with "
|
||
"--model <litellm-model-name>."
|
||
)
|
||
idle_elapsed = time.monotonic() - last_activity
|
||
if idle_elapsed > idle_cap:
|
||
_kill(f"idle cap {idle_cap}s exceeded")
|
||
raise RuntimeError(
|
||
f"`{' '.join(cmd)}` produced no output for {idle_cap}s. "
|
||
"Check network connectivity, raise "
|
||
"HEADROOM_LEARN_CLI_IDLE_TIMEOUT_SECS, or try a different "
|
||
"backend with --model <litellm-model-name>."
|
||
)
|
||
|
||
# Block up to 1s waiting for the next event, then re-check deadlines.
|
||
try:
|
||
tag, line = events.get(timeout=1.0)
|
||
except queue.Empty:
|
||
continue
|
||
|
||
if line is None:
|
||
eofs += 1
|
||
continue
|
||
last_activity = time.monotonic()
|
||
if tag == "stdout":
|
||
stdout_lines.append(line)
|
||
event = _parse_stream_event(line)
|
||
if event is not None and event.get("type") == "result":
|
||
# Last result event wins if multiple are emitted.
|
||
result_text = event.get("result")
|
||
if isinstance(result_text, str):
|
||
final_result = result_text
|
||
else:
|
||
stderr_lines.append(line)
|
||
|
||
proc.wait()
|
||
|
||
if proc.returncode != 0:
|
||
stderr_blob = "".join(stderr_lines)[:_MAX_SNIPPET_LEN]
|
||
raise RuntimeError(f"`{' '.join(cmd)}` failed (exit {proc.returncode}):\n{stderr_blob}")
|
||
|
||
stderr_blob = "".join(stderr_lines)
|
||
if stderr_blob.strip():
|
||
logger.debug("CLI stderr (exit 0): %s", stderr_blob[:_MAX_SNIPPET_LEN])
|
||
|
||
if final_result is None:
|
||
stdout_snippet = "".join(stdout_lines)[:_MAX_SNIPPET_LEN]
|
||
raise RuntimeError(
|
||
f"`{' '.join(cmd)}` did not emit a final `result` event. "
|
||
f"First {_MAX_SNIPPET_LEN} chars of stdout:\n{stdout_snippet}"
|
||
)
|
||
|
||
try:
|
||
return _strip_fenced_json(final_result)
|
||
except json.JSONDecodeError as exc:
|
||
snippet = final_result[:_MAX_SNIPPET_LEN]
|
||
raise RuntimeError(
|
||
f"`{' '.join(cmd)}` returned unparseable output. "
|
||
f"First {_MAX_SNIPPET_LEN} chars:\n{snippet}"
|
||
) from exc
|
||
|
||
|
||
def _parse_stream_event(line: str) -> dict | None:
|
||
"""Parse one line of claude-cli stream-json output, returning None on junk."""
|
||
line = line.strip()
|
||
if not line:
|
||
return None
|
||
try:
|
||
parsed = json.loads(line)
|
||
except json.JSONDecodeError:
|
||
return None
|
||
return parsed if isinstance(parsed, dict) else None
|
||
|
||
|
||
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.
|
||
For CLI-based models (ending in "-cli"), delegates to ``_call_cli_llm``.
|
||
"""
|
||
if model in _CLI_MODEL_IDS:
|
||
return _call_cli_llm(digest, model)
|
||
|
||
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": _USER_PROMPT_PREFIX + digest,
|
||
},
|
||
],
|
||
max_tokens=4096,
|
||
api_base=api_base,
|
||
)
|
||
|
||
# Extract text from response
|
||
text = response.choices[0].message.content or ""
|
||
return _strip_fenced_json(text)
|
||
|
||
|
||
# =============================================================================
|
||
# 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)
|