#!/usr/bin/env python3 """ PRISM Context Memory - Obsidian Storage Layer This module handles storage operations using Obsidian markdown notes. Analysis is done by the Claude Code agent, not here. Architecture: - Agent analyzes files/code (using native Read/Grep tools) - Agent calls these storage functions to persist results as markdown - No AI/LLM calls in this module - pure data storage - Uses YAML frontmatter for metadata - Stores in Obsidian vault as markdown files """ import os import json import re from datetime import datetime from typing import List, Dict, Optional from pathlib import Path import sys from urllib.parse import quote try: import frontmatter except ImportError: print("[ERROR] python-frontmatter not installed") print(" Run: pip install python-frontmatter") sys.exit(1) try: import yaml except ImportError: yaml = None # Import memory intelligence layer try: from memory_intelligence import ( calculate_decay, reinforce_confidence, calculate_relevance_score, extract_tags_from_content, find_similar_notes, should_update_existing, merge_note_content ) INTELLIGENCE_AVAILABLE = True except ImportError: INTELLIGENCE_AVAILABLE = False # Import REST API client try: from obsidian_rest_client import get_client as get_rest_client REST_API_AVAILABLE = True except ImportError: REST_API_AVAILABLE = False def get_rest_client(): return None # Cache for core-config.yaml _core_config_cache = None def load_core_config() -> Dict: """ Load core-config.yaml from PRISM root. Returns config dict with memory settings, or empty dict if not found. Uses caching to avoid repeated file reads. """ global _core_config_cache if _core_config_cache is not None: return _core_config_cache if yaml is None: _core_config_cache = {} return _core_config_cache try: prism_root = get_prism_root() config_path = prism_root / "core-config.yaml" if config_path.exists(): with open(config_path, 'r', encoding='utf-8') as f: _core_config_cache = yaml.safe_load(f) or {} else: _core_config_cache = {} except Exception: _core_config_cache = {} return _core_config_cache def get_config_value(key: str, default: str) -> str: """ Get configuration value from core-config.yaml or environment. Priority: 1. Environment variable 2. core-config.yaml 3. Default value Args: key: Config key (e.g., 'vault' for memory.vault) default: Default value if not found Returns: Configuration value """ # Check environment variable first (highest priority) env_key = f"PRISM_OBSIDIAN_{key.upper()}" if env_key in os.environ: return os.environ[env_key] # Check core-config.yaml config = load_core_config() memory_config = config.get('memory', {}) if key in memory_config: return str(memory_config[key]) # Return default return default def find_git_root() -> Optional[str]: """ Find git root directory (PRISM root). PRISM root is identified by the .git folder. This is the .prism directory where all PRISM skills live. Returns: Absolute path to .prism directory """ current = Path.cwd() while current != current.parent: if (current / ".git").exists(): return str(current) current = current.parent return None def get_prism_root() -> Path: """ Get PRISM root directory (same as git root). This is the .prism folder where all PRISM skills and config live. The vault will be created relative to this directory. Returns: Path object for PRISM root """ git_root = find_git_root() if git_root: return Path(git_root) # Fallback: assume we're running from within .prism return Path.cwd() def get_vault_path() -> Path: """ Get Obsidian vault path. Configuration Priority: 1. PRISM_OBSIDIAN_VAULT environment variable 2. memory.vault in core-config.yaml 3. Default: ../docs/memory Path Resolution: - If relative path: resolves relative to PRISM root (.prism folder) - If absolute path: uses as-is Examples: PRISM_OBSIDIAN_VAULT="../docs/memory" → {PROJECT_ROOT}/docs/memory (e.g., C:/Dev/MyProject/docs/memory) memory.vault in core-config.yaml: "../docs/memory" → {PROJECT_ROOT}/docs/memory PRISM_OBSIDIAN_VAULT="docs/knowledge" → {PRISM_ROOT}/docs/knowledge (inside .prism folder) PRISM_OBSIDIAN_VAULT="/absolute/path/to/vault" → /absolute/path/to/vault Returns: Absolute path to vault """ vault_path = get_config_value('vault', '../docs/memory') # Resolve relative paths from PRISM root (.prism folder) if not os.path.isabs(vault_path): prism_root = get_prism_root() vault_path = prism_root / vault_path return Path(vault_path).resolve() # Normalize path (resolve .. references) def get_folder_paths() -> Dict[str, Path]: """ Get folder paths for different note types. Reads from core-config.yaml memory.folders or environment variables. Falls back to default PRISM-Memory structure. """ vault = get_vault_path() # Get folders config from core-config or env vars config = load_core_config() memory_config = config.get('memory', {}) folders_config = memory_config.get('folders', {}) return { 'files': vault / ( os.environ.get("PRISM_MEMORY_FILES_FOLDER") or folders_config.get('files', 'PRISM-Memory/Files') ), 'patterns': vault / ( os.environ.get("PRISM_MEMORY_PATTERNS_FOLDER") or folders_config.get('patterns', 'PRISM-Memory/Patterns') ), 'decisions': vault / ( os.environ.get("PRISM_MEMORY_DECISIONS_FOLDER") or folders_config.get('decisions', 'PRISM-Memory/Decisions') ), 'commits': vault / ( os.environ.get("PRISM_MEMORY_COMMITS_FOLDER") or folders_config.get('commits', 'PRISM-Memory/Commits') ), 'interactions': vault / ( os.environ.get("PRISM_MEMORY_INTERACTIONS_FOLDER") or folders_config.get('interactions', 'PRISM-Memory/Interactions') ), 'preferences': vault / ( os.environ.get("PRISM_MEMORY_PREFERENCES_FOLDER") or folders_config.get('preferences', 'PRISM-Memory/Preferences') ), 'learnings': vault / ( os.environ.get("PRISM_MEMORY_LEARNINGS_FOLDER") or folders_config.get('learnings', 'PRISM-Memory/Learnings') ), 'index': vault / "PRISM-Memory/Index" } def ensure_folder(path: Path) -> None: """Ensure folder exists, create if needed.""" path.mkdir(parents=True, exist_ok=True) def sanitize_filename(name: str) -> str: """Convert name to safe filename.""" # Replace problematic characters name = name.replace('/', '-').replace('\\', '-') name = re.sub(r'[<>:"|?*]', '', name) return name def to_wikilink(path: str) -> str: """Convert file path to Obsidian wikilink.""" # Remove .md extension if present if path.endswith('.md'): path = path[:-3] return f"[[{path}]]" def format_date(dt: Optional[datetime] = None) -> str: """Format datetime for frontmatter.""" if dt is None: dt = datetime.now() return dt.strftime("%Y-%m-%dT%H:%M:%SZ") def write_note_hybrid(note_path: Path, post: frontmatter.Post, vault_path: Path) -> bool: """ Write note using REST API with filesystem fallback. Strategy: 1. Try REST API first (if configured and healthy) 2. Fall back to filesystem if API fails 3. Always ensure filesystem is in sync Args: note_path: Full path to note file post: Frontmatter post object vault_path: Vault root path Returns: True if written successfully, False otherwise """ # Get markdown content content = frontmatter.dumps(post) # Calculate relative path from vault root try: relative_path = note_path.relative_to(vault_path) vault_relative_str = str(relative_path).replace('\\', '/') except ValueError: # Path not relative to vault, use filesystem only vault_relative_str = None # Try REST API first api_success = False if REST_API_AVAILABLE and vault_relative_str: client = get_rest_client() if client and client.is_healthy: try: # Extract tags from frontmatter if present tags = post.metadata.get('tags', []) api_success = client.create_or_update_note( path=vault_relative_str, content=content, tags=tags ) if api_success: # REST API succeeded, still write to filesystem for backup pass except Exception as e: # API failed, will use filesystem fallback pass # Filesystem write (always as fallback or backup) try: ensure_folder(note_path.parent) with open(note_path, 'w', encoding='utf-8') as f: f.write(content) return True except Exception as e: # Both API and filesystem failed return False # ============================================================================ # STORE operations (Agent provides analyzed data, we just store it) # ============================================================================ def store_file_analysis( file_path: str, summary: str, purpose: str, complexity: str, language: str, lines_of_code: int, dependencies: List[str], functions: List[Dict[str, str]] = None ) -> bool: """ Store file analysis results as markdown note with intelligent upsert. NOTE: Agent does the analysis, this function just stores the results. Features: - Checks for existing note and updates if found - Adds intelligence metadata (confidence, access tracking) - Extracts semantic tags from content - Tracks memory health over time Args: file_path: Path to file summary: One-sentence summary (from agent analysis) purpose: Main purpose (from agent analysis) complexity: 'simple', 'moderate', or 'complex' language: Programming language lines_of_code: Number of lines dependencies: List of imported files/modules functions: List of function analyses [{name, signature, purpose, complexity}, ...] Returns: True if successful """ try: folders = get_folder_paths() vault_path = get_vault_path() # Create file structure matching original path # e.g., src/auth/jwt.ts -> Files/src/auth/jwt.ts.md relative_path = Path(file_path) note_path = folders['files'] / f"{file_path}.md" # Check if note already exists (upsert logic) is_update = note_path.exists() # Ensure parent directories exist ensure_folder(note_path.parent) # Extract intelligent tags from content content_for_tags = f"{summary} {purpose} {language} {' '.join(dependencies)}" base_tags = [language, complexity, 'code-analysis'] if INTELLIGENCE_AVAILABLE: intelligent_tags = extract_tags_from_content(content_for_tags, base_tags) else: intelligent_tags = base_tags # Prepare metadata with intelligence features now = format_date() metadata = { 'type': 'file-analysis', 'file_path': file_path, 'language': language, 'complexity': complexity, 'lines_of_code': lines_of_code, 'analyzed_at': now, 'last_modified': now, 'last_accessed': now, 'dependencies': dependencies, 'tags': intelligent_tags, # Intelligence metadata 'confidence_score': 0.5 if not is_update else None, # Will merge if update 'access_count': 0 if not is_update else None, 'relevance_score': 0.5 if not is_update else None } # Build content content_parts = [ f"# {file_path}\n", f"## Summary\n{summary}\n", f"## Purpose\n{purpose}\n" ] # Add functions if provided if functions: content_parts.append("\n## Key Functions\n") for func in functions: name = func.get('name', 'unnamed') signature = func.get('signature', '') func_purpose = func.get('purpose', '') func_complexity = func.get('complexity', 'moderate') content_parts.append(f"\n### `{name}`") content_parts.append(f"- **Purpose:** {func_purpose}") content_parts.append(f"- **Complexity:** {func_complexity}") if signature: content_parts.append(f"- **Signature:** `{signature}`") content_parts.append("") # Add related notes section content_parts.append("\n## Related Notes\n") content_parts.append("\n") # Add dependencies section if any if dependencies: content_parts.append("\n## Dependencies\n") content_parts.append("```") for dep in dependencies: content_parts.append(dep) content_parts.append("```") content = "\n".join(content_parts) # Handle updates intelligently if is_update: try: existing_post = frontmatter.load(note_path) # Preserve and update intelligence metadata old_confidence = existing_post.get('confidence_score', 0.5) old_access_count = existing_post.get('access_count', 0) # Update metadata (merge with existing) metadata['confidence_score'] = min(old_confidence + 0.1, 1.0) # Increase slightly on update metadata['access_count'] = old_access_count metadata['analyzed_at'] = existing_post.get('analyzed_at', now) # Keep original metadata['last_modified'] = now # Update to now # Merge tags (keep unique) old_tags = existing_post.get('tags', []) metadata['tags'] = sorted(list(set(old_tags + intelligent_tags))) # Merge content if significantly different if len(content) > len(existing_post.content) * 1.2: # New content is substantially longer - append if INTELLIGENCE_AVAILABLE: content = merge_note_content(existing_post.content, content, "sections") else: timestamp = datetime.now().strftime("%Y-%m-%d") content = f"{existing_post.content}\n\n## Update - {timestamp}\n\n{content}" print(f"[OK] Updated analysis for {file_path}") except Exception as e: print(f"[WARN] Could not merge existing note, will overwrite: {e}") # Create or update frontmatter post post = frontmatter.Post(content, **metadata) # Write to file using hybrid approach (REST API + filesystem) write_note_hybrid(note_path, post, vault_path) if not is_update: print(f"[OK] Stored analysis for {file_path}") return True except Exception as e: print(f"[ERROR] Failed to store file analysis: {e}") return False def store_pattern( name: str, description: str, example_path: Optional[str] = None, category: Optional[str] = None ) -> bool: """ Store a reusable code pattern as markdown note. Args: name: Pattern name description: What the pattern does (from agent analysis) example_path: Optional example file path category: Optional category (e.g., 'Architecture', 'Testing', 'Security') """ try: folders = get_folder_paths() vault_path = get_vault_path() # Organize by category if provided if category: pattern_folder = folders['patterns'] / category.capitalize() else: pattern_folder = folders['patterns'] ensure_folder(pattern_folder) # Create filename from pattern name filename = sanitize_filename(name) + ".md" note_path = pattern_folder / filename # Check if pattern already exists existing_usage_count = 1 if note_path.exists(): existing_post = frontmatter.load(note_path) existing_usage_count = existing_post.get('usage_count', 0) + 1 # Prepare metadata metadata = { 'type': 'pattern', 'category': category or 'general', 'created_at': format_date(), 'updated_at': format_date(), 'usage_count': existing_usage_count, 'tags': [category] if category else [] } # Build content content_parts = [ f"# {name}\n", f"## Description\n{description}\n" ] if example_path: content_parts.append(f"\n## Example Implementation") content_parts.append(to_wikilink(f"Files/{example_path}")) content_parts.extend([ "\n## When to Use\n", "\n", "\n## Benefits\n", "\n", "\n## Trade-offs\n", "\n", "\n## Related Patterns\n", "\n", "\n## Used In\n", "\n" ]) content = "\n".join(content_parts) # Create or update note post = frontmatter.Post(content, **metadata) write_note_hybrid(note_path, post, vault_path) print(f"[OK] Stored pattern: {name}") return True except Exception as e: print(f"[ERROR] Failed to store pattern: {e}") return False def store_decision( title: str, reasoning: str, context: Optional[str] = None, alternatives: Optional[str] = None ) -> bool: """ Store an architectural decision as markdown note. Args: title: Decision title reasoning: Why this decision was made (from agent analysis) context: Background context alternatives: Alternatives that were considered """ try: folders = get_folder_paths() vault_path = get_vault_path() ensure_folder(folders['decisions']) # Create filename with date prefix for chronological sorting date_str = datetime.now().strftime("%Y-%m-%d") filename = f"{date_str} {sanitize_filename(title)}.md" note_path = folders['decisions'] / filename # Prepare metadata metadata = { 'type': 'decision', 'decision_date': date_str, 'status': 'accepted', 'impact': 'medium', # Can be updated later 'tags': [] } # Build content content_parts = [ f"# {title}\n", f"## Decision\n{title}\n", f"## Context\n{context or 'N/A'}\n", f"## Reasoning\n{reasoning}\n" ] if alternatives: content_parts.extend([ f"\n## Alternatives Considered\n", f"{alternatives}\n" ]) content_parts.extend([ "\n## Implementation\n", "\n", "\n## Consequences\n", "### Positive\n", "\n", "\n### Negative\n", "\n", "\n## Related Notes\n", "\n" ]) content = "\n".join(content_parts) # Create note post = frontmatter.Post(content, **metadata) write_note_hybrid(note_path, post, vault_path) print(f"[OK] Stored decision: {title}") return True except Exception as e: print(f"[ERROR] Failed to store decision: {e}") return False def store_interaction( skill: str, context: str, action: str, outcome: str, learned: str, effectiveness: str = "successful" ) -> bool: """ Store agent interaction and learning as markdown note. Args: skill: Skill/command used context: What was the situation action: What action was taken outcome: What happened learned: What did we learn effectiveness: 'successful', 'partial', 'failed' """ try: folders = get_folder_paths() vault_path = get_vault_path() ensure_folder(folders['interactions']) # Create filename with timestamp and topic timestamp = datetime.now().strftime("%Y-%m-%d") topic = sanitize_filename(context[:50]) # First 50 chars of context filename = f"{timestamp}-{topic}.md" note_path = folders['interactions'] / filename # Prepare metadata metadata = { 'type': 'interaction', 'date': format_date(), 'outcome': effectiveness, 'tags': [skill, effectiveness] } # Build content content = f"""# {context} ## Request {context} ## Approach {action} ## Outcome {outcome} ## Key Learnings {learned} ## Related Notes """ # Create note post = frontmatter.Post(content, **metadata) write_note_hybrid(note_path, post, vault_path) return True except Exception as e: print(f"[ERROR] Failed to store interaction: {e}") return False def store_git_commit( commit_hash: str, author: str, date: str, message: str, files_changed: int, insertions: int, deletions: int ) -> bool: """ Store git commit context as markdown note. Args: commit_hash: Git commit hash author: Commit author date: Commit date message: Commit message files_changed: Number of files changed insertions: Number of insertions deletions: Number of deletions """ try: folders = get_folder_paths() vault_path = get_vault_path() # Organize by year-month commit_date = datetime.fromisoformat(date.replace('Z', '+00:00')) year_month = commit_date.strftime("%Y-%m") commit_folder = folders['commits'] / year_month ensure_folder(commit_folder) # Create filename from hash and message short_hash = commit_hash[:7] slug = sanitize_filename(message.split('\n')[0][:50].lower().replace(' ', '-')) filename = f"{short_hash}-{slug}.md" note_path = commit_folder / filename # Prepare metadata metadata = { 'type': 'git-commit', 'commit_hash': commit_hash, 'author': author, 'date': date, 'files_changed': files_changed, 'insertions': insertions, 'deletions': deletions, 'tags': ['git-commit'] } # Build content content = f"""# {message.split(chr(10))[0]} ## Commit `{commit_hash}` ## Message ``` {message} ``` ## Statistics - Files changed: {files_changed} - Insertions: {insertions} - Deletions: {deletions} ## Context ## Related Notes """ # Create note post = frontmatter.Post(content, **metadata) write_note_hybrid(note_path, post, vault_path) return True except Exception as e: print(f"[ERROR] Failed to store commit: {e}") return False # ============================================================================ # RECALL operations (retrieving stored context) # ============================================================================ def recall_query(query: str, limit: int = 10, update_access: bool = True) -> List[Dict]: """ Full-text search across all markdown notes with intelligent tracking. Features: - Updates access metadata on retrieval - Applies confidence decay - Calculates relevance scores - Tracks successful retrievals Args: query: Search query limit: Maximum results to return update_access: Whether to update access metadata (default True) Returns: List of results with type and relevance scores """ results = [] folders = get_folder_paths() vault = get_vault_path() # Sanitize query query_lower = query.lower() try: # Search all markdown files in vault for md_file in vault.rglob("*.md"): try: post = frontmatter.load(md_file) # Calculate relevance score relevance = 0 content_lower = post.content.lower() # Title match (highest weight) title = md_file.stem if query_lower in title.lower(): relevance += 10 # Frontmatter match for key, value in post.metadata.items(): if isinstance(value, str) and query_lower in value.lower(): relevance += 5 elif isinstance(value, list): for item in value: if isinstance(item, str) and query_lower in item.lower(): relevance += 3 # Content match if query_lower in content_lower: relevance += content_lower.count(query_lower) if relevance > 0: note_type = post.get('type', 'unknown') relative_path = md_file.relative_to(vault) # Apply memory decay if intelligence available confidence = post.get('confidence_score', 0.5) if INTELLIGENCE_AVAILABLE and 'last_accessed' in post.metadata: try: last_accessed = datetime.fromisoformat(post.get('last_accessed')) confidence = calculate_decay(confidence, last_accessed) except: pass result = { 'type': note_type, 'path': str(relative_path), 'title': title, 'relevance': relevance, 'confidence': confidence, 'metadata': post.metadata } # Add type-specific fields if note_type == 'file-analysis': result.update({ 'file_path': post.get('file_path', ''), 'summary': post.content.split('\n## Summary\n')[1].split('\n##')[0].strip() if '\n## Summary\n' in post.content else '', 'complexity': post.get('complexity', ''), 'language': post.get('language', '') }) elif note_type == 'pattern': result.update({ 'name': title, 'description': post.content.split('\n## Description\n')[1].split('\n##')[0].strip() if '\n## Description\n' in post.content else '', 'category': post.get('category', '') }) elif note_type == 'decision': result.update({ 'title': title, 'reasoning': post.content.split('\n## Reasoning\n')[1].split('\n##')[0].strip() if '\n## Reasoning\n' in post.content else '', 'decision_date': post.get('decision_date', '') }) results.append(result) except Exception as e: # Skip files that can't be parsed continue # Sort by relevance and limit results.sort(key=lambda x: x['relevance'], reverse=True) top_results = results[:limit] # Update access metadata for retrieved notes (if enabled) if update_access and INTELLIGENCE_AVAILABLE: for result in top_results: try: note_path = vault / result['path'] if not note_path.exists(): continue post = frontmatter.load(note_path) # Update access tracking old_confidence = post.get('confidence_score', 0.5) old_access_count = post.get('access_count', 0) # Reinforce confidence on successful retrieval new_confidence = reinforce_confidence(old_confidence, True) post.metadata['confidence_score'] = new_confidence post.metadata['access_count'] = old_access_count + 1 post.metadata['last_accessed'] = format_date() # Write updated metadata using hybrid approach write_note_hybrid(note_path, post, vault) except Exception: # Don't fail query if update fails continue return top_results except Exception as e: print(f"[ERROR] Search failed: {e}") return [] def recall_file(path: str) -> Optional[Dict]: """Get detailed information about a specific file analysis note.""" try: folders = get_folder_paths() note_path = folders['files'] / f"{path}.md" if not note_path.exists(): return None post = frontmatter.load(note_path) result = { 'path': path, 'metadata': post.metadata, 'content': post.content, 'summary': post.content.split('\n## Summary\n')[1].split('\n##')[0].strip() if '\n## Summary\n' in post.content else '', 'purpose': post.content.split('\n## Purpose\n')[1].split('\n##')[0].strip() if '\n## Purpose\n' in post.content else '' } return result except Exception as e: print(f"[ERROR] Failed to recall file: {e}") return None def recall_pattern(name: str) -> Optional[Dict]: """Get detailed information about a specific pattern note.""" try: folders = get_folder_paths() # Search in all category subfolders for pattern_file in folders['patterns'].rglob(f"{sanitize_filename(name)}.md"): post = frontmatter.load(pattern_file) return { 'name': name, 'metadata': post.metadata, 'content': post.content, 'description': post.content.split('\n## Description\n')[1].split('\n##')[0].strip() if '\n## Description\n' in post.content else '' } return None except Exception as e: print(f"[ERROR] Failed to recall pattern: {e}") return None def get_memory_stats() -> Dict: """Get statistics about stored memory.""" try: folders = get_folder_paths() vault = get_vault_path() stats = { 'files': 0, 'patterns': 0, 'decisions': 0, 'interactions': 0, 'commits': 0, 'total_notes': 0, 'vault_size_mb': 0 } # Count by type for md_file in vault.rglob("*.md"): try: post = frontmatter.load(md_file) note_type = post.get('type', 'unknown') if note_type == 'file-analysis': stats['files'] += 1 elif note_type == 'pattern': stats['patterns'] += 1 elif note_type == 'decision': stats['decisions'] += 1 elif note_type == 'interaction': stats['interactions'] += 1 elif note_type == 'git-commit': stats['commits'] += 1 stats['total_notes'] += 1 except: continue # Calculate vault size total_size = sum(f.stat().st_size for f in vault.rglob("*") if f.is_file()) stats['vault_size_mb'] = round(total_size / (1024 * 1024), 2) return stats except Exception as e: print(f"[ERROR] Failed to get stats: {e}") return {} # ============================================================================ # CONVENIENCE HELPERS (simplified APIs) # ============================================================================ def remember_file(file_path: str, summary: str = None, purpose: str = None, complexity: str = None, note: str = None) -> bool: """ Store file analysis with automatic language detection and defaults. Simplified API for hooks and quick captures. Args: file_path: Path to file summary: One-sentence summary (auto-generated if missing) purpose: Main purpose (defaults to "Pending analysis") complexity: 'simple', 'moderate', or 'complex' (defaults to 'moderate') note: Additional note (e.g., "Modified via Edit") Returns: True if successful """ import os if not os.path.exists(file_path): return False # Detect language from extension ext_map = { '.py': 'python', '.js': 'javascript', '.ts': 'typescript', '.jsx': 'javascript', '.tsx': 'typescript', '.rb': 'ruby', '.go': 'go', '.rs': 'rust', '.java': 'java', '.cs': 'csharp', '.cpp': 'cpp', '.c': 'c', '.h': 'c', '.hpp': 'cpp', '.php': 'php', '.swift': 'swift', '.kt': 'kotlin' } ext = os.path.splitext(file_path)[1].lower() language = ext_map.get(ext, 'unknown') # Count lines try: with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: lines = len(f.readlines()) except: lines = 0 # Use provided values or defaults summary = summary or f"File: {os.path.basename(file_path)}" purpose = purpose or "Pending analysis" complexity = complexity or "moderate" if note: summary = f"{summary} ({note})" return store_file_analysis( file_path=file_path, summary=summary, purpose=purpose, complexity=complexity, language=language, lines_of_code=lines, dependencies=[], functions=None ) def recall_decisions(days: Optional[int] = None) -> List[Dict]: """ Get architectural decisions, optionally filtered by timeframe. Args: days: Only return decisions from last N days (None = all) Returns: List of decision dictionaries """ from datetime import datetime, timedelta try: folders = get_folder_paths() decisions_folder = folders['decisions'] if not decisions_folder.exists(): return [] decisions = [] cutoff = None if days: cutoff = datetime.now() - timedelta(days=days) for md_file in decisions_folder.glob("*.md"): try: post = frontmatter.load(md_file) # Filter by date if specified if cutoff: decision_date = post.get('decision_date') if decision_date: file_date = datetime.fromisoformat(decision_date) if file_date < cutoff: continue # Only include accepted decisions if post.get('status') == 'accepted': decisions.append({ 'title': md_file.stem, 'decision_date': post.get('decision_date'), 'status': post.get('status'), 'impact': post.get('impact'), 'content': post.content, 'metadata': post.metadata }) except Exception: continue # Sort by date (newest first) decisions.sort(key=lambda x: x.get('decision_date', ''), reverse=True) return decisions except Exception as e: print(f"[ERROR] Failed to recall decisions: {e}") return [] def consolidate_story_learnings( story_id: str, story_title: str, files_changed: List[str] = None, patterns_used: List[str] = None, decisions_made: List[str] = None, key_learnings: List[str] = None ) -> Dict[str, int]: """ Consolidate learnings after story completion. This function: 1. Finds memories related to the story 2. Reviews confidence scores and identifies decayed memories 3. Refreshes low-confidence memories with updated context 4. Creates learning notes capturing what was learned 5. Reinforces patterns/decisions that were successfully used Args: story_id: Story identifier (e.g., "PLAT-123") story_title: Human-readable story title files_changed: List of file paths that were modified patterns_used: List of patterns that were applied decisions_made: List of decisions that were made key_learnings: List of key takeaways from the story Returns: Dict with counts: { 'memories_reviewed': int, 'memories_refreshed': int, 'patterns_reinforced': int, 'learnings_captured': int } """ if not INTELLIGENCE_AVAILABLE: print("[WARNING] Memory intelligence not available, skipping consolidation") return {} config = load_core_config() memory_config = config.get('memory', {}) # Check if consolidation is enabled if not memory_config.get('consolidate_on_story_complete', True): return {} review_threshold = memory_config.get('review_threshold', 0.3) stats = { 'memories_reviewed': 0, 'memories_refreshed': 0, 'patterns_reinforced': 0, 'learnings_captured': 0 } try: vault = get_vault_path() folders = get_folder_paths() # 1. Review and refresh file memories if files_changed and memory_config.get('refresh_related_memories', True): for file_path in files_changed: note_path = folders['files'] / f"{file_path}.md" if note_path.exists(): post = frontmatter.load(note_path) confidence = post.get('confidence_score', 0.5) stats['memories_reviewed'] += 1 # If confidence is low, it needs refresh if confidence < review_threshold: # Mark for review by adding tag tags = post.get('tags', []) if 'needs-review' not in tags: tags.append('needs-review') post.metadata['tags'] = tags post.metadata['review_reason'] = f'Low confidence ({confidence:.2f}) after story {story_id}' post.metadata['last_story'] = story_id write_note_hybrid(note_path, post, vault) stats['memories_refreshed'] += 1 else: # Reinforce successful memory new_confidence = reinforce_confidence(confidence, True) post.metadata['confidence_score'] = new_confidence post.metadata['last_story'] = story_id post.metadata['last_accessed'] = format_date() write_note_hybrid(note_path, post, vault) # 2. Reinforce patterns that were used if patterns_used: for pattern_name in patterns_used: # Find pattern note by searching pattern_files = list(folders['patterns'].rglob('*.md')) for pattern_file in pattern_files: post = frontmatter.load(pattern_file) if post.get('name') == pattern_name or post.get('pattern') == pattern_name: # Reinforce confidence old_confidence = post.get('confidence_score', 0.5) new_confidence = reinforce_confidence(old_confidence, True) post.metadata['confidence_score'] = new_confidence post.metadata['usage_count'] = post.get('usage_count', 0) + 1 post.metadata['last_used_story'] = story_id post.metadata['last_accessed'] = format_date() write_note_hybrid(pattern_file, post, vault) stats['patterns_reinforced'] += 1 break # 3. Create learning note for the story if key_learnings: learning_folder = folders.get('learnings') if learning_folder: ensure_folder(learning_folder) # Create dated learning note date_str = datetime.now().strftime('%Y-%m-%d') learning_note = learning_folder / f"{date_str}_{sanitize_filename(story_id)}.md" metadata = { 'type': 'story-learning', 'story_id': story_id, 'story_title': story_title, 'completed_at': format_date(), 'files_changed': files_changed or [], 'patterns_used': patterns_used or [], 'decisions_made': decisions_made or [], 'confidence_score': 0.8, # High confidence - fresh learning 'tags': ['learning', 'story-complete', story_id] } content = f"# Story Learning: {story_title}\n\n" content += f"**Story:** {story_id}\n" content += f"**Completed:** {format_date()}\n\n" content += "## Key Learnings\n\n" for learning in key_learnings: content += f"- {learning}\n" content += "\n## Context\n\n" if files_changed: content += f"**Files Changed:** {len(files_changed)}\n" if patterns_used: content += f"**Patterns Used:** {', '.join(patterns_used)}\n" if decisions_made: content += f"**Decisions Made:** {', '.join(decisions_made)}\n" content += "\n## Related Notes\n\n" content += "\n" post = frontmatter.Post(content, **metadata) write_note_hybrid(learning_note, post, vault) stats['learnings_captured'] = 1 return stats except Exception as e: print(f"[ERROR] Story consolidation failed: {e}") return stats def get_memories_needing_review() -> List[Dict]: """ Get list of memories that need review due to low confidence. Returns list of dicts with: - path: Path to note file - title: Note title - confidence: Current confidence score - last_accessed: Last access date - type: Note type (file, pattern, decision, etc.) """ if not INTELLIGENCE_AVAILABLE: return [] config = load_core_config() memory_config = config.get('memory', {}) review_threshold = memory_config.get('review_threshold', 0.3) needs_review = [] try: vault = get_vault_path() # Search all markdown files for note_path in vault.rglob('*.md'): if note_path.name in ['README.md', 'File Index.md', 'Pattern Index.md', 'Decision Log.md']: continue try: post = frontmatter.load(note_path) confidence = post.get('confidence_score', 0.5) if confidence < review_threshold: needs_review.append({ 'path': str(note_path.relative_to(vault)), 'title': post.get('file_path') or post.get('name') or post.get('decision') or note_path.stem, 'confidence': confidence, 'last_accessed': post.get('last_accessed', 'never'), 'type': post.get('type', 'unknown') }) except Exception: continue # Sort by confidence (lowest first) needs_review.sort(key=lambda x: x['confidence']) return needs_review except Exception as e: print(f"[ERROR] Failed to get review list: {e}") return [] if __name__ == "__main__": # Test connection and show stats print("Context Memory Storage (Obsidian)") print("=" * 50) try: vault = get_vault_path() print(f"\nVault path: {vault}") if not vault.exists(): print(f"\n[WARNING] Vault does not exist: {vault}") print(" Run: python skills/context-memory/utils/init_vault.py") else: stats = get_memory_stats() print("\nMemory Stats:") for key, value in stats.items(): print(f" {key}: {value}") print("\n[OK] Storage layer is operational") except Exception as e: print(f"\n[ERROR] {e}") sys.exit(1)