Features: - reviewer_tool: Multi-language code review (Python, JS/TS, Go, Rust, Java, C++, C#, PHP) - lazy_loader: Context-triggered skill loading with 3-tier budget system - manifest: Profile-based skill installation (coding, debugging, planning, shipping, research) - instinct: Learning system that tracks patterns and predicts skill loading - hook_profile: Security hook profiles (minimal, standard, strict, developer, rust-dev) - hook_tool + hooks: Post-write hook execution system Code Review Tool: - Supports 9 languages with lint, typecheck, format, test, security checks - Auto-detects language from file extensions - Configurable tools per language (e.g., ruff, eslint, golangci-lint) Lazy Loading: - CONTEXT_TO_SKILLS mapping for 20+ context triggers - Budget-aware loading (Tier 0: core, Tier 1: context, Tier 2: rare) - Emergency mode at >90% context usage Integration: - Registered in model_tools.py and toolsets.py - 8 language reviewers mapped in lazy_loader (python, go, rust, js, java, cpp, csharp, php)
509 lines
17 KiB
Python
509 lines
17 KiB
Python
"""
|
|
Instinct Learning System
|
|
|
|
Learns from successful interactions to improve tool/skill loading.
|
|
Tracks patterns of what tools and skills are used together,
|
|
and uses these patterns to predict future loading needs.
|
|
|
|
Inspired by ECC's experience tracking.
|
|
|
|
Usage:
|
|
from agent.instinct import InstinctLearner, get_instinct_learner
|
|
|
|
# Get the learner singleton
|
|
learner = get_instinct_learner()
|
|
|
|
# Record a successful interaction
|
|
learner.record_interaction(context="debug python code", tools=["terminal", "read_file"], skills=["systematic-debugging"])
|
|
|
|
# Get predicted skills for context
|
|
predictions = learner.predict_for_context("my python is broken")
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Set, Tuple, Any
|
|
from dataclasses import dataclass, field, asdict
|
|
from collections import defaultdict
|
|
from functools import lru_cache
|
|
|
|
from hermes_constants import get_hermes_home
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# =============================================================================
|
|
# Data Structures
|
|
# =============================================================================
|
|
|
|
@dataclass
|
|
class InteractionRecord:
|
|
"""Record of a successful interaction."""
|
|
timestamp: float
|
|
context: str
|
|
context_hash: str # For quick lookup
|
|
tools: List[str]
|
|
skills: List[str]
|
|
success: bool = True
|
|
feedback: Optional[str] = None # Optional user feedback
|
|
|
|
@classmethod
|
|
def create(cls, context: str, tools: List[str], skills: List[str],
|
|
feedback: Optional[str] = None) -> "InteractionRecord":
|
|
import hashlib
|
|
context_hash = hashlib.md5(context.lower().encode()).hexdigest()[:12]
|
|
return cls(
|
|
timestamp=time.time(),
|
|
context=context,
|
|
context_hash=context_hash,
|
|
tools=sorted(tools),
|
|
skills=sorted(skills),
|
|
feedback=feedback,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class PatternStats:
|
|
"""Statistics for a context pattern."""
|
|
count: int = 0
|
|
total_success: int = 0
|
|
avg_tools: float = 0.0
|
|
avg_skills: float = 0.0
|
|
last_used: float = 0.0
|
|
|
|
# Tool/skill co-occurrence counts
|
|
tool_counts: Dict[str, int] = field(default_factory=dict)
|
|
skill_counts: Dict[str, int] = field(default_factory=dict)
|
|
|
|
@property
|
|
def success_rate(self) -> float:
|
|
if self.count == 0:
|
|
return 0.0
|
|
return self.total_success / self.count
|
|
|
|
def to_dict(self) -> Dict:
|
|
return {
|
|
"count": self.count,
|
|
"total_success": self.total_success,
|
|
"avg_tools": self.avg_tools,
|
|
"avg_skills": self.avg_skills,
|
|
"last_used": self.last_used,
|
|
"tool_counts": self.tool_counts,
|
|
"skill_counts": self.skill_counts,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Dict) -> "PatternStats":
|
|
return cls(
|
|
count=data.get("count", 0),
|
|
total_success=data.get("total_success", 0),
|
|
avg_tools=data.get("avg_tools", 0.0),
|
|
avg_skills=data.get("avg_skills", 0.0),
|
|
last_used=data.get("last_used", 0.0),
|
|
tool_counts=data.get("tool_counts", {}),
|
|
skill_counts=data.get("skill_counts", {}),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class InstinctProfile:
|
|
"""
|
|
Learned instincts for a context pattern.
|
|
|
|
Created by aggregating InteractionRecords.
|
|
"""
|
|
pattern: str # The context pattern/key
|
|
tools: List[str] # Most commonly used tools
|
|
skills: List[str] # Most commonly used skills
|
|
confidence: float # 0-1 based on sample size
|
|
sample_size: int
|
|
last_used: float
|
|
|
|
def to_dict(self) -> Dict:
|
|
return {
|
|
"pattern": self.pattern,
|
|
"tools": self.tools,
|
|
"skills": self.skills,
|
|
"confidence": self.confidence,
|
|
"sample_size": self.sample_size,
|
|
"last_used": self.last_used,
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# Instinct Learner
|
|
# =============================================================================
|
|
|
|
class InstinctLearner:
|
|
"""
|
|
Learns from interactions to predict tool/skill loading.
|
|
|
|
Tracks patterns and builds instincts that can be used
|
|
to preload tools and skills before they're explicitly needed.
|
|
"""
|
|
|
|
def __init__(self, storage_path: Optional[Path] = None):
|
|
"""
|
|
Initialize the instinct learner.
|
|
|
|
Args:
|
|
storage_path: Path to store interaction data.
|
|
Defaults to ~/.hermes/instinct/
|
|
"""
|
|
if storage_path is None:
|
|
storage_path = get_hermes_home() / "instinct"
|
|
|
|
self.storage_path = storage_path
|
|
self.storage_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
self.interactions_file = storage_path / "interactions.jsonl"
|
|
self.patterns_file = storage_path / "patterns.json"
|
|
self.instincts_file = storage_path / "instincts.json"
|
|
|
|
# In-memory cache
|
|
self._patterns: Dict[str, PatternStats] = {}
|
|
self._instincts: Dict[str, InstinctProfile] = {}
|
|
self._context_hints: Dict[str, List[str]] = {} # hint -> [pattern]
|
|
|
|
# Load existing data
|
|
self._load()
|
|
|
|
def _load(self) -> None:
|
|
"""Load patterns and instincts from disk."""
|
|
# Load patterns
|
|
if self.patterns_file.exists():
|
|
try:
|
|
data = json.loads(self.patterns_file.read_text())
|
|
self._patterns = {
|
|
k: PatternStats.from_dict(v) for k, v in data.items()
|
|
}
|
|
logger.info(f"Loaded {len(self._patterns)} patterns")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to load patterns: {e}")
|
|
|
|
# Load instincts
|
|
if self.instincts_file.exists():
|
|
try:
|
|
data = json.loads(self.instincts_file.read_text())
|
|
self._instincts = {
|
|
k: InstinctProfile(**v) for k, v in data.items()
|
|
}
|
|
logger.info(f"Loaded {len(self._instincts)} instincts")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to load instincts: {e}")
|
|
|
|
def _save(self) -> None:
|
|
"""Save patterns and instincts to disk."""
|
|
try:
|
|
# Save patterns
|
|
patterns_data = {k: v.to_dict() for k, v in self._patterns.items()}
|
|
self.patterns_file.write_text(json.dumps(patterns_data, indent=2))
|
|
|
|
# Save instincts
|
|
instincts_data = {k: v.to_dict() for k, v in self._instincts.items()}
|
|
self.instincts_file.write_text(json.dumps(instincts_data, indent=2))
|
|
|
|
logger.debug(f"Saved {len(self._patterns)} patterns, {len(self._instincts)} instincts")
|
|
except Exception as e:
|
|
logger.error(f"Failed to save instinct data: {e}")
|
|
|
|
def record_interaction(
|
|
self,
|
|
context: str,
|
|
tools: List[str],
|
|
skills: List[str],
|
|
success: bool = True,
|
|
feedback: Optional[str] = None,
|
|
) -> None:
|
|
"""
|
|
Record a successful interaction.
|
|
|
|
Args:
|
|
context: The conversation context
|
|
tools: Tools that were used
|
|
skills: Skills that were loaded
|
|
success: Whether the interaction was successful
|
|
feedback: Optional user feedback
|
|
"""
|
|
import re
|
|
|
|
# Create interaction record
|
|
record = InteractionRecord.create(context, tools, skills, feedback)
|
|
|
|
# Append to interactions log
|
|
with open(self.interactions_file, "a") as f:
|
|
f.write(json.dumps(asdict(record)) + "\n")
|
|
|
|
# Extract context hints (keywords)
|
|
hints = self._extract_hints(context)
|
|
|
|
# Update patterns for each hint
|
|
for hint in hints:
|
|
if hint not in self._patterns:
|
|
self._patterns[hint] = PatternStats()
|
|
|
|
stats = self._patterns[hint]
|
|
stats.count += 1
|
|
if success:
|
|
stats.total_success += 1
|
|
|
|
# Update running averages
|
|
stats.avg_tools = (stats.avg_tools * (stats.count - 1) + len(tools)) / stats.count
|
|
stats.avg_skills = (stats.avg_skills * (stats.count - 1) + len(skills)) / stats.count
|
|
stats.last_used = record.timestamp
|
|
|
|
# Update co-occurrence counts
|
|
for tool in tools:
|
|
stats.tool_counts[tool] = stats.tool_counts.get(tool, 0) + 1
|
|
for skill in skills:
|
|
stats.skill_counts[skill] = stats.skill_counts.get(skill, 0) + 1
|
|
|
|
# Update hint index
|
|
for hint in hints:
|
|
if hint not in self._context_hints:
|
|
self._context_hints[hint] = []
|
|
if record.context_hash not in self._context_hints[hint]:
|
|
self._context_hints[hint].append(record.context_hash)
|
|
|
|
# Rebuild instincts for affected patterns
|
|
self._rebuild_instincts(hints)
|
|
|
|
# Save periodically (every 10 interactions)
|
|
if sum(p.count for p in self._patterns.values()) % 10 == 0:
|
|
self._save()
|
|
|
|
def _extract_hints(self, context: str) -> List[str]:
|
|
"""Extract meaningful hints from context."""
|
|
import re
|
|
|
|
context_lower = context.lower()
|
|
|
|
# Extract words (3+ chars)
|
|
words = re.findall(r'\b\w{3,}\b', context_lower)
|
|
|
|
# Filter out common stop words
|
|
stop_words = {
|
|
'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can',
|
|
'her', 'was', 'one', 'our', 'out', 'day', 'get', 'has', 'him',
|
|
'his', 'how', 'its', 'may', 'new', 'now', 'old', 'see', 'two',
|
|
'who', 'boy', 'did', 'she', 'use', 'way', 'will', 'with', 'have',
|
|
'this', 'that', 'from', 'they', 'what', 'were', 'been', 'when',
|
|
'would', 'there', 'could', 'their', 'which', 'about', 'after',
|
|
'before', 'between', 'into', 'over', 'under', 'again', 'more',
|
|
'some', 'such', 'only', 'just', 'also', 'very', 'than', 'then',
|
|
'them', 'these', 'those', 'here', 'where', 'why', 'how', 'your',
|
|
}
|
|
|
|
hints = [w for w in words if w not in stop_words]
|
|
|
|
# Add bigrams for common patterns
|
|
bigrams = []
|
|
for i in range(len(words) - 1):
|
|
bigram = f"{words[i]}_{words[i+1]}"
|
|
bigrams.append(bigram)
|
|
|
|
hints.extend(bigrams)
|
|
|
|
return hints[:20] # Limit to top 20 hints
|
|
|
|
def _rebuild_instincts(self, hints: List[str]) -> None:
|
|
"""
|
|
Rebuild instincts for the given hints.
|
|
|
|
An instinct is a prediction for what tools/skills to load
|
|
based on learned patterns.
|
|
"""
|
|
for hint in hints:
|
|
stats = self._patterns.get(hint)
|
|
if not stats or stats.count < 2:
|
|
continue
|
|
|
|
# Calculate confidence based on sample size
|
|
confidence = min(1.0, stats.count / 10) # Max confidence at 10 samples
|
|
|
|
# Get top tools (appearing in >50% of interactions)
|
|
threshold = stats.count * 0.5
|
|
top_tools = [t for t, c in stats.tool_counts.items() if c >= threshold]
|
|
|
|
# Get top skills
|
|
top_skills = [s for s, c in stats.skill_counts.items() if c >= threshold]
|
|
|
|
if top_tools or top_skills:
|
|
self._instincts[hint] = InstinctProfile(
|
|
pattern=hint,
|
|
tools=top_tools[:5], # Max 5 tools
|
|
skills=top_skills[:5], # Max 5 skills
|
|
confidence=confidence,
|
|
sample_size=stats.count,
|
|
last_used=stats.last_used,
|
|
)
|
|
|
|
def predict_for_context(self, context: str, limit: int = 10) -> List[str]:
|
|
"""
|
|
Predict what to load for a context.
|
|
|
|
Args:
|
|
context: The conversation context
|
|
limit: Maximum number of predictions
|
|
|
|
Returns:
|
|
List of tool/skill paths to preload
|
|
"""
|
|
hints = self._extract_hints(context)
|
|
|
|
# Aggregate predictions from matching hints
|
|
tool_scores: Dict[str, float] = defaultdict(float)
|
|
skill_scores: Dict[str, float] = defaultdict(float)
|
|
|
|
for hint in hints:
|
|
instinct = self._instincts.get(hint)
|
|
if not instinct:
|
|
continue
|
|
|
|
# Weight by confidence
|
|
weight = instinct.confidence
|
|
|
|
for tool in instinct.tools:
|
|
tool_scores[tool] += weight
|
|
for skill in instinct.skills:
|
|
skill_scores[skill] += weight
|
|
|
|
# Combine and sort
|
|
predictions = []
|
|
for tool, score in sorted(tool_scores.items(), key=lambda x: -x[1])[:limit]:
|
|
predictions.append(tool)
|
|
for skill, score in sorted(skill_scores.items(), key=lambda x: -x[1])[:limit]:
|
|
if skill not in predictions:
|
|
predictions.append(skill)
|
|
|
|
return predictions[:limit]
|
|
|
|
def get_instinct_for_hint(self, hint: str) -> Optional[InstinctProfile]:
|
|
"""Get the instinct for a specific hint."""
|
|
return self._instincts.get(hint)
|
|
|
|
def get_stats(self) -> Dict[str, Any]:
|
|
"""Get learning statistics."""
|
|
total_interactions = sum(p.count for p in self._patterns.values())
|
|
return {
|
|
"total_patterns": len(self._patterns),
|
|
"total_instincts": len(self._instincts),
|
|
"total_interactions": total_interactions,
|
|
"avg_tools_per_interaction": sum(p.avg_tools for p in self._patterns.values()) / max(1, len(self._patterns)),
|
|
"avg_skills_per_interaction": sum(p.avg_skills for p in self._patterns.values()) / max(1, len(self._patterns)),
|
|
}
|
|
|
|
def clear_old_data(self, days: int = 30) -> int:
|
|
"""
|
|
Clear pattern data older than specified days.
|
|
|
|
Args:
|
|
days: Number of days to retain
|
|
|
|
Returns:
|
|
Number of patterns removed
|
|
"""
|
|
import time
|
|
cutoff = time.time() - (days * 86400)
|
|
|
|
removed = 0
|
|
for pattern in list(self._patterns.keys()):
|
|
if self._patterns[pattern].last_used < cutoff:
|
|
del self._patterns[pattern]
|
|
if pattern in self._instincts:
|
|
del self._instincts[pattern]
|
|
removed += 1
|
|
|
|
if removed > 0:
|
|
self._save()
|
|
|
|
return removed
|
|
|
|
def export_instincts(self) -> Dict[str, Any]:
|
|
"""Export all instincts for sharing/backup."""
|
|
return {
|
|
"version": "1.0.0",
|
|
"exported_at": time.time(),
|
|
"instincts": {k: v.to_dict() for k, v in self._instincts.items()},
|
|
"patterns": {k: v.to_dict() for k, v in self._patterns.items()},
|
|
}
|
|
|
|
def import_instincts(self, data: Dict) -> int:
|
|
"""
|
|
Import instincts from backup.
|
|
|
|
Args:
|
|
data: Exported instinct data
|
|
|
|
Returns:
|
|
Number of instincts imported
|
|
"""
|
|
count = 0
|
|
|
|
for k, v in data.get("instincts", {}).items():
|
|
try:
|
|
self._instincts[k] = InstinctProfile(**v)
|
|
count += 1
|
|
except Exception as e:
|
|
logger.warning(f"Failed to import instinct {k}: {e}")
|
|
|
|
for k, v in data.get("patterns", {}).items():
|
|
try:
|
|
self._patterns[k] = PatternStats.from_dict(v)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to import pattern {k}: {e}")
|
|
|
|
if count > 0:
|
|
self._save()
|
|
|
|
return count
|
|
|
|
|
|
# =============================================================================
|
|
# Singleton
|
|
# =============================================================================
|
|
|
|
_instinct_learner: Optional[InstinctLearner] = None
|
|
|
|
|
|
def get_instinct_learner() -> InstinctLearner:
|
|
"""Get the singleton InstinctLearner instance."""
|
|
global _instinct_learner
|
|
if _instinct_learner is None:
|
|
_instinct_learner = InstinctLearner()
|
|
return _instinct_learner
|
|
|
|
|
|
def record_success(
|
|
context: str,
|
|
tools: List[str],
|
|
skills: List[str],
|
|
feedback: Optional[str] = None,
|
|
) -> None:
|
|
"""
|
|
Convenience function to record a successful interaction.
|
|
|
|
Args:
|
|
context: The conversation context
|
|
tools: Tools that were used
|
|
skills: Skills that were loaded
|
|
feedback: Optional user feedback
|
|
"""
|
|
get_instinct_learner().record_interaction(context, tools, skills, success=True, feedback=feedback)
|
|
|
|
|
|
def predict_loading(context: str, limit: int = 10) -> List[str]:
|
|
"""
|
|
Predict what to load for a context.
|
|
|
|
Args:
|
|
context: The conversation context
|
|
limit: Maximum number of predictions
|
|
|
|
Returns:
|
|
List of tool/skill paths to preload
|
|
"""
|
|
return get_instinct_learner().predict_for_context(context, limit)
|