Files
Aperant/auto-claude/memory.py
T
2025-12-15 21:10:27 +01:00

701 lines
22 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Session Memory System
=====================
Persists learnings between autonomous coding sessions to avoid rediscovering
codebase patterns, gotchas, and insights.
Architecture Decision:
Memory System Hierarchy:
PRIMARY: Graphiti (when GRAPHITI_ENABLED=true)
- Graph-based knowledge storage with FalkorDB
- Semantic search across sessions
- Cross-project context retrieval
- Rich relationship modeling
FALLBACK: File-based (when Graphiti is disabled)
- Zero external dependencies (no database required)
- Human-readable files for debugging and inspection
- Guaranteed availability (no network/service failures)
- Simple backup and version control integration
The agent.py orchestrator uses save_session_memory() which:
1. Tries Graphiti first if enabled
2. Falls back to file-based if Graphiti is disabled or fails
This ensures memory is ALWAYS saved, regardless of configuration.
Each spec has its own memory directory:
auto-claude/specs/001-feature/memory/
├── codebase_map.json # Key files and their purposes
├── patterns.md # Code patterns to follow
├── gotchas.md # Pitfalls to avoid
└── session_insights/
├── session_001.json # What session 1 learned
└── session_002.json # What session 2 learned
Usage:
# Save session insights
from memory import save_session_insights
insights = {
"subtasks_completed": ["subtask-1"],
"discoveries": {...},
"what_worked": ["approach"],
"what_failed": ["mistake"],
"recommendations_for_next_session": ["tip"]
}
save_session_insights(spec_dir, session_num=1, insights=insights)
# Load all past insights
from memory import load_all_insights
all_insights = load_all_insights(spec_dir)
# Update codebase map
from memory import update_codebase_map
discoveries = {
"src/api/auth.py": "Handles JWT authentication and token validation",
"src/models/user.py": "User database model with password hashing"
}
update_codebase_map(spec_dir, discoveries)
# Append gotcha
from memory import append_gotcha
append_gotcha(spec_dir, "Database connections must be explicitly closed in workers")
# Append pattern
from memory import append_pattern
append_pattern(spec_dir, "Use try/except with specific exceptions, log errors with context")
Graphiti Integration:
When GRAPHITI_ENABLED=true and a valid provider is configured, session insights
and discoveries are also saved to the Graphiti knowledge graph.
This enables semantic search and cross-session context retrieval.
Supported providers:
- LLM: OpenAI, Anthropic, Azure OpenAI, Ollama
- Embedder: OpenAI, Voyage AI, Azure OpenAI, Ollama
See graphiti_config.py for provider configuration details.
# Check if Graphiti is enabled
from memory import is_graphiti_memory_enabled
if is_graphiti_memory_enabled():
# Graphiti will automatically store data alongside file-based memory
pass
"""
import asyncio
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
# Configure logging
logger = logging.getLogger(__name__)
# =============================================================================
# Graphiti Integration Helpers
# =============================================================================
def is_graphiti_memory_enabled() -> bool:
"""
Check if Graphiti memory integration is available.
Returns True if:
- GRAPHITI_ENABLED is set to true/1/yes
- A valid LLM provider is configured (OpenAI, Anthropic, Azure, or Ollama)
- A valid embedder provider is configured (OpenAI, Voyage, Azure, or Ollama)
See graphiti_config.py for detailed provider requirements.
"""
try:
from graphiti_config import is_graphiti_enabled
return is_graphiti_enabled()
except ImportError:
return False
def _get_graphiti_memory(spec_dir: Path, project_dir: Path | None = None):
"""
Get a GraphitiMemory instance if available.
Args:
spec_dir: Spec directory
project_dir: Project root directory (defaults to spec_dir.parent.parent)
Returns:
GraphitiMemory instance or None if not available
"""
if not is_graphiti_memory_enabled():
return None
try:
from graphiti_memory import GraphitiMemory
if project_dir is None:
project_dir = spec_dir.parent.parent
return GraphitiMemory(spec_dir, project_dir)
except ImportError:
return None
def _run_async(coro):
"""
Run an async coroutine synchronously.
Handles the case where we're already in an event loop.
"""
try:
loop = asyncio.get_running_loop()
# Already in an event loop - create a task
return asyncio.ensure_future(coro)
except RuntimeError:
# No event loop running - create one
return asyncio.run(coro)
async def _save_to_graphiti_async(
spec_dir: Path,
session_num: int,
insights: dict,
project_dir: Path | None = None,
) -> bool:
"""
Save session insights to Graphiti (async helper).
This is called in addition to file-based storage when Graphiti is enabled.
"""
graphiti = _get_graphiti_memory(spec_dir, project_dir)
if not graphiti:
return False
try:
result = await graphiti.save_session_insights(session_num, insights)
# Also save codebase discoveries if present
discoveries = insights.get("discoveries", {})
files_understood = discoveries.get("files_understood", {})
if files_understood:
await graphiti.save_codebase_discoveries(files_understood)
# Save patterns
for pattern in discoveries.get("patterns_found", []):
await graphiti.save_pattern(pattern)
# Save gotchas
for gotcha in discoveries.get("gotchas_encountered", []):
await graphiti.save_gotcha(gotcha)
await graphiti.close()
return result
except Exception as e:
logger.warning(f"Failed to save to Graphiti: {e}")
try:
await graphiti.close()
except Exception:
pass
return False
# =============================================================================
# File-Based Memory Functions
# =============================================================================
def get_memory_dir(spec_dir: Path) -> Path:
"""
Get the memory directory for a spec, creating it if needed.
Args:
spec_dir: Path to spec directory (e.g., auto-claude/specs/001-feature/)
Returns:
Path to memory directory
"""
memory_dir = spec_dir / "memory"
memory_dir.mkdir(exist_ok=True)
return memory_dir
def get_session_insights_dir(spec_dir: Path) -> Path:
"""
Get the session insights directory, creating it if needed.
Args:
spec_dir: Path to spec directory
Returns:
Path to session_insights directory
"""
insights_dir = get_memory_dir(spec_dir) / "session_insights"
insights_dir.mkdir(parents=True, exist_ok=True)
return insights_dir
def save_session_insights(spec_dir: Path, session_num: int, insights: dict) -> None:
"""
Save insights from a completed session.
Args:
spec_dir: Path to spec directory
session_num: Session number (1-indexed)
insights: Dictionary containing session learnings with keys:
- subtasks_completed: list[str] - Subtask IDs completed
- discoveries: dict - New file purposes, patterns, gotchas found
- files_understood: dict[str, str] - {path: purpose}
- patterns_found: list[str] - Pattern descriptions
- gotchas_encountered: list[str] - Gotcha descriptions
- what_worked: list[str] - Successful approaches
- what_failed: list[str] - Unsuccessful approaches
- recommendations_for_next_session: list[str] - Suggestions
Example:
insights = {
"subtasks_completed": ["subtask-1", "subtask-2"],
"discoveries": {
"files_understood": {
"src/api/auth.py": "JWT authentication handler"
},
"patterns_found": ["Use async/await for all DB calls"],
"gotchas_encountered": ["Must close DB connections in workers"]
},
"what_worked": ["Added comprehensive error handling first"],
"what_failed": ["Tried inline validation - should use middleware"],
"recommendations_for_next_session": ["Focus on integration tests next"]
}
"""
insights_dir = get_session_insights_dir(spec_dir)
session_file = insights_dir / f"session_{session_num:03d}.json"
# Build complete insight structure
session_data = {
"session_number": session_num,
"timestamp": datetime.now(timezone.utc).isoformat(),
"subtasks_completed": insights.get("subtasks_completed", []),
"discoveries": insights.get(
"discoveries",
{"files_understood": {}, "patterns_found": [], "gotchas_encountered": []},
),
"what_worked": insights.get("what_worked", []),
"what_failed": insights.get("what_failed", []),
"recommendations_for_next_session": insights.get(
"recommendations_for_next_session", []
),
}
# Write to file (always use file-based storage)
with open(session_file, "w") as f:
json.dump(session_data, f, indent=2)
# Also save to Graphiti if enabled (non-blocking, errors logged but not raised)
if is_graphiti_memory_enabled():
try:
_run_async(_save_to_graphiti_async(spec_dir, session_num, session_data))
logger.info(f"Session {session_num} insights also saved to Graphiti")
except Exception as e:
# Don't fail the save if Graphiti fails - file-based is the primary storage
logger.warning(f"Graphiti save failed (file-based save succeeded): {e}")
def load_all_insights(spec_dir: Path) -> list[dict]:
"""
Load all session insights, ordered by session number.
Args:
spec_dir: Path to spec directory
Returns:
List of insight dictionaries, oldest to newest
"""
insights_dir = get_session_insights_dir(spec_dir)
if not insights_dir.exists():
return []
# Find all session JSON files
session_files = sorted(insights_dir.glob("session_*.json"))
insights = []
for session_file in session_files:
try:
with open(session_file) as f:
insights.append(json.load(f))
except (OSError, json.JSONDecodeError):
# Skip corrupted files
continue
return insights
def update_codebase_map(spec_dir: Path, discoveries: dict[str, str]) -> None:
"""
Update the codebase map with newly discovered file purposes.
This function merges new discoveries with existing ones. If a file path
already exists, its purpose will be updated.
Args:
spec_dir: Path to spec directory
discoveries: Dictionary mapping file paths to their purposes
Example: {
"src/api/auth.py": "Handles JWT authentication",
"src/models/user.py": "User database model"
}
"""
memory_dir = get_memory_dir(spec_dir)
map_file = memory_dir / "codebase_map.json"
# Load existing map or create new
if map_file.exists():
try:
with open(map_file) as f:
codebase_map = json.load(f)
except (OSError, json.JSONDecodeError):
codebase_map = {}
else:
codebase_map = {}
# Update with new discoveries
codebase_map.update(discoveries)
# Add metadata
if "_metadata" not in codebase_map:
codebase_map["_metadata"] = {}
codebase_map["_metadata"]["last_updated"] = datetime.now(timezone.utc).isoformat()
codebase_map["_metadata"]["total_files"] = len(
[k for k in codebase_map.keys() if k != "_metadata"]
)
# Write back
with open(map_file, "w") as f:
json.dump(codebase_map, f, indent=2, sort_keys=True)
# Also save to Graphiti if enabled
if is_graphiti_memory_enabled() and discoveries:
try:
graphiti = _get_graphiti_memory(spec_dir)
if graphiti:
_run_async(graphiti.save_codebase_discoveries(discoveries))
logger.info("Codebase discoveries also saved to Graphiti")
except Exception as e:
logger.warning(f"Graphiti codebase save failed: {e}")
def load_codebase_map(spec_dir: Path) -> dict[str, str]:
"""
Load the codebase map.
Args:
spec_dir: Path to spec directory
Returns:
Dictionary mapping file paths to their purposes.
Returns empty dict if no map exists.
"""
memory_dir = get_memory_dir(spec_dir)
map_file = memory_dir / "codebase_map.json"
if not map_file.exists():
return {}
try:
with open(map_file) as f:
codebase_map = json.load(f)
# Remove metadata before returning
codebase_map.pop("_metadata", None)
return codebase_map
except (OSError, json.JSONDecodeError):
return {}
def append_gotcha(spec_dir: Path, gotcha: str) -> None:
"""
Append a gotcha (pitfall to avoid) to the gotchas list.
Gotchas are deduplicated - if the same gotcha already exists,
it won't be added again.
Args:
spec_dir: Path to spec directory
gotcha: Description of the pitfall to avoid
Example:
append_gotcha(spec_dir, "Database connections must be closed in workers")
append_gotcha(spec_dir, "API rate limits: 100 req/min per IP")
"""
memory_dir = get_memory_dir(spec_dir)
gotchas_file = memory_dir / "gotchas.md"
# Load existing gotchas
existing_gotchas = set()
if gotchas_file.exists():
content = gotchas_file.read_text()
# Extract bullet points
for line in content.split("\n"):
line = line.strip()
if line.startswith("- "):
existing_gotchas.add(line[2:].strip())
# Add new gotcha if not duplicate
gotcha_stripped = gotcha.strip()
if gotcha_stripped and gotcha_stripped not in existing_gotchas:
# Append to file
with open(gotchas_file, "a") as f:
if gotchas_file.stat().st_size == 0:
# First entry - add header
f.write("# Gotchas and Pitfalls\n\n")
f.write("Things to watch out for in this codebase:\n\n")
f.write(f"- {gotcha_stripped}\n")
# Also save to Graphiti if enabled
if is_graphiti_memory_enabled():
try:
graphiti = _get_graphiti_memory(spec_dir)
if graphiti:
_run_async(graphiti.save_gotcha(gotcha_stripped))
except Exception as e:
logger.warning(f"Graphiti gotcha save failed: {e}")
def load_gotchas(spec_dir: Path) -> list[str]:
"""
Load all gotchas.
Args:
spec_dir: Path to spec directory
Returns:
List of gotcha strings
"""
memory_dir = get_memory_dir(spec_dir)
gotchas_file = memory_dir / "gotchas.md"
if not gotchas_file.exists():
return []
content = gotchas_file.read_text()
gotchas = []
for line in content.split("\n"):
line = line.strip()
if line.startswith("- "):
gotchas.append(line[2:].strip())
return gotchas
def append_pattern(spec_dir: Path, pattern: str) -> None:
"""
Append a code pattern to follow.
Patterns are deduplicated - if the same pattern already exists,
it won't be added again.
Args:
spec_dir: Path to spec directory
pattern: Description of the code pattern
Example:
append_pattern(spec_dir, "Use try/except with specific exceptions")
append_pattern(spec_dir, "All API responses use {success: bool, data: any, error: string}")
"""
memory_dir = get_memory_dir(spec_dir)
patterns_file = memory_dir / "patterns.md"
# Load existing patterns
existing_patterns = set()
if patterns_file.exists():
content = patterns_file.read_text()
# Extract bullet points
for line in content.split("\n"):
line = line.strip()
if line.startswith("- "):
existing_patterns.add(line[2:].strip())
# Add new pattern if not duplicate
pattern_stripped = pattern.strip()
if pattern_stripped and pattern_stripped not in existing_patterns:
# Append to file
with open(patterns_file, "a") as f:
if patterns_file.stat().st_size == 0:
# First entry - add header
f.write("# Code Patterns\n\n")
f.write("Established patterns to follow in this codebase:\n\n")
f.write(f"- {pattern_stripped}\n")
# Also save to Graphiti if enabled
if is_graphiti_memory_enabled():
try:
graphiti = _get_graphiti_memory(spec_dir)
if graphiti:
_run_async(graphiti.save_pattern(pattern_stripped))
except Exception as e:
logger.warning(f"Graphiti pattern save failed: {e}")
def load_patterns(spec_dir: Path) -> list[str]:
"""
Load all code patterns.
Args:
spec_dir: Path to spec directory
Returns:
List of pattern strings
"""
memory_dir = get_memory_dir(spec_dir)
patterns_file = memory_dir / "patterns.md"
if not patterns_file.exists():
return []
content = patterns_file.read_text()
patterns = []
for line in content.split("\n"):
line = line.strip()
if line.startswith("- "):
patterns.append(line[2:].strip())
return patterns
def get_memory_summary(spec_dir: Path) -> dict[str, Any]:
"""
Get a summary of all memory data for a spec.
Useful for understanding what the system has learned so far.
Args:
spec_dir: Path to spec directory
Returns:
Dictionary with memory summary:
- total_sessions: int
- total_files_mapped: int
- total_patterns: int
- total_gotchas: int
- recent_insights: list[dict] (last 3 sessions)
"""
insights = load_all_insights(spec_dir)
codebase_map = load_codebase_map(spec_dir)
patterns = load_patterns(spec_dir)
gotchas = load_gotchas(spec_dir)
return {
"total_sessions": len(insights),
"total_files_mapped": len(codebase_map),
"total_patterns": len(patterns),
"total_gotchas": len(gotchas),
"recent_insights": insights[-3:] if len(insights) > 3 else insights,
}
def clear_memory(spec_dir: Path) -> None:
"""
Clear all memory for a spec.
WARNING: This deletes all session insights, codebase map, patterns, and gotchas.
Use with caution - typically only needed when starting completely fresh.
Args:
spec_dir: Path to spec directory
"""
memory_dir = get_memory_dir(spec_dir)
if memory_dir.exists():
import shutil
shutil.rmtree(memory_dir)
# CLI interface for testing and manual management
if __name__ == "__main__":
import argparse
import sys
parser = argparse.ArgumentParser(
description="Session Memory System - Manage memory for auto-claude specs"
)
parser.add_argument(
"--spec-dir",
type=Path,
required=True,
help="Path to spec directory (e.g., auto-claude/specs/001-feature)",
)
parser.add_argument(
"--action",
choices=[
"summary",
"list-insights",
"list-map",
"list-patterns",
"list-gotchas",
"clear",
],
default="summary",
help="Action to perform",
)
args = parser.parse_args()
if not args.spec_dir.exists():
print(f"Error: Spec directory not found: {args.spec_dir}")
sys.exit(1)
if args.action == "summary":
summary = get_memory_summary(args.spec_dir)
print("\n" + "=" * 70)
print(" MEMORY SUMMARY")
print("=" * 70)
print(f"\nSpec: {args.spec_dir.name}")
print(f"Total sessions: {summary['total_sessions']}")
print(f"Files mapped: {summary['total_files_mapped']}")
print(f"Patterns: {summary['total_patterns']}")
print(f"Gotchas: {summary['total_gotchas']}")
if summary["recent_insights"]:
print("\nRecent sessions:")
for insight in summary["recent_insights"]:
session_num = insight.get("session_number")
subtasks = len(insight.get("subtasks_completed", []))
print(f" Session {session_num}: {subtasks} subtasks completed")
elif args.action == "list-insights":
insights = load_all_insights(args.spec_dir)
print(json.dumps(insights, indent=2))
elif args.action == "list-map":
codebase_map = load_codebase_map(args.spec_dir)
print(json.dumps(codebase_map, indent=2, sort_keys=True))
elif args.action == "list-patterns":
patterns = load_patterns(args.spec_dir)
print("\nCode Patterns:")
for pattern in patterns:
print(f" - {pattern}")
elif args.action == "list-gotchas":
gotchas = load_gotchas(args.spec_dir)
print("\nGotchas:")
for gotcha in gotchas:
print(f" - {gotcha}")
elif args.action == "clear":
confirm = input(f"Clear all memory for {args.spec_dir.name}? (yes/no): ")
if confirm.lower() == "yes":
clear_memory(args.spec_dir)
print("Memory cleared.")
else:
print("Cancelled.")