915 lines
31 KiB
Python
Executable File
915 lines
31 KiB
Python
Executable File
#!/usr/bin/env -S uv run --script
|
|
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = [
|
|
# "model2vec>=0.3.0",
|
|
# "semantic-router>=0.1.0",
|
|
# "numpy>=1.24.0",
|
|
# "rapidfuzz>=3.0.0"
|
|
# ]
|
|
# ///
|
|
"""
|
|
Contextune UserPromptSubmit Hook
|
|
|
|
Detects slash commands from natural language prompts using 3-tier cascade:
|
|
1. Keyword matching (0.02ms, 60% coverage)
|
|
2. Model2Vec embeddings (0.2ms, 30% coverage)
|
|
3. Semantic Router (50ms, 10% coverage)
|
|
|
|
Uses Claude Code headless mode for interactive prompt analysis and suggestions.
|
|
|
|
Hook Protocol:
|
|
- Input: JSON via stdin with {"prompt": "...", "session_id": "..."}
|
|
- Output: JSON via stdout with {"continue": true, "feedback": "..."}
|
|
"""
|
|
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
# Add lib directory to Python path
|
|
PLUGIN_ROOT = Path(__file__).parent.parent
|
|
sys.path.insert(0, str(PLUGIN_ROOT / "lib"))
|
|
|
|
# Import matchers (now using RapidFuzz-based keyword matcher v2!)
|
|
from keyword_matcher_v2 import IntentMatch, KeywordMatcherV2 as KeywordMatcher
|
|
from model2vec_matcher import Model2VecMatcher
|
|
from observability_db import ObservabilityDB
|
|
from semantic_router_matcher import SemanticRouterMatcher
|
|
|
|
|
|
class ContextuneDetector:
|
|
"""
|
|
3-tier intent detection cascade.
|
|
|
|
Uses your existing matchers in order of speed:
|
|
1. KeywordMatcher (always fast)
|
|
2. Model2VecMatcher (if available)
|
|
3. SemanticRouterMatcher (if API key available)
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._keyword = None
|
|
self._model2vec = None
|
|
self._semantic = None
|
|
|
|
def _get_keyword(self):
|
|
if self._keyword is None:
|
|
self._keyword = KeywordMatcher()
|
|
return self._keyword
|
|
|
|
def _get_model2vec(self):
|
|
if self._model2vec is None:
|
|
m = Model2VecMatcher()
|
|
self._model2vec = m if m.is_available() else None
|
|
return self._model2vec
|
|
|
|
def _get_semantic(self):
|
|
if self._semantic is None:
|
|
m = SemanticRouterMatcher()
|
|
self._semantic = m if m.is_available() else None
|
|
return self._semantic
|
|
|
|
def detect(self, text: str) -> IntentMatch | None:
|
|
"""Detect intent using 3-tier cascade."""
|
|
|
|
# Tier 1: Keyword (always available)
|
|
result = self._get_keyword().match(text)
|
|
if result:
|
|
return result
|
|
|
|
# Tier 2: Model2Vec
|
|
m2v = self._get_model2vec()
|
|
if m2v:
|
|
result = m2v.match(text)
|
|
if result:
|
|
return result
|
|
|
|
# Tier 3: Semantic Router
|
|
sem = self._get_semantic()
|
|
if sem:
|
|
result = sem.match(text)
|
|
if result:
|
|
return result
|
|
|
|
return None
|
|
|
|
|
|
class ClaudeCodeHaikuEngineer:
|
|
"""
|
|
Uses Claude Code headless mode to analyze prompts and provide interactive suggestions.
|
|
|
|
Benefits:
|
|
- No separate API key needed (uses existing Claude Code auth)
|
|
- Integrated billing with Claude Code
|
|
- Fast Haiku model for cost optimization
|
|
- Interactive blocking mode for user feedback
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._claude_available = None
|
|
|
|
def is_available(self) -> bool:
|
|
"""Check if Claude Code CLI is available."""
|
|
if self._claude_available is None:
|
|
try:
|
|
result = subprocess.run(
|
|
["claude", "--version"], capture_output=True, text=True, timeout=2
|
|
)
|
|
self._claude_available = result.returncode == 0
|
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
self._claude_available = False
|
|
|
|
return self._claude_available
|
|
|
|
def analyze_and_enhance(
|
|
self,
|
|
prompt: str,
|
|
detected_command: str,
|
|
confidence: float,
|
|
available_commands: list[str],
|
|
timeout: int = 30,
|
|
) -> dict[str, Any] | None:
|
|
"""
|
|
Analyze prompt using Claude Code headless mode and suggest enhancements.
|
|
|
|
Args:
|
|
prompt: User's original prompt
|
|
detected_command: Command detected by cascade
|
|
confidence: Detection confidence (0-1)
|
|
available_commands: List of all available commands
|
|
timeout: Timeout in seconds
|
|
|
|
Returns:
|
|
Dict with analysis results or None if unavailable/failed
|
|
"""
|
|
if not self.is_available():
|
|
return None
|
|
|
|
# Build analysis prompt for Haiku
|
|
analysis_prompt = f"""You are a prompt enhancement assistant for Contextune, a Claude Code plugin.
|
|
|
|
USER'S PROMPT: "{prompt}"
|
|
|
|
DETECTED COMMAND: {detected_command}
|
|
DETECTION CONFIDENCE: {confidence:.0%}
|
|
|
|
AVAILABLE ALTERNATIVES:
|
|
{chr(10).join(f"- {cmd}" for cmd in available_commands[:10])}
|
|
|
|
TASK: Analyze the user's prompt and provide:
|
|
1. Whether the detected command is the best match (true/false)
|
|
2. Alternative commands if better matches exist
|
|
3. A brief, helpful suggestion for the user
|
|
|
|
RESPONSE FORMAT (JSON):
|
|
{{
|
|
"is_best_match": true/false,
|
|
"alternatives": ["command1", "command2"],
|
|
"suggestion": "Brief suggestion text"
|
|
}}
|
|
|
|
Be concise. Focus on actionability."""
|
|
|
|
try:
|
|
# Call Claude Code headless with Haiku model
|
|
cmd = [
|
|
"claude",
|
|
"--model",
|
|
"claude-haiku-4-5",
|
|
"-p",
|
|
analysis_prompt,
|
|
"--output-format",
|
|
"json",
|
|
"--allowedTools",
|
|
"", # No tools needed for this analysis
|
|
]
|
|
|
|
result = subprocess.run(
|
|
cmd, capture_output=True, text=True, timeout=timeout
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
print(f"DEBUG: Claude Code error: {result.stderr}", file=sys.stderr)
|
|
return None
|
|
|
|
# Parse Claude's response - it's nested in a wrapper object
|
|
claude_response = json.loads(result.stdout)
|
|
|
|
# Extract the actual result (may be nested in "result" field)
|
|
if "result" in claude_response:
|
|
result_text = claude_response["result"]
|
|
# Result may contain JSON in markdown code blocks
|
|
if "```json" in result_text:
|
|
# Extract JSON from markdown code block
|
|
json_start = result_text.find("```json") + 7
|
|
json_end = result_text.find("```", json_start)
|
|
result_text = result_text[json_start:json_end].strip()
|
|
|
|
# Parse the extracted JSON
|
|
analysis = json.loads(result_text)
|
|
return analysis
|
|
else:
|
|
# If no "result" field, assume the whole response is the analysis
|
|
return claude_response
|
|
|
|
except subprocess.TimeoutExpired:
|
|
print(f"DEBUG: Claude Code timeout after {timeout}s", file=sys.stderr)
|
|
return None
|
|
except json.JSONDecodeError as e:
|
|
print(f"DEBUG: Failed to parse Claude response: {e}", file=sys.stderr)
|
|
print(f"DEBUG: Raw output: {result.stdout[:200]}", file=sys.stderr)
|
|
return None
|
|
except Exception as e:
|
|
print(f"DEBUG: Haiku engineer error: {e}", file=sys.stderr)
|
|
return None
|
|
|
|
|
|
def should_process(prompt: str) -> bool:
|
|
"""Check if prompt needs intent detection."""
|
|
if not prompt or not prompt.strip():
|
|
return False
|
|
|
|
# Skip if already a command
|
|
if prompt.strip().startswith("/"):
|
|
return False
|
|
|
|
# Skip internal Haiku analysis prompts (prevent feedback loop)
|
|
if prompt.startswith("You are a prompt enhancement assistant"):
|
|
return False
|
|
|
|
# Skip if too short
|
|
if len(prompt.strip().split()) < 3:
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def write_detection_for_statusline(match: IntentMatch, prompt: str):
|
|
"""Write detection data to observability DB for status line to read."""
|
|
try:
|
|
db = ObservabilityDB(".contextune/observability.db")
|
|
db.set_detection(
|
|
command=match.command,
|
|
confidence=match.confidence,
|
|
method=match.method,
|
|
prompt_preview=prompt[:60] + ("..." if len(prompt) > 60 else ""),
|
|
latency_ms=match.latency_ms,
|
|
)
|
|
|
|
# Also log matcher performance
|
|
db.log_matcher_performance(match.method, match.latency_ms, success=True)
|
|
|
|
print(
|
|
f"DEBUG: Wrote detection to observability DB: {match.command} ({match.confidence:.0%} {match.method})",
|
|
file=sys.stderr,
|
|
)
|
|
except Exception as e:
|
|
# Don't fail hook if observability write fails
|
|
print(f"DEBUG: Failed to write to observability DB: {e}", file=sys.stderr)
|
|
# Also log the error
|
|
try:
|
|
db = ObservabilityDB(".contextune/observability.db")
|
|
db.log_error("user_prompt_submit", type(e).__name__, str(e))
|
|
except:
|
|
pass
|
|
|
|
|
|
def clear_detection_statusline():
|
|
"""Clear status line detection (no match found)."""
|
|
try:
|
|
db = ObservabilityDB(".contextune/observability.db")
|
|
db.clear_detection()
|
|
print("DEBUG: Cleared detection from observability DB", file=sys.stderr)
|
|
except Exception as e:
|
|
print(
|
|
f"DEBUG: Failed to clear detection from observability DB: {e}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
|
|
def get_detection_count() -> int:
|
|
"""Get total number of detections for progressive tips."""
|
|
try:
|
|
db = ObservabilityDB(".contextune/observability.db")
|
|
stats = db.get_stats()
|
|
return stats.get("detections", {}).get("total", 0)
|
|
except:
|
|
pass
|
|
return 0
|
|
|
|
|
|
def increment_detection_count():
|
|
"""Increment detection counter for progressive disclosure."""
|
|
try:
|
|
data_dir = Path.home() / ".claude" / "plugins" / "contextune" / "data"
|
|
data_dir.mkdir(parents=True, exist_ok=True)
|
|
stats_file = data_dir / "detection_stats.json"
|
|
|
|
stats = {"total_detections": 0, "by_method": {}, "by_command": {}}
|
|
if stats_file.exists():
|
|
with open(stats_file) as f:
|
|
stats = json.load(f)
|
|
|
|
stats["total_detections"] = stats.get("total_detections", 0) + 1
|
|
|
|
with open(stats_file, "w") as f:
|
|
json.dump(stats, f, indent=2)
|
|
except:
|
|
pass # Don't fail hook if stats tracking fails
|
|
|
|
|
|
# Command action descriptions for directive feedback
|
|
COMMAND_ACTIONS = {
|
|
# Contextune commands
|
|
"/ctx:design": "design system architecture with structured workflow",
|
|
"/ctx:research": "get fast answers using 3 parallel agents",
|
|
"/ctx:plan": "create parallel development plans",
|
|
"/ctx:execute": "run tasks in parallel worktrees",
|
|
"/ctx:status": "monitor parallel task progress",
|
|
"/ctx:cleanup": "clean up completed worktrees",
|
|
"/ctx:help": "see example-first command guide",
|
|
"/ctx:configure": "enable persistent status bar display",
|
|
"/ctx:stats": "see your time & cost savings",
|
|
"/ctx:verify": "verify and execute detected command with confirmation",
|
|
# Skill-only detections (no commands)
|
|
"skill:ctx:performance": "analyze and optimize parallel workflow performance",
|
|
"skill:ctx:parallel-expert": "get guidance on parallelizing tasks effectively",
|
|
"skill:ctx:help": "discover Contextune features and capabilities",
|
|
"skill:ctx:worktree": "troubleshoot git worktree issues and conflicts",
|
|
}
|
|
|
|
# Skill mapping for reliable Claude execution
|
|
# Maps slash commands AND skill detections to skill names
|
|
# Skills are auto-discovered by Claude Code from: contextune/skills/*/SKILL.md
|
|
SKILL_MAPPING = {
|
|
# Commands with skills
|
|
"/ctx:design": "ctx:architect", # Plugin skill: skills/software-architect
|
|
"/ctx:research": "ctx:researcher", # Plugin skill: skills/researcher
|
|
# Skills without commands (direct skill suggestions)
|
|
"skill:ctx:performance": "ctx:performance",
|
|
"skill:ctx:parallel-expert": "ctx:parallel-expert",
|
|
"skill:ctx:help": "ctx:help",
|
|
"skill:ctx:worktree": "ctx:worktree",
|
|
# Note: /ctx:plan and /ctx:execute are commands, not skills
|
|
# They execute workflows directly rather than providing guidance
|
|
}
|
|
|
|
|
|
def create_skill_augmented_prompt(match: IntentMatch, original_prompt: str) -> str:
|
|
"""
|
|
Augment prompt with skill suggestion for more reliable execution.
|
|
|
|
Evidence: Skills are invoked more reliably than slash commands because
|
|
they use Claude's native Skill tool (structured, type-safe) vs text expansion.
|
|
|
|
Args:
|
|
match: Detected command and confidence
|
|
original_prompt: User's original prompt text
|
|
|
|
Returns:
|
|
Augmented prompt that guides Claude to use skill or command
|
|
"""
|
|
if match.command in SKILL_MAPPING:
|
|
skill_name = SKILL_MAPPING[match.command]
|
|
# Strong directive: "You can use your X skill"
|
|
return f"{original_prompt}. You can use your {skill_name} skill to help with this task."
|
|
else:
|
|
# For commands without skills, use directive language
|
|
action = COMMAND_ACTIONS.get(match.command, "complete this request")
|
|
return f"{original_prompt}. Please use the {match.command} command to {action}."
|
|
|
|
|
|
def get_contextual_tip(match: IntentMatch, detection_count: int) -> str:
|
|
"""Generate directive contextual tip based on usage patterns."""
|
|
|
|
# First-time users (1-3 detections)
|
|
if detection_count <= 3:
|
|
return "New user? Type `/ctx:help` to see all commands with examples"
|
|
|
|
# Early users (4-10 detections) - promote status bar
|
|
elif detection_count <= 10:
|
|
return "Enable persistent detection: Type `/ctx:configure` to set up status bar"
|
|
|
|
# Experienced users (11-20) - promote advanced features
|
|
elif detection_count <= 20:
|
|
if match.command.startswith("/ctx:"):
|
|
return "Want parallel workflows? Type `/ctx:plan` to work on multiple tasks simultaneously"
|
|
return f"Blazing fast: {match.latency_ms:.2f}ms detection. Type `/ctx:stats` to see all metrics"
|
|
|
|
# Power users (21+) - occasional celebration
|
|
else:
|
|
if detection_count % 10 == 0: # Every 10th detection
|
|
return f"🎉 {detection_count} detections! Type `/ctx:stats` to see your time & cost savings"
|
|
return None # No tip for most interactions
|
|
|
|
|
|
def load_available_commands() -> list[str]:
|
|
"""Load list of all available commands for Claude Code."""
|
|
# Return all commands from COMMAND_ACTIONS
|
|
return [cmd for cmd in COMMAND_ACTIONS if cmd.startswith("/")]
|
|
|
|
|
|
def load_available_skills() -> dict[str, str]:
|
|
"""
|
|
Load all available skills from plugin.
|
|
|
|
Returns:
|
|
Dict mapping directory names to skill names
|
|
e.g., {'software-architect': 'ctx:architect'}
|
|
"""
|
|
skills_dir = PLUGIN_ROOT / 'skills'
|
|
|
|
if not skills_dir.exists():
|
|
return {}
|
|
|
|
skill_map = {}
|
|
|
|
for skill_dir in skills_dir.iterdir():
|
|
if not skill_dir.is_dir():
|
|
continue
|
|
|
|
skill_file = skill_dir / 'SKILL.md'
|
|
if not skill_file.exists():
|
|
continue
|
|
|
|
# Read skill name from frontmatter
|
|
try:
|
|
with open(skill_file) as f:
|
|
content = f.read()
|
|
match = re.search(r'^name:\s*(.+)$', content, re.MULTILINE)
|
|
if match:
|
|
skill_name = match.group(1).strip()
|
|
skill_map[skill_dir.name] = skill_name
|
|
except:
|
|
continue
|
|
|
|
return skill_map
|
|
|
|
|
|
def detect_skill_invocation(prompt: str) -> tuple[bool, str]:
|
|
"""
|
|
Detect if user is trying to invoke a skill explicitly.
|
|
|
|
Returns:
|
|
(is_skill_invocation, attempted_skill_name)
|
|
"""
|
|
patterns = [
|
|
r'use (?:the )?([a-z\-:]+) skill',
|
|
r'with (?:the )?([a-z\-:]+) skill',
|
|
r'([a-z\-:]+) skill to',
|
|
r'activate (?:the )?([a-z\-:]+)',
|
|
r'invoke (?:the )?([a-z\-:]+)',
|
|
]
|
|
|
|
for pattern in patterns:
|
|
match = re.search(pattern, prompt.lower())
|
|
if match:
|
|
return True, match.group(1)
|
|
|
|
return False, ''
|
|
|
|
|
|
def find_correct_skill_name(attempted_name: str, skill_map: dict[str, str]) -> tuple[str | None, int]:
|
|
"""
|
|
Find correct skill name using fuzzy matching.
|
|
|
|
Args:
|
|
attempted_name: What user tried to use
|
|
skill_map: Directory → skill name mapping
|
|
|
|
Returns:
|
|
(correct_name, confidence_score)
|
|
"""
|
|
from rapidfuzz import fuzz
|
|
|
|
# Exact directory match
|
|
if attempted_name in skill_map:
|
|
return skill_map[attempted_name], 100
|
|
|
|
# Exact skill name match (already correct)
|
|
if attempted_name in skill_map.values():
|
|
return attempted_name, 100
|
|
|
|
# Fuzzy match
|
|
best_match = None
|
|
best_score = 0
|
|
|
|
for directory, skill_name in skill_map.items():
|
|
# Directory name
|
|
score = fuzz.ratio(attempted_name, directory)
|
|
if score > best_score and score > 70:
|
|
best_score = score
|
|
best_match = skill_name
|
|
|
|
# Skill name
|
|
score = fuzz.ratio(attempted_name, skill_name)
|
|
if score > best_score and score > 70:
|
|
best_score = score
|
|
best_match = skill_name
|
|
|
|
# Skill name without prefix
|
|
skill_base = skill_name.replace('ctx:', '')
|
|
score = fuzz.ratio(attempted_name, skill_base)
|
|
if score > best_score and score > 70:
|
|
best_score = score
|
|
best_match = skill_name
|
|
|
|
return best_match, best_score if best_match else 0
|
|
|
|
|
|
def format_suggestion(match: IntentMatch, detection_count: int = 0) -> str:
|
|
"""Format detection with directive, actionable phrasing."""
|
|
|
|
# Get action description
|
|
action = COMMAND_ACTIONS.get(match.command, "execute this command")
|
|
|
|
# Build directive message
|
|
confidence_pct = int(match.confidence * 100)
|
|
|
|
# Primary directive message
|
|
base_msg = (
|
|
f"💡 Type `{match.command}` to {action} ({confidence_pct}% {match.method}"
|
|
)
|
|
|
|
# Add latency if fast (show performance)
|
|
if match.latency_ms < 1.0:
|
|
base_msg += f", {match.latency_ms:.2f}ms"
|
|
|
|
base_msg += ")"
|
|
|
|
# Get contextual tip
|
|
tip = get_contextual_tip(match, detection_count)
|
|
|
|
if tip:
|
|
return f"{base_msg}\n💡 {tip}"
|
|
|
|
return base_msg
|
|
|
|
|
|
def format_interactive_suggestion(
|
|
match: IntentMatch, analysis: dict[str, Any] | None, detection_count: int = 0
|
|
) -> str:
|
|
"""
|
|
Format interactive suggestion with Haiku analysis.
|
|
|
|
Args:
|
|
match: Detected command match
|
|
analysis: Haiku analysis results (optional)
|
|
detection_count: Total detections for contextual tips
|
|
|
|
Returns:
|
|
Formatted suggestion message
|
|
"""
|
|
# Get action description
|
|
action = COMMAND_ACTIONS.get(match.command, "execute this command")
|
|
confidence_pct = int(match.confidence * 100)
|
|
|
|
# Base detection message
|
|
base_msg = f"🎯 Detected: `{match.command}` ({confidence_pct}% via {match.method})"
|
|
|
|
# Add latency if fast
|
|
if match.latency_ms < 1.0:
|
|
base_msg += f"\n⚡ Detection speed: {match.latency_ms:.2f}ms"
|
|
|
|
# Add Haiku analysis if available
|
|
if analysis:
|
|
if not analysis.get("is_best_match", True):
|
|
alternatives = analysis.get("alternatives", [])
|
|
if alternatives:
|
|
base_msg += "\n\n💡 Better alternatives:"
|
|
for alt in alternatives[:3]:
|
|
alt_action = COMMAND_ACTIONS.get(alt, "execute this command")
|
|
base_msg += f"\n • `{alt}` - {alt_action}"
|
|
|
|
suggestion = analysis.get("suggestion")
|
|
if suggestion:
|
|
base_msg += f"\n\n💬 Suggestion: {suggestion}"
|
|
else:
|
|
# Fallback without analysis
|
|
base_msg += f"\n\n📝 Action: Type `{match.command}` to {action}"
|
|
|
|
# Get contextual tip
|
|
tip = get_contextual_tip(match, detection_count)
|
|
if tip:
|
|
base_msg += f"\n\n💡 Tip: {tip}"
|
|
|
|
return base_msg
|
|
|
|
|
|
def detect_git_workflow(prompt: str) -> tuple[bool, str | None]:
|
|
"""
|
|
Detect git workflow patterns in natural language.
|
|
|
|
Returns: (is_git_workflow, script_command)
|
|
"""
|
|
prompt_lower = prompt.lower()
|
|
|
|
# Git workflow patterns
|
|
patterns = [
|
|
# Commit and push
|
|
(r'\b(commit|save|push|commit.*push|push.*commit)\b', '/ctx:git-commit'),
|
|
# Create PR
|
|
(r'\b(create.*pr|make.*pr|open.*pr|pull.*request)\b', None), # TODO: /ctx:git-pr
|
|
# Merge
|
|
(r'\b(merge|merge.*branch)\b', None), # TODO: /ctx:git-merge
|
|
]
|
|
|
|
for pattern, command in patterns:
|
|
if re.search(pattern, prompt_lower):
|
|
print(f"DEBUG: Git workflow detected: {pattern} → {command}", file=sys.stderr)
|
|
return True, command
|
|
|
|
return False, None
|
|
|
|
|
|
def main():
|
|
"""Hook entry point."""
|
|
|
|
try:
|
|
# Read hook event from stdin
|
|
event_json = sys.stdin.read()
|
|
event = json.loads(event_json)
|
|
|
|
prompt = event.get("prompt", "")
|
|
|
|
# DEBUG: Log what we received
|
|
print(
|
|
f"DEBUG: Contextune hook triggered with prompt: '{prompt}'", file=sys.stderr
|
|
)
|
|
|
|
# Check if we should process
|
|
if not should_process(prompt):
|
|
print("DEBUG: Skipping prompt (should_process=False)", file=sys.stderr)
|
|
# Pass through unchanged
|
|
response = {"continue": True, "suppressOutput": True}
|
|
print(json.dumps(response))
|
|
return
|
|
|
|
print("DEBUG: Processing prompt (should_process=True)", file=sys.stderr)
|
|
|
|
# GIT WORKFLOW DETECTION: Check if user wants git workflow
|
|
is_git_workflow, git_command = detect_git_workflow(prompt)
|
|
|
|
if is_git_workflow and git_command:
|
|
print(f"DEBUG: Git workflow detected, suggesting: {git_command}", file=sys.stderr)
|
|
|
|
feedback = f"""⚡ Git Workflow Detected
|
|
|
|
Your request matches a git workflow pattern.
|
|
|
|
💡 **Recommended:** Use the deterministic slash command instead:
|
|
`{git_command}`
|
|
|
|
**Benefits:**
|
|
- ✅ 93-97% token reduction (~$0.002 vs ~$0.037-0.086)
|
|
- ✅ Single command execution
|
|
- ✅ Deterministic, tested workflow
|
|
- ✅ Auto-detects remote
|
|
- ✅ Proper error handling
|
|
|
|
**Example:**
|
|
```
|
|
{git_command}
|
|
```
|
|
|
|
Continuing with your original prompt, but consider using the slash command for efficiency."""
|
|
|
|
response = {
|
|
"continue": True,
|
|
"additionalContext": feedback,
|
|
"suppressOutput": False
|
|
}
|
|
print(json.dumps(response))
|
|
return
|
|
|
|
# SKILL DETECTION: Check if user is trying to invoke a skill
|
|
is_skill_invocation, attempted_skill = detect_skill_invocation(prompt)
|
|
|
|
if is_skill_invocation:
|
|
print(f"DEBUG: Skill invocation detected: '{attempted_skill}'", file=sys.stderr)
|
|
|
|
# Load available skills
|
|
skill_map = load_available_skills()
|
|
print(f"DEBUG: Loaded {len(skill_map)} skills", file=sys.stderr)
|
|
|
|
# Find correct skill name
|
|
correct_skill, confidence = find_correct_skill_name(attempted_skill, skill_map)
|
|
|
|
if correct_skill and confidence > 70:
|
|
if attempted_skill != correct_skill:
|
|
# Suggest correction
|
|
suggestion = f"""💡 Skill Name Correction
|
|
|
|
Detected: Trying to use '{attempted_skill}' skill
|
|
Correct name: '{correct_skill}' (match confidence: {confidence}%)
|
|
|
|
Use: {correct_skill}
|
|
|
|
Available skills:
|
|
{chr(10).join(f' • {name} (directory: {dir})' for dir, name in skill_map.items())}
|
|
"""
|
|
print(f"DEBUG: Suggesting skill name correction: {attempted_skill} → {correct_skill}", file=sys.stderr)
|
|
|
|
response = {
|
|
"continue": True,
|
|
"additionalContext": suggestion,
|
|
"suppressOutput": False
|
|
}
|
|
print(json.dumps(response))
|
|
return
|
|
else:
|
|
print(f"DEBUG: Skill name already correct: {correct_skill}", file=sys.stderr)
|
|
else:
|
|
print(f"DEBUG: No matching skill found for '{attempted_skill}'", file=sys.stderr)
|
|
|
|
# Initialize detector
|
|
detector = ContextuneDetector()
|
|
|
|
# Detect intent
|
|
match = detector.detect(prompt)
|
|
|
|
print(f"DEBUG: Detection result: {match}", file=sys.stderr)
|
|
|
|
if match is None or match.confidence < 0.7:
|
|
print(
|
|
"DEBUG: No match or low confidence, passing through", file=sys.stderr
|
|
)
|
|
# Clear status line detection (no match)
|
|
clear_detection_statusline()
|
|
# No match or low confidence - pass through
|
|
response = {"continue": True, "suppressOutput": True}
|
|
print(json.dumps(response))
|
|
return
|
|
|
|
# Write detection for status line
|
|
write_detection_for_statusline(match, prompt)
|
|
|
|
# Get current detection count for progressive tips
|
|
detection_count = get_detection_count()
|
|
|
|
# Increment counter
|
|
increment_detection_count()
|
|
|
|
print(
|
|
f"DEBUG: Command detected (detection #{detection_count + 1})",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
# Initialize Haiku engineer for interactive analysis
|
|
engineer = ClaudeCodeHaikuEngineer()
|
|
haiku_analysis = None
|
|
haiku_latency_ms = 0.0
|
|
|
|
# Selective triggering: Only run Haiku for low-confidence or fuzzy/semantic matches
|
|
# High-confidence exact matches (0.95+) are reliable and don't need Haiku validation
|
|
should_run_haiku = match.confidence < 0.95 or match.method in ['fuzzy', 'semantic']
|
|
|
|
if should_run_haiku:
|
|
print(f"DEBUG: Triggering Haiku analysis (confidence={match.confidence:.2f}, method={match.method})", file=sys.stderr)
|
|
else:
|
|
print(f"DEBUG: Skipping Haiku analysis (high-confidence {match.method} match: {match.confidence:.2f})", file=sys.stderr)
|
|
|
|
# Try to get Haiku analysis for better suggestions (only if needed)
|
|
if should_run_haiku and engineer.is_available():
|
|
print(f"DEBUG: Running Haiku analysis...", file=sys.stderr)
|
|
available_commands = load_available_commands()
|
|
|
|
# Track Haiku analysis latency
|
|
import time
|
|
|
|
haiku_start = time.perf_counter()
|
|
|
|
haiku_analysis = engineer.analyze_and_enhance(
|
|
prompt=prompt,
|
|
detected_command=match.command,
|
|
confidence=match.confidence,
|
|
available_commands=available_commands,
|
|
timeout=30,
|
|
)
|
|
|
|
haiku_latency_ms = (time.perf_counter() - haiku_start) * 1000
|
|
|
|
if haiku_analysis:
|
|
print(
|
|
f"DEBUG: Haiku analysis: {json.dumps(haiku_analysis)}",
|
|
file=sys.stderr,
|
|
)
|
|
else:
|
|
print(f"DEBUG: Haiku analysis failed or timed out", file=sys.stderr)
|
|
elif not should_run_haiku:
|
|
print(f"DEBUG: Haiku analysis skipped (selective triggering)", file=sys.stderr)
|
|
else:
|
|
print(
|
|
"DEBUG: Claude Code CLI not available, skipping Haiku analysis",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
# AUGMENT MODE: Modify prompt with skill/command suggestion for reliability
|
|
print("DEBUG: Augmenting prompt for Claude", file=sys.stderr)
|
|
|
|
# Create augmented prompt with skill suggestion
|
|
augmented_prompt = create_skill_augmented_prompt(match, prompt)
|
|
|
|
# Format interactive suggestion with Haiku analysis (if available)
|
|
interactive_msg = format_interactive_suggestion(
|
|
match, haiku_analysis, detection_count
|
|
)
|
|
|
|
# Determine the best command to use
|
|
# If Haiku suggests alternatives and it's not the best match, use the first alternative
|
|
best_command = match.command
|
|
correction_accepted = False
|
|
|
|
if haiku_analysis and not haiku_analysis.get("is_best_match", True):
|
|
alternatives = haiku_analysis.get("alternatives", [])
|
|
if alternatives:
|
|
best_command = alternatives[0]
|
|
correction_accepted = True
|
|
print(
|
|
f"DEBUG: Haiku suggests using {best_command} instead of {match.command}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
# Log correction to observability DB
|
|
if haiku_analysis:
|
|
try:
|
|
db = ObservabilityDB(".contextune/observability.db")
|
|
|
|
# Estimate token counts (rough approximation)
|
|
# Haiku prompt is ~150 tokens + command list + user prompt
|
|
prompt_tokens = (
|
|
150 + len(prompt.split()) + len(load_available_commands()) * 5
|
|
)
|
|
# Response is typically ~50-100 tokens
|
|
completion_tokens = (
|
|
50 + len(str(haiku_analysis.get("suggestion", ""))) // 4
|
|
)
|
|
|
|
db.log_correction(
|
|
original_command=match.command,
|
|
corrected_command=best_command,
|
|
original_confidence=match.confidence,
|
|
correction_accepted=correction_accepted,
|
|
model_name="haiku-4-5",
|
|
reasoning=haiku_analysis.get("suggestion", ""),
|
|
prompt_tokens=prompt_tokens,
|
|
completion_tokens=completion_tokens,
|
|
latency_ms=haiku_latency_ms,
|
|
session_id=event.get("session_id", ""),
|
|
prompt_preview=prompt[:100],
|
|
)
|
|
print(
|
|
f"DEBUG: Logged correction to observability DB (accepted={correction_accepted})",
|
|
file=sys.stderr,
|
|
)
|
|
except Exception as e:
|
|
print(f"DEBUG: Failed to log correction: {e}", file=sys.stderr)
|
|
|
|
# Create augmented prompt with the best command (potentially corrected by Haiku)
|
|
if best_command != match.command:
|
|
# Use Haiku's suggested command
|
|
action = COMMAND_ACTIONS.get(best_command, "complete this request")
|
|
if best_command in SKILL_MAPPING:
|
|
skill_name = SKILL_MAPPING[best_command]
|
|
augmented_prompt = f"{prompt}. You can use your {skill_name} skill to help with this task."
|
|
else:
|
|
augmented_prompt = (
|
|
f"{prompt}. Please use the {best_command} command to {action}."
|
|
)
|
|
else:
|
|
# Use original detection
|
|
augmented_prompt = create_skill_augmented_prompt(match, prompt)
|
|
|
|
# SUGGEST-ONLY MODE: Show detection but let user decide
|
|
# Don't auto-execute - just provide helpful suggestion
|
|
print(
|
|
f"DEBUG: Using suggest-only mode - showing suggestion for {best_command}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
response = {
|
|
"continue": True,
|
|
"additionalContext": interactive_msg,
|
|
"suppressOutput": False
|
|
}
|
|
|
|
print(f"DEBUG: Response: {json.dumps(response)}", file=sys.stderr)
|
|
print(json.dumps(response))
|
|
|
|
except Exception as e:
|
|
# Log error but don't block Claude
|
|
import traceback
|
|
|
|
print(f"Contextune error: {e}", file=sys.stderr)
|
|
print(f"DEBUG: Traceback: {traceback.format_exc()}", file=sys.stderr)
|
|
response = {"continue": True, "suppressOutput": True}
|
|
print(json.dumps(response))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|