项目文件夹

文件
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 行
34 KiB
Python

此文件含有不可见的 Unicode 字符
此文件含有人类无法区分的不可见的 Unicode 字符,但可以由计算机进行不同的处理。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""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)