Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:03:20 +08:00

890 lines
34 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)