#!/usr/bin/env python3
"""
Agent Memory System for OpenCode using LanceDB - Full Pro Features
Based on memory-lancedb-pro but adapted for OpenCode + Ollama.

Features:
- Hybrid Retrieval (Vector + BM25)
- Cross-Encoder Reranking (Jina API)
- Multi-Stage Scoring (recency, importance, length norm, time decay, MMR)
- Multi-Scope Isolation
- Adaptive Retrieval & Noise Filtering

Configuration:
- Config file: .opencode/memory-config.json (auto-loaded from default location)
- Override: python bin/agent-memory.py --config /path/to/config.json <command>
- Falls back to hardcoded defaults if config not found

Usage:
  python bin/agent-memory.py [--config PATH] <command> [options]
"""

import lancedb
import json
import os
import sys
import uuid
import math
import re
import time
from pathlib import Path
from datetime import datetime
from typing import List, Optional, Dict, Any
from urllib import request
from urllib.error import URLError

# ============================================================================
# Configuration
# ============================================================================

VAULT_ROOT = Path(__file__).parent.parent

# Default config values (used if config file not found)
_DEFAULT_CONFIG = {
    "retrieval": {
        "mode": "hybrid",
        "vector_weight": 0.7,
        "bm25_weight": 0.3,
        "min_score": 0.3,
        "rerank": "none",
        "candidate_pool_size": 20,
        "recency_half_life_days": 14,
        "recency_weight": 0.10,
        "filter_noise": True,
        "length_norm_anchor": 500,
        "hard_min_score": 0.35,
        "time_decay_half_life_days": 60,
        "bm25_boost": 0.30
    },
    "embedding": {
        "model": "bge-m3:latest",
        "dimension": 1024,
        "ollama_url": "http://localhost:11434"
    },
    "database": {
        "memory_dir": ".opencode/memory",
        "table_name": "memories",
        "backup_dir": ".opencode/memory/backups"
    },
    "cli": {
        "timeout_seconds": 30,
        "cache_max_size": 100,
        "cache_ttl_seconds": 86400
    }
}

def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
    """Load config from file or use defaults.
    
    Args:
        config_path: Optional path to config file. If not provided, tries default location.
    
    Returns:
        Config dict (validated).
    """
    if config_path is None:
        config_file = VAULT_ROOT / ".opencode" / "memory-config.json"
    else:
        config_file = Path(config_path)
    
    # Try to load from file
    if config_file.exists():
        try:
            with open(config_file, 'r') as f:
                config = json.load(f)
                # Validate required top-level keys
                required_keys = {"retrieval", "embedding", "database", "cli"}
                if not required_keys.issubset(config.keys()):
                    print(f"Warning: Config missing keys, using defaults for missing sections", file=sys.stderr)
                    # Merge with defaults
                    for key in required_keys:
                        if key not in config:
                            config[key] = _DEFAULT_CONFIG[key]
                return config
        except Exception as e:
            print(f"Error loading config from {config_file}: {e}. Using defaults.", file=sys.stderr)
            return _DEFAULT_CONFIG.copy()
    else:
        # If explicit path provided but not found, warn; otherwise silent
        if config_path is not None:
            print(f"Config file not found at {config_file}. Using defaults.", file=sys.stderr)
        return _DEFAULT_CONFIG.copy()

# Load config (can be overridden by --config CLI arg, see main())
_CONFIG = load_config()

MEMORY_DIR = VAULT_ROOT / _CONFIG["database"]["memory_dir"]
DB_PATH = MEMORY_DIR / "agent-memory-lance"
OLLAMA_URL = _CONFIG["embedding"]["ollama_url"]
EMBEDDING_MODEL = _CONFIG["embedding"]["model"]
EMBEDDING_DIM = _CONFIG["embedding"]["dimension"]
TABLE_NAME = _CONFIG["database"]["table_name"]

# ============================================================================
# Query Embedding Cache (Performance Optimization)
# ============================================================================

_query_cache = {}       # {cache_key: (embedding, timestamp)}

# Cache key prefix includes model version for cache invalidation on model change
_EMBEDDING_MODEL = EMBEDDING_MODEL
_CACHE_PREFIX = f"emb_{_EMBEDDING_MODEL.replace(':', '_')}"

# Cache size and TTL from config (loaded via load_config())
def get_cache_max_size() -> int:
    return _CONFIG.get("cli", {}).get("cache_max_size", 100)

def get_cache_ttl() -> int:
    return _CONFIG.get("cli", {}).get("cache_ttl_seconds", 86400)


def normalize_query(query: str) -> str:
    """Normalize query for consistent caching."""
    return query.lower().strip()


def get_cache_key(query: str) -> str:
    """Build cache key with model version and normalized query."""
    return f"{_CACHE_PREFIX}_{normalize_query(query)}"


def get_embedding_with_cache(query: str) -> tuple[list[float], bool]:
    """Get embedding from cache or compute fresh.
    
    Returns: (embedding, was_cached)
    """
    key = get_cache_key(query)  # Use shared cache key with model prefix
    
    # Check cache
    if key in _query_cache:
        emb, timestamp = _query_cache[key]
        if time.time() - timestamp < get_cache_ttl():
            return emb, True
    
    # Compute fresh
    emb = get_embedding(query)
    if emb:
        # Evict oldest if cache full (O(n) - OK for small cache)
        if len(_query_cache) >= get_cache_max_size():
            oldest_key = min(_query_cache, key=lambda k: _query_cache[k][1])
            del _query_cache[oldest_key]
        
        _query_cache[key] = (emb, time.time())
    
    return emb, False


def get_cache_stats() -> dict:
    """Get cache statistics for observability."""
    return {
        "size": len(_query_cache),
        "max_size": get_cache_max_size(),
        "ttl_seconds": get_cache_ttl(),
    }


# ============================================================================
# Query Classifier (BM25 vs Vector)
# ============================================================================

def should_use_bm25_only(query: str) -> bool:
    """Determine if BM25-only search is sufficient.
    
    Returns True for:
    - Very short queries (< 10 chars) - need exact match
    - Short keyword queries (<= 3 words) - likely search-like
    - But NOT questions (what/who/where/how) - need semantic
    
    Returns False for:
    - Questions (what, who, where, how, tell me) - need semantic
    - Longer queries (> 3 words) - likely natural language
    """
    query = query.strip()
    
    # Very short = likely need exact keyword match
    if len(query) < 10:
        return True
    
    # Check if it's a question - need semantic for questions
    question_patterns = ['what', 'who', 'where', 'how', 'tell me', 'show me', 'find']
    query_lower = query.lower()
    is_question = any(query_lower.startswith(p) or f' {p}' in query_lower for p in question_patterns)
    
    # Count words
    word_count = len(query.split())
    
    # If it's a question, use semantic (hybrid)
    if is_question:
        return False
    
    # If short keyword (no question words), use BM25
    if word_count <= 3:
        return True
    
    # More than 3 words but not a question - still likely keyword search
    if word_count <= 5:
        return True
    
    # Longer queries - use semantic
    return False

# ============================================================================
# Ollama Auto-Start
# ============================================================================

def check_ollama_running() -> bool:
    """Check if Ollama is running."""
    try:
        req = request.Request(f"{OLLAMA_URL}/api/tags", method="GET")
        with request.urlopen(req, timeout=3) as resp:
            return resp.status == 200
    except Exception:
        return False

def start_ollama() -> bool:
    """Try to start Ollama."""
    import subprocess
    import platform
    
    system = platform.system()
    try:
        if system == "Windows":
            # Use shell=True for Windows to use cmd built-in commands
            subprocess.Popen("ollama serve", shell=True, 
                           creationflags=0x08000000 if hasattr(subprocess, 'CREATE_NO_WINDOW') else 0)
        elif system == "Darwin":  # macOS
            subprocess.Popen(["open", "-a", "Ollama"])
        else:  # Linux
            subprocess.Popen(["ollama", "serve"], start_new_session=True)
        
        # Wait and check
        for _ in range(10):  # 10 attempts, 1 second each
            time.sleep(1)
            if check_ollama_running():
                return True
    except Exception as e:
        print(f"Failed to start Ollama: {e}", file=sys.stderr)
    return False

def ensure_ollama():
    """Ensure Ollama is running, start if needed."""
    if check_ollama_running():
        return True
    
    print("Ollama not running, attempting to start...", file=sys.stderr)
    if start_ollama():
        print("Ollama started successfully", file=sys.stderr)
        return True
    
    print("Could not start Ollama. Please run 'ollama serve' manually.", file=sys.stderr)
    return False

# Retrieval Configuration
# Using single embedding model (bge-m3) for simplicity and sustainability
# - bge-m3 is already excellent for semantic search
# - Hybrid retrieval (vector + BM25) with RRF fusion provides score blending
# - Cross-encoder reranking removed: qllama/bge-reranker-v2-m3 has only 'completion'
#   capability in Ollama (not 'embedding'), so embed API calls on it produced garbage
#   scores (all 1.0), adding ~4.5s latency with zero accuracy benefit.

# _CONFIG["retrieval"] is loaded from _CONFIG (set by load_config() above)
# Access via: _CONFIG["retrieval"]

# ============================================================================
# Embedding
# ============================================================================

def get_embedding(text: str, model: str = EMBEDDING_MODEL) -> List[float]:
    """Get embedding from Ollama."""
    # Ensure Ollama is running before attempting
    if not ensure_ollama():
        print("Cannot get embedding: Ollama not available", file=sys.stderr)
        return []
    
    url = f"{OLLAMA_URL}/api/embeddings"
    payload = json.dumps({"model": model, "prompt": text}).encode("utf-8")
    req = request.Request(
        url,
        data=payload,
        headers={"Content-Type": "application/json"}
    )
    try:
        with request.urlopen(req, timeout=60) as resp:
            result = json.loads(resp.read().decode("utf-8"))
            return result.get("embedding", [])
    except Exception as e:
        print(f"Error getting embedding: {e}", file=sys.stderr)
        return []

# ============================================================================
# Noise Filtering
# ============================================================================

DENIAL_PATTERNS = [
    re.compile(r"i don'?t have (any )?(information|data|memory|record)", re.I),
    re.compile(r"i'?m not sure about", re.I),
    re.compile(r"i don'?t recall", re.I),
    re.compile(r"i don'?t remember", re.I),
    re.compile(r"it looks like i don'?t", re.I),
    re.compile(r"i wasn'?t able to find", re.I),
    re.compile(r"no (relevant )?memories found", re.I),
    re.compile(r"i don'?t have access to", re.I),
]

META_QUESTION_PATTERNS = [
    re.compile(r"\bdo you (remember|recall|know about)\b", re.I),
    re.compile(r"\bcan you (remember|recall)\b", re.I),
    re.compile(r"\bdid i (tell|mention|say|share)\b", re.I),
    re.compile(r"\bhave i (told|mentioned|said)\b", re.I),
    re.compile(r"\bwhat did i (tell|say|mention)\b", re.I),
]

BOILERPLATE_PATTERNS = [
    re.compile(r"^(hi|hello|hey|good morning|good evening|greetings)", re.I),
    re.compile(r"^fresh session", re.I),
    re.compile(r"^new session", re.I),
    re.compile(r"^HEARTBEAT", re.I),
]

def is_noise(text: str) -> bool:
    """Check if text is noise that should be filtered."""
    trimmed = text.strip()
    
    if len(trimmed) < 5:
        return True
    
    for pattern in DENIAL_PATTERNS:
        if pattern.search(trimmed):
            return True
    
    for pattern in META_QUESTION_PATTERNS:
        if pattern.search(trimmed):
            return True
    
    for pattern in BOILERPLATE_PATTERNS:
        if pattern.match(trimmed):
            return True
    
    return False

# ============================================================================
# Adaptive Retrieval
# ============================================================================

SKIP_PATTERNS = [
    re.compile(r"^(hi|hello|hey|good\s*(morning|afternoon|evening|night)|greetings|yo|sup|howdy|what'?s up)\b", re.I),
    re.compile(r"^/"),  # slash commands
    re.compile(r"^(run|build|test|ls|cd|git|npm|pip|docker|curl|cat|grep|find|make|sudo)\b", re.I),
    re.compile(r"^(yes|no|yep|nope|ok|okay|sure|fine|thanks|thank you|thx|ty|got it|understood|cool|nice|great|good|perfect|awesome)\s*[.!]?$", re.I),
    re.compile(r"^(go ahead|continue|proceed|do it|start|begin|next|实施|开始|继续|好的|可以|行)\s*[.!]?$", re.I),
    re.compile(r"^[\U0001F300-\U0001F9FF\s]+$", re.U),  # emoji only
    re.compile(r"^HEARTBEAT", re.I),
    re.compile(r"^\[System", re.I),
]

FORCE_RETRIEVE_PATTERNS = [
    re.compile(r"\b(remember|recall|forgot|memory|memories)\b", re.I),
    re.compile(r"\b(last time|before|previously|earlier|yesterday|ago)\b", re.I),
    re.compile(r"\b(my (name|email|phone|address|birthday|preference))\b", re.I),
    re.compile(r"\b(what did (i|we)|did i (tell|say|mention))\b", re.I),
    re.compile(r"(你记得|之前|上次|以前|还记得|提到过|说过)", re.I),
    re.compile(r"^(what|i|can you|do you)", re.I),  # Short queries starting with these
]

def should_skip_retrieval(query: str) -> bool:
    """Determine if a query should skip memory retrieval."""
    trimmed = query.strip()
    
    # Force retrieve if memory-related intent
    for pattern in FORCE_RETRIEVE_PATTERNS:
        if pattern.search(trimmed):
            return False
    
    # Too short
    if len(trimmed) < 5:
        return True
    
    # Skip patterns
    for pattern in SKIP_PATTERNS:
        if pattern.match(trimmed):
            return True
    
    # Short non-question messages - be more permissive
    has_cjk = bool(re.search(r"[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]", trimmed))
    # Allow short queries that look like they need memory
    if len(trimmed) < 15 and "?" not in trimmed and "？" not in trimmed:
        # But don't skip if it has meaningful content
        if len(trimmed) < 5:
            return True
    
    return False

# ============================================================================
# Utility Functions
# ============================================================================

def clamp(value: float, min_val: float, max_val: float) -> float:
    """Clamp value between min and max."""
    if not math.isfinite(value):
        return min_val
    return max(min_val, min(max_val, value))

def clamp_01(value: float, fallback: float = 0.0) -> float:
    """Clamp value between 0 and 1."""
    return clamp(value, 0.0, 1.0)

def cosine_similarity(a: List[float], b: List[float]) -> float:
    """Calculate cosine similarity between two vectors."""
    if len(a) != len(b):
        raise ValueError("Vector dimensions must match")
    
    dot_product = sum(x * y for x, y in zip(a, b))
    norm_a = math.sqrt(sum(x * x for x in a))
    norm_b = math.sqrt(sum(x * x for x in b))
    
    if norm_a == 0 or norm_b == 0:
        return 0.0
    return dot_product / (norm_a * norm_b)

# ============================================================================
# Database
# ============================================================================

def init_db():
    """Initialize LanceDB with FTS index and handle schema migration."""
    MEMORY_DIR.mkdir(parents=True, exist_ok=True)
    db = lancedb.connect(str(DB_PATH))
    
    try:
        table = db.open_table(TABLE_NAME)
        
        # Check if we need to migrate schema
        schema = table.schema
        schema_fields = {field.name for field in schema}
        
        # Required new fields
        new_fields = {"access_count", "last_accessed"}
        missing_fields = new_fields - schema_fields
        
        if missing_fields:
            print(f"Migrating schema: adding missing fields {missing_fields}", file=sys.stderr)
            table = migrate_schema(db, table, missing_fields)
            
    except Exception:
        # Create table with new schema
        sample = {
            "id": "__schema__",
            "text": "",
            "vector": [0.0] * EMBEDDING_DIM,
            "category": "fact",
            "scope": "global",
            "importance": 0.0,
            "timestamp": 0,
            "metadata": "{}",
            "source": "schema",
            "access_count": 0,
            "last_accessed": 0
        }
        table = db.create_table(TABLE_NAME, [sample])
        table.delete('id = "__schema__"')
    
    # Create FTS index for BM25
    try:
        indices = table.list_indices()
        has_fts = False
        for idx in indices:
            idx_type = getattr(idx, 'index_type', None) or getattr(idx, 'indexType', None) or ''
            cols = getattr(idx, 'columns', []) or []
            if 'FTS' in str(idx_type) or 'text' in cols:
                has_fts = True
                break
        if not has_fts:
            table.create_fts_index("text")
    except Exception as e:
        print(f"Note: FTS index: {e}", file=sys.stderr)
    
    return db, table

def migrate_schema(db, old_table, missing_fields):
    """Migrate table to new schema with backup (safer than dropping)."""
    import shutil
    from datetime import datetime
    
    table_name = old_table.name
    
    print(f"Migrating table '{table_name}' to add fields {missing_fields}...", file=sys.stderr)
    
    # Step 1: Backup before migration
    backup_dir = DB_PATH / "backups"
    backup_dir.mkdir(parents=True, exist_ok=True)
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    backup_path = backup_dir / f"memories_{timestamp}"
    
    try:
        # Copy database to backup location
        shutil.copytree(DB_PATH, backup_path)
        print(f"Backup created at: {backup_path}", file=sys.stderr)
    except Exception as e:
        print(f"Warning: Could not create backup: {e}", file=sys.stderr)
        # Continue anyway - migration might still work
    
    # Get all data from old table
    try:
        # Use to_arrow() which returns a PyArrow Table
        arrow_table = old_table.to_arrow()
        data = arrow_table.to_pylist()  # Convert to list of dicts
        print(f"Found {len(data)} records to migrate", file=sys.stderr)
    except Exception as e:
        print(f"Warning: Could not read old table data: {e}", file=sys.stderr)
        print(f"Restore from backup: {backup_path}", file=sys.stderr)
        data = []
    
    # Add missing fields to each record
    migrated_data = []
    for record in data:
        migrated_record = dict(record)
        if "access_count" in missing_fields:
            migrated_record["access_count"] = 0
        if "last_accessed" in missing_fields:
            migrated_record["last_accessed"] = 0
        migrated_data.append(migrated_record)
    
    # Drop old table and recreate with new schema
    db.drop_table(table_name)
    
    # Create new table with migrated data
    if migrated_data:
        new_table = db.create_table(table_name, migrated_data)
    else:
        # Create empty table with correct schema
        sample = {
            "id": "__schema__",
            "text": "",
            "vector": [0.0] * EMBEDDING_DIM,
            "category": "fact",
            "scope": "global",
            "importance": 0.0,
            "timestamp": 0,
            "metadata": "{}",
            "source": "schema",
            "access_count": 0,
            "last_accessed": 0
        }
        new_table = db.create_table(table_name, [sample])
        new_table.delete('id = "__schema__"')
    
    # Recreate FTS index (it was dropped with the table)
    try:
        new_table.create_fts_index("text")
        print("Recreated FTS index", file=sys.stderr)
    except Exception as e:
        print(f"Note: Could not create FTS index: {e}", file=sys.stderr)
    
    print(f"Schema migration completed successfully!", file=sys.stderr)
    print(f"Backup available at: {backup_path}", file=sys.stderr)
    return new_table


def migrate_schema_fallback(db, old_table, missing_fields):
    """Fallback migration - kept for compatibility."""
    table_name = old_table.name

# ============================================================================
# Memory Operations
# ============================================================================

# Valid categories for memory classification
# - preference: User preferences, settings, likes/dislikes
# - decision: Choices made, conclusions reached  
# - fact: Factual information, verified data
# - error: Errors made, bugs spotted, problems encountered
# - learned: Verified conclusions, lessons learned, insights
# - uncertain: Information that needs further verification
VALID_CATEGORIES = ["preference", "decision", "fact", "error", "learned", "uncertain"]

# Valid sources for memory capture
# - manual: Admin manually stored
# - auto-session: Auto-captured from session summary
# - auto-correction: From user corrections
# - auto-preference: Detected from user behavior
VALID_SOURCES = ["manual", "auto-session", "auto-correction", "auto-preference"]

# Scope validation pattern: agent:scope-type
# Examples: admin:global, github-explorer:session-1, vault-navigator:project-alpha
# Format: lowercase letters/numbers/hyphens : lowercase letters/numbers/hyphens
SCOPE_PATTERN = re.compile(r"^([a-z][a-z0-9-]*):([a-z][a-z0-9-]*)$")

def validate_scope(scope: str) -> str:
    """Validate scope format. Returns valid scope or default."""
    if SCOPE_PATTERN.match(scope):
        return scope
    return "admin:global"

def store_memory(text: str, category: str = "fact", scope: str = "admin:global",
                importance: float = 0.5, metadata: dict = None, source: str = "manual") -> str:
    """Store a new memory."""
    # Validate category
    if category not in VALID_CATEGORIES:
        print(f"Warning: Unknown category '{category}'. Using 'fact'.", file=sys.stderr)
        category = "fact"
    
    # Validate source
    if source not in VALID_SOURCES:
        print(f"Warning: Unknown source '{source}'. Using 'manual'.", file=sys.stderr)
        source = "manual"
    
    # Validate scope format
    scope = validate_scope(scope)
    
    # Filter noise on store
    if _CONFIG["retrieval"]["filter_noise"] and is_noise(text):
        print(f"Skipped storing noise: {text[:50]}...", file=sys.stderr)
        return None
    
    _, table = init_db()
    
    memory_id = str(uuid.uuid4())
    timestamp = int(datetime.now().timestamp() * 1000)
    
    # Get embedding
    embedding = get_embedding(text)
    if not embedding:
        print("Error: Could not get embedding", file=sys.stderr)
        return None
    
    entry = {
        "id": memory_id,
        "text": text,
        "vector": embedding,
        "category": category,
        "scope": scope,
        "importance": importance,
        "timestamp": timestamp,
        "metadata": json.dumps(metadata) if metadata else "{}",
        "source": source,
        "access_count": 0,
        "last_accessed": 0
    }
    
    table.add([entry])
    
    print(f"Stored memory: {memory_id} [{category}] scope={scope}")
    return memory_id

def vector_search(query_vector: List[float], limit: int = 10, min_score: float = 0.1, 
                scope_filter: List[str] = None) -> List[Dict]:
    """Vector search."""
    _, table = init_db()
    
    try:
        query = table.search(query_vector, vector_column_name="vector").limit(limit * 10)
        
        if scope_filter:
            conditions = " OR ".join(f"scope = '{s}'" for s in scope_filter)
            query = query.where(f"({conditions}) OR scope IS NULL")
        
        results = query.to_list()
        
        memories = []
        for row in results:
            distance = row.get("_distance", 0)
            score = 1 / (1 + distance)
            
            if score < min_score:
                continue
            
            # Apply scope filter in app layer too
            row_scope = row.get("scope") or "global"
            if scope_filter and row_scope not in scope_filter:
                continue
            
            memories.append({
                "entry": {
                    "id": row["id"],
                    "text": row["text"],
                    "vector": row["vector"],
                    "category": row["category"],
                    "scope": row_scope,
                    "importance": row["importance"],
                    "timestamp": row["timestamp"],
                    "metadata": json.loads(row["metadata"]) if row.get("metadata") else {},
                },
                "score": score,
                "rank": len(memories) + 1,
            })
            
            if len(memories) >= limit:
                break
        
        return memories
        
    except Exception as e:
        print(f"Vector search error: {e}", file=sys.stderr)
        return []

def expand_query_for_bm25(query: str) -> str:
    """Expand query with synonyms and related terms for better BM25 recall.
    
    Pure text manipulation — zero latency, no Ollama calls.
    Only expands known high-value patterns; does not modify the original.
    Returns expanded query string with additional terms appended.
    """
    expansions = {
        # Preferences & settings
        "prefer": ["preference", "like", "want", "setting"],
        "preference": ["prefer", "like", "want", "setting"],
        "setting": ["config", "configuration", "preference", "option"],
        "config": ["configuration", "setting", "setup", "preference"],
        # Decisions
        "decided": ["decision", "chose", "choice", "selected"],
        "decision": ["decided", "chose", "choice", "selected"],
        "chose": ["choice", "selected", "decided", "picked"],
        # Goals & tasks
        "goal": ["objective", "target", "aim", "plan"],
        "task": ["todo", "work", "job", "action"],
        "project": ["work", "initiative", "effort"],
        # Memory-specific terms
        "remember": ["recall", "memory", "stored", "noted"],
        "recall": ["remember", "memory", "stored"],
        "memory": ["remember", "recall", "stored"],
        # Technical terms
        "error": ["bug", "issue", "problem", "failure"],
        "bug": ["error", "issue", "problem"],
        "fix": ["resolved", "fixed", "patched", "solution"],
        "agent": ["bot", "assistant", "skill"],
        "skill": ["agent", "plugin", "capability"],
    }
    
    query_lower = query.lower()
    extra_terms = []
    
    for term, synonyms in expansions.items():
        # Only expand if the exact term appears (word boundary check)
        if re.search(r'\b' + re.escape(term) + r'\b', query_lower):
            # Add synonyms not already in query
            for syn in synonyms:
                if syn not in query_lower:
                    extra_terms.append(syn)
    
    if extra_terms:
        # Deduplicate and append
        unique_extras = list(dict.fromkeys(extra_terms))[:6]  # Cap at 6 extra terms
        return query + " " + " ".join(unique_extras)
    
    return query


def ensure_fts_index(table):
    """Ensure FTS index exists on text column. Recreates if empty or missing."""
    try:
        indices = table.list_indices()
        has_fts = False
        idx_name = "text_idx"
        
        for idx in indices:
            idx_type = getattr(idx, 'index_type', None) or getattr(idx, 'indexType', None) or ''
            cols = getattr(idx, 'columns', []) or getattr(idx, 'fields', []) or []
            # Handle both dict and object access
            if isinstance(idx, dict):
                idx_name = idx.get('name', 'text_idx')
            else:
                idx_name = getattr(idx, 'name', 'text_idx')
            
            if 'FTS' in str(idx_type) or 'Inverted' in str(idx_type) or 'text' in cols:
                has_fts = True
                # Check if index has actually indexed rows
                try:
                    stats = table.index_stats(idx_name)
                    if stats and getattr(stats, 'num_indexed_rows', 0) == 0:
                        print(f"Warning: FTS index has 0 rows. Recreating...", file=sys.stderr)
                        table.drop_index(idx_name)
                        table.create_fts_index("text")
                        print(f"Recreated FTS index", file=sys.stderr)
                except Exception:
                    pass  # Index stats not available
                break
        
        if not has_fts:
            print(f"Warning: No FTS index found. Creating...", file=sys.stderr)
            table.create_fts_index("text")
            print(f"Created FTS index", file=sys.stderr)
            
    except Exception as e:
        print(f"Warning: Could not ensure FTS index: {e}", file=sys.stderr)


def bm25_search(query: str, limit: int = 10, scope_filter: List[str] = None) -> List[Dict]:
    """BM25 full-text search with query expansion."""
    _, table = init_db()
    
    # Expand query with synonyms/related terms for better recall (zero latency)
    expanded_query = expand_query_for_bm25(query)
    if expanded_query != query:
        print(f"BM25 query expanded: '{query[:40]}' → '{expanded_query[:60]}'", file=sys.stderr)
    
    try:
        # Ensure FTS index exists and works
        ensure_fts_index(table)
        
        search = table.search(expanded_query, "fts").limit(limit)
        
        if scope_filter:
            conditions = " OR ".join(f"scope = '{s}'" for s in scope_filter)
            search = search.where(f"({conditions}) OR scope IS NULL")
        
        results = search.to_list()
        
        memories = []
        for row in results:
            raw_score = row.get("_score", 0)
            # Sigmoid normalization
            normalized = 1 / (1 + math.exp(-raw_score / 5)) if raw_score > 0 else 0.5
            
            row_scope = row.get("scope") or "global"
            
            memories.append({
                "entry": {
                    "id": row["id"],
                    "text": row["text"],
                    "vector": row["vector"],
                    "category": row["category"],
                    "scope": row_scope,
                    "importance": row["importance"],
                    "timestamp": row["timestamp"],
                    "metadata": json.loads(row["metadata"]) if row.get("metadata") else {},
                },
                "score": normalized,
                "rank": len(memories) + 1,
            })
        
        return memories
        
    except Exception as e:
        print(f"BM25 search error: {e}", file=sys.stderr)
        return []

def fuse_results(vector_results: List[Dict], bm25_results: List[Dict]) -> List[Dict]:
    """RRF Fusion of vector and BM25 results."""
    vector_map = {r["entry"]["id"]: r for r in vector_results}
    bm25_map = {r["entry"]["id"]: r for r in bm25_results}
    
    all_ids = set(vector_map.keys()) | set(bm25_map.keys())
    
    fused = []
    for id in all_ids:
        v = vector_map.get(id)
        b = bm25_map.get(id)
        
        base = v or b
        
        vector_score = v["score"] if v else 0
        bm25_hit = 1 if b else 0
        bm25_boost = _CONFIG["retrieval"].get("bm25_boost", 0.30)
        
        # Base = vector score; BM25 hit boosts by configured % (default 30%)
        if v:
            fused_score = clamp_01(vector_score + (bm25_hit * bm25_boost * vector_score), 0.1)
        else:
            fused_score = clamp_01(max(b["score"], 0.5), 0.1)
        
        fused.append({
            "entry": base["entry"],
            "score": fused_score,
            "sources": {
                "vector": {"score": v["score"], "rank": v["rank"]} if v else None,
                "bm25": {"score": b["score"], "rank": b["rank"]} if b else None,
                "fused": {"score": fused_score},
            }
        })
    
    return sorted(fused, key=lambda x: x["score"], reverse=True)

def apply_recency_boost(results: List[Dict]) -> List[Dict]:
    """Apply recency boost: newer memories get score bonus."""
    config = _CONFIG["retrieval"]
    half_life = config["recency_half_life_days"]
    weight = config["recency_weight"]
    
    if not half_life or half_life <= 0 or not weight:
        return results
    
    now = time.time() * 1000
    boosted = []
    
    for r in results:
        ts = r["entry"].get("timestamp", now)
        age_days = (now - ts) / 86_400_000
        boost = math.exp(-age_days / half_life) * weight
        
        boosted.append({
            **r,
            "score": clamp_01(r["score"] + boost, r["score"])
        })
    
    return sorted(boosted, key=lambda x: x["score"], reverse=True)

def apply_importance_weight(results: List[Dict]) -> List[Dict]:
    """Apply importance weighting."""
    base_weight = 0.7
    weighted = []
    
    for r in results:
        importance = r["entry"].get("importance", 0.7)
        factor = base_weight + (1 - base_weight) * importance
        
        weighted.append({
            **r,
            "score": clamp_01(r["score"] * factor, r["score"] * base_weight)
        })
    
    return sorted(weighted, key=lambda x: x["score"], reverse=True)

def apply_length_normalization(results: List[Dict]) -> List[Dict]:
    """Apply length normalization: penalize long entries."""
    anchor = _CONFIG["retrieval"]["length_norm_anchor"]
    
    if not anchor or anchor <= 0:
        return results
    
    normalized = []
    
    for r in results:
        char_len = len(r["entry"]["text"])
        ratio = char_len / anchor
        log_ratio = math.log2(max(ratio, 1))
        factor = 1 / (1 + 0.5 * log_ratio)
        
        normalized.append({
            **r,
            "score": clamp_01(r["score"] * factor, r["score"] * 0.3)
        })
    
    return sorted(normalized, key=lambda x: x["score"], reverse=True)

def apply_time_decay(results: List[Dict]) -> List[Dict]:
    """Apply time decay: penalize old entries, but reinforce frequently accessed memories."""
    half_life = _CONFIG["retrieval"]["time_decay_half_life_days"]
    
    if not half_life or half_life <= 0:
        return results
    
    now = time.time() * 1000
    decayed = []
    
    for r in results:
        ts = r["entry"].get("timestamp", now)
        age_days = (now - ts) / 86_400_000
        
        # Base decay factor
        base_factor = 0.5 + 0.5 * math.exp(-age_days / half_life)
        
        # Access reinforcement: frequently accessed memories decay slower
        access_count = r["entry"].get("access_count", 0)
        last_accessed = r["entry"].get("last_accessed", 0)
        
        if access_count > 0 and last_accessed > 0:
            # Calculate access reinforcement factor
            # More accesses = slower decay (up to 2x slower)
            access_factor = 1.0 + min(1.0, math.log10(access_count + 1) / 2.0)
            
            # Recent accesses = slower decay
            access_age_days = (now - last_accessed) / 86_400_000
            recency_factor = 1.0 + max(0, 1.0 - min(1.0, access_age_days / 30.0))  # 30-day window
            
            # Combined reinforcement
            reinforcement = min(2.0, access_factor * recency_factor)
            factor = base_factor * reinforcement
        else:
            factor = base_factor
        
        decayed.append({
            **r,
            "score": clamp_01(r["score"] * factor, r["score"] * 0.5)
        })
    
    return sorted(decayed, key=lambda x: x["score"], reverse=True)

def apply_mmr_diversity(results: List[Dict], threshold: float = 0.85) -> List[Dict]:
    """Apply MMR diversity: avoid near-duplicate results."""
    if len(results) <= 1:
        return results
    
    selected = []
    deferred = []
    
    for candidate in results:
        too_similar = False
        for s in selected:
            s_vec = s["entry"].get("vector", [])
            c_vec = candidate["entry"].get("vector", [])
            
            if s_vec and c_vec:
                sim = cosine_similarity(s_vec, c_vec)
                if sim > threshold:
                    too_similar = True
                    break
        
        if too_similar:
            deferred.append(candidate)
        else:
            selected.append(candidate)
    
    return selected + deferred

def filter_noise_results(results: List[Dict]) -> List[Dict]:
    """Filter noise from results."""
    if not _CONFIG["retrieval"]["filter_noise"]:
        return results
    
    return [r for r in results if not is_noise(r["entry"]["text"])]

# Stable output contract for recall_memories().
# These are the fields agents and callers may depend on.
# Internal schema fields (vector, access_count, last_accessed) are
# intentionally excluded — they are implementation details.
# If the internal schema changes, update this constant; do NOT add new
# fields to the return dict without a corresponding entry here.
RECALL_OUTPUT_FIELDS = frozenset({
    "id", "text", "category", "scope", "importance",
    "timestamp", "metadata", "source", "score",
})

def recall_memories(query: str, scope: str = None, category: str = None, 
                   limit: int = 10, min_importance: float = 0.0) -> List[dict]:
    """Hybrid retrieval with all advanced features."""
    config = _CONFIG["retrieval"]
    
    # Adaptive retrieval: skip if not needed
    if should_skip_retrieval(query):
        print(f"Skipping retrieval for: {query[:30]}...")
        return []
    
    _, table = init_db()
    
    # Get query embedding (with cache)
    query_vector, was_cached = get_embedding_with_cache(query)
    if not query_vector:
        print("Error: Could not get query embedding", file=sys.stderr)
        return []
    
    if was_cached:
        print(f"Cache hit for: {query[:30]}...")
    
    # Build scope filter
    scope_filter = [scope] if scope else None
    
    # Check if BM25-only is sufficient
    use_bm25_only = should_use_bm25_only(query)
    
    # Run vector and/or BM25 based on query type
    if config["mode"] == "hybrid" and not use_bm25_only:
        # Full hybrid search for semantic queries
        vector_results = vector_search(query_vector, config["candidate_pool_size"], 0.1, scope_filter)
        bm25_results = bm25_search(query, config["candidate_pool_size"], scope_filter)
        
        # Filter by category
        if category:
            vector_results = [r for r in vector_results if r["entry"]["category"] == category]
            bm25_results = [r for r in bm25_results if r["entry"]["category"] == category]
        
        # Fuse results
        fused = fuse_results(vector_results, bm25_results)
        
        # Filter min score
        filtered = [r for r in fused if r["score"] >= config["min_score"]]
    elif use_bm25_only:
        # BM25-only for keyword-heavy or short queries
        print(f"BM25-only mode for: {query[:30]}...")
        bm25_results = bm25_search(query, limit * 2, scope_filter)
        
        # Filter by category
        if category:
            bm25_results = [r for r in bm25_results if r["entry"]["category"] == category]
        
        filtered = bm25_results
    else:
        # Vector-only mode
        vector_results = vector_search(query_vector, limit * 2, config["min_score"], scope_filter)
        if category:
            vector_results = [r for r in vector_results if r["entry"]["category"] == category]
        filtered = vector_results
    
    # Reranking removed (cross-encoder model lacked embedding capability)
    reranked = filtered
    
    # Apply multi-stage scoring
    recency_boosted = apply_recency_boost(reranked)
    importance_weighted = apply_importance_weight(recency_boosted)
    length_normalized = apply_length_normalization(importance_weighted)
    time_decayed = apply_time_decay(length_normalized)
    
    # Apply min importance
    if min_importance > 0:
        time_decayed = [r for r in time_decayed if r["entry"]["importance"] >= min_importance]
    
    # Hard min score
    hard_filtered = [r for r in time_decayed if r["score"] >= config["hard_min_score"]]
    
    # Filter noise
    denoised = filter_noise_results(hard_filtered)
    
    # MMR diversity
    deduplicated = apply_mmr_diversity(denoised)
    
    # Format output and update access tracking
    memories = []
    current_time = int(datetime.now().timestamp() * 1000)
    
    for r in deduplicated[:limit]:
        memory_id = r["entry"]["id"]
        
        # Update access tracking in database
        try:
            # Fresh DB read to avoid stale in-memory snapshot bug:
            # r["entry"]["access_count"] reflects the value at query time, not now.
            # Multiple recalls of the same memory within a session would all read
            # the same stale snapshot and write count+1 repeatedly, never advancing
            # beyond 1. A fresh read ensures we always increment the true current value.
            current_rows = table.search().where(f"id = '{memory_id}'").limit(1).to_list()
            current_count = current_rows[0].get("access_count", 0) if current_rows else 0
            table.update(
                where=f"id = '{memory_id}'",
                values={
                    "access_count": current_count + 1,
                    "last_accessed": current_time
                }
            )
        except Exception as e:
            print(f"Warning: Could not update access tracking for {memory_id[:8]}: {e}", file=sys.stderr)
        
        memories.append({
            "id": memory_id,
            "text": r["entry"]["text"],
            "category": r["entry"]["category"],
            "scope": r["entry"]["scope"],
            "importance": r["entry"]["importance"],
            "timestamp": r["entry"]["timestamp"],
            "metadata": r["entry"].get("metadata"),
            "source": r["entry"].get("source"),
            "access_count": r["entry"].get("access_count", 0),
            "last_accessed": r["entry"].get("last_accessed", 0),
            "score": round(r["score"] * 100, 1),  # Convert to percentage
        })
    

    return memories

def forget_memories(query: str = None, memory_id: str = None, scope: str = None) -> int:
    """Delete memories by query or ID."""
    _, table = init_db()
    
    deleted = 0
    
    if memory_id:
        table.delete(f"id = '{memory_id}'")
        deleted = 1
        
    elif query:
        results = recall_memories(query, scope=scope, limit=100)
        if results:
            for r in results:
                table.delete(f"id = '{r['id']}'")
            deleted = len(results)
            
    elif scope:
        table.delete(f"scope = '{scope}'")
        deleted = 1  # Can't easily count
    
    print(f"Deleted {deleted} memory(ies)")
    return deleted

def update_memory(memory_id: str, text: str = None, category: str = None, 
                 importance: float = None, scope: str = None) -> bool:
    """Update an existing memory."""
    _, table = init_db()
    
    # First, find the memory
    try:
        result = table.search().where(f"id = '{memory_id}'").to_list()
        if not result:
            print(f"Error: Memory {memory_id[:8]} not found", file=sys.stderr)
            return False
        
        entry = result[0]
        updates = {}
        
        if text is not None:
            # Need to update embedding if text changes
            new_embedding = get_embedding(text)
            if new_embedding:
                updates["text"] = text
                updates["vector"] = new_embedding
            else:
                print("Warning: Could not get new embedding, text not updated", file=sys.stderr)
        
        if category is not None:
            updates["category"] = category
        
        if importance is not None:
            updates["importance"] = importance
        
        if scope is not None:
            updates["scope"] = scope
        
        if updates:
            table.update(where=f"id = '{memory_id}'", values=updates)
            print(f"Updated memory: {memory_id[:8]}")
            return True
        else:
            print("No changes specified")
            return False
            
    except Exception as e:
        print(f"Error updating memory: {e}", file=sys.stderr)
        return False

def list_memories(scope: str = None, category: str = None, limit: int = 50) -> List[dict]:
    """List memories without semantic search."""
    _, table = init_db()
    
    try:
        arrow_table = table.to_arrow()
        results = arrow_table.to_pydict()
        
        rows = []
        num_rows = len(results.get("id", []))
        for i in range(num_rows):
            row = {k: v[i] if i < len(v) else None for k, v in results.items()}
            
            # Apply filters
            if scope and row.get("scope") != scope:
                continue
            if category and row.get("category") != category:
                continue
            
            rows.append({
                "id": row.get("id"),
                "text": row.get("text", ""),
                "category": row.get("category", "other"),
                "scope": row.get("scope", "global"),
                "importance": row.get("importance", 0.5),
                "timestamp": row.get("timestamp", 0),
                "metadata": row.get("metadata"),
                "source": row.get("source"),
                "access_count": row.get("access_count", 0),
                "last_accessed": row.get("last_accessed", 0),
            })
            
            if len(rows) >= limit:
                break
        
        return rows
    except Exception as e:
        print(f"List error: {e}", file=sys.stderr)
        return []

def export_memories(output_path: str = None) -> str:
    """Export all memories to a JSON file (vectors excluded)."""
    _, table = init_db()

    try:
        arrow_table = table.to_arrow()
        results = arrow_table.to_pydict()
        num_rows = len(results.get("id", []))

        rows = []
        for i in range(num_rows):
            row = {k: v[i] if i < len(v) else None for k, v in results.items()}
            rows.append({
                "id": row.get("id"),
                "text": row.get("text", ""),
                "category": row.get("category", "fact"),
                "scope": row.get("scope", "admin:global"),
                "importance": row.get("importance", 0.5),
                "timestamp": row.get("timestamp", 0),
                "metadata": row.get("metadata"),
                "source": row.get("source", "manual"),
                "access_count": row.get("access_count", 0),
                "last_accessed": row.get("last_accessed", 0),
            })

        if not output_path:
            timestamp_str = datetime.now().strftime("%Y%m%d_%H%M%S")
            export_dir = DB_PATH.parent
            export_dir.mkdir(parents=True, exist_ok=True)
            output_path = str(export_dir / f"export_{timestamp_str}.json")

        with open(output_path, "w", encoding="utf-8") as f:
            json.dump({"version": 1, "count": len(rows), "memories": rows}, f, indent=2, ensure_ascii=False)

        print(f"Exported {len(rows)} memories to: {output_path}")
        return output_path
    except Exception as e:
        print(f"Export error: {e}", file=sys.stderr)
        return None


def import_memories(input_path: str, overwrite: bool = False) -> int:
    """Import memories from a JSON export file. Returns count of imported entries."""
    if not os.path.exists(input_path):
        print(f"Error: File not found: {input_path}", file=sys.stderr)
        return 0

    try:
        with open(input_path, "r", encoding="utf-8") as f:
            data = json.load(f)
    except (json.JSONDecodeError, OSError) as e:
        print(f"Error reading export file: {e}", file=sys.stderr)
        return 0

    memories = data.get("memories", [])
    if not memories:
        print("No memories found in export file.")
        return 0

    _, table = init_db()

    if overwrite:
        # Clear existing memories before importing
        try:
            arrow_table = table.to_arrow()
            existing_ids = arrow_table.to_pydict().get("id", [])
            for eid in existing_ids:
                try:
                    table.delete(f"id = '{eid}'")
                except Exception:
                    pass
            print(f"Cleared {len(existing_ids)} existing memories.", file=sys.stderr)
        except Exception as e:
            print(f"Warning: Could not clear existing memories: {e}", file=sys.stderr)
    else:
        # Collect existing IDs to skip duplicates
        try:
            arrow_table = table.to_arrow()
            existing_ids = set(arrow_table.to_pydict().get("id", []))
        except Exception:
            existing_ids = set()

    imported = 0
    skipped = 0
    for entry in memories:
        eid = entry.get("id")
        if not overwrite and eid and eid in existing_ids:
            skipped += 1
            continue

        text = entry.get("text", "").strip()
        if not text:
            skipped += 1
            continue

        metadata_raw = entry.get("metadata")
        if isinstance(metadata_raw, str):
            try:
                metadata = json.loads(metadata_raw) if metadata_raw and metadata_raw != "{}" else None
            except json.JSONDecodeError:
                metadata = None
        elif isinstance(metadata_raw, dict):
            metadata = metadata_raw if metadata_raw else None
        else:
            metadata = None

        store_memory(
            text=text,
            category=entry.get("category", "fact"),
            scope=entry.get("scope", "admin:global"),
            importance=float(entry.get("importance", 0.5)),
            metadata=metadata,
            source=entry.get("source", "manual"),
        )
        imported += 1

    print(f"Imported {imported} memories. Skipped {skipped} (duplicates or empty).")
    return imported


def show_stats():
    """Show memory statistics."""
    _, table = init_db()
    
    try:
        arrow_table = table.to_arrow()
        results = arrow_table.to_pydict()
        
        total = len(results.get("id", []))
        
        by_category = {}
        by_scope = {}
        timestamps = []
        
        for i in range(total):
            cat = results.get("category", [None] * total)[i]
            sc = results.get("scope", [None] * total)[i]
            ts = results.get("timestamp", [0] * total)[i]
            
            if cat:
                by_category[cat] = by_category.get(cat, 0) + 1
            if sc:
                by_scope[sc] = by_scope.get(sc, 0) + 1
            if ts:
                timestamps.append(ts)
    except Exception as e:
        print(f"Stats error: {e}", file=sys.stderr)
        total = 0
        by_category = {}
        by_scope = {}
        timestamps = []
    
    # Use JSON for easy parsing - avoids colon issues
    import json
    output = {
        "total": total,
        "byCategory": by_category,
        "byScope": by_scope,
        "oldestTimestamp": min(timestamps) if timestamps else None,
        "newestTimestamp": max(timestamps) if timestamps else None,
    }
    print("__JSON_START__")
    print(json.dumps(output))
    print("__JSON_END__")

# ============================================================================
# CLI Interface
# ============================================================================

def main():
    global _CONFIG  # Allow reloading config
    
    if len(sys.argv) < 2:
        print(__doc__)
        sys.exit(1)
    
    # Check for --config flag before processing command
    config_path = None
    filtered_args = []
    i = 1
    while i < len(sys.argv):
        if sys.argv[i] == "--config" and i + 1 < len(sys.argv):
            config_path = sys.argv[i + 1]
            i += 2
        else:
            filtered_args.append(sys.argv[i])
            i += 1
    
    # Reload config if custom path provided
    if config_path:
        _CONFIG = load_config(config_path)
        sys.argv = [sys.argv[0]] + filtered_args  # Remove --config from argv
    
    command = sys.argv[1].lower()
    
    # Initialize DB for all commands
    init_db()
    
    if command == "init":
        print("Database initialized with FTS index.")
    
    elif command == "store":
        if len(sys.argv) < 3:
            print("Usage: agent-memory.py store <text> [options]")
            print("Options:")
            print("  --category <cat>    Category: preference, decision, fact, error, learned, uncertain")
            print("  --scope <scope>      Scope (default: admin:global)")
            print("  --importance <0-1>  Importance (default: 0.5)")
            print("  --metadata <json>    JSON metadata object")
            print("  --source <src>       Source: manual, auto-session, auto-correction, auto-preference")
            sys.exit(1)
        
        text = sys.argv[2]
        category = "fact"
        scope = "admin:global"
        importance = 0.5
        metadata = None
        source = "manual"
        
        i = 3
        while i < len(sys.argv):
            if sys.argv[i] == "--category" and i + 1 < len(sys.argv):
                category = sys.argv[i + 1]
                i += 2
            elif sys.argv[i] == "--scope" and i + 1 < len(sys.argv):
                scope = sys.argv[i + 1]
                i += 2
            elif sys.argv[i] == "--importance" and i + 1 < len(sys.argv):
                importance = float(sys.argv[i + 1])
                i += 2
            elif sys.argv[i] == "--metadata" and i + 1 < len(sys.argv):
                try:
                    metadata = json.loads(sys.argv[i + 1])
                except json.JSONDecodeError as e:
                    print(f"Error: Invalid JSON metadata: {e}", file=sys.stderr)
                    sys.exit(1)
                i += 2
            elif sys.argv[i] == "--source" and i + 1 < len(sys.argv):
                source = sys.argv[i + 1]
                i += 2
            else:
                i += 1
        
        store_memory(text, category, scope, importance, metadata, source)
    
    elif command == "recall":
        if len(sys.argv) < 3:
            print("Usage: agent-memory.py recall <query> [options]")
            sys.exit(1)
        
        query = sys.argv[2]
        scope = None
        category = None
        limit = 10
        
        i = 3
        while i < len(sys.argv):
            if sys.argv[i] == "--scope" and i + 1 < len(sys.argv):
                scope = sys.argv[i + 1]
                i += 2
            elif sys.argv[i] == "--category" and i + 1 < len(sys.argv):
                category = sys.argv[i + 1]
                i += 2
            elif sys.argv[i] == "--limit" and i + 1 < len(sys.argv):
                limit = int(sys.argv[i + 1])
                i += 2
            else:
                i += 1
        
        results = recall_memories(query, scope, category, limit)
        
        if not results:
            print("No memories found.")
        else:
            print(f"Found {len(results)} memory(ies):\n")
            for i, r in enumerate(results, 1):
                print(f"{i}. [{r['category']}] {r['text'][:80]}...")
                print(f"   Score: {r['score']}% | Scope: {r['scope']} | Importance: {r['importance']}")
                print()
    
    elif command == "forget":
        query = None
        memory_id = None
        scope = None
        
        i = 2
        while i < len(sys.argv):
            if sys.argv[i] == "--query" and i + 1 < len(sys.argv):
                query = sys.argv[i + 1]
                i += 2
            elif sys.argv[i] == "--id" and i + 1 < len(sys.argv):
                memory_id = sys.argv[i + 1]
                i += 2
            elif sys.argv[i] == "--scope" and i + 1 < len(sys.argv):
                scope = sys.argv[i + 1]
                i += 2
            else:
                i += 1
        
        if not query and not memory_id and not scope:
            print("Usage: agent-memory.py forget [--query <text>] [--id <uuid>] [--scope <scope>]")
            sys.exit(1)
        
        forget_memories(query, memory_id, scope)
    
    elif command == "list":
        scope = None
        category = None
        limit = 50
        
        i = 2
        while i < len(sys.argv):
            if sys.argv[i] == "--scope" and i + 1 < len(sys.argv):
                scope = sys.argv[i + 1]
                i += 2
            elif sys.argv[i] == "--category" and i + 1 < len(sys.argv):
                category = sys.argv[i + 1]
                i += 2
            elif sys.argv[i] == "--limit" and i + 1 < len(sys.argv):
                limit = int(sys.argv[i + 1])
                i += 2
            else:
                i += 1
        
        results = list_memories(scope, category, limit)
        
        if not results:
            print("No memories found.")
        else:
            # Remove unicode characters for Windows compatibility
            def clean_text(text):
                return text.encode('ascii', 'replace').decode('ascii')
            
            print(f"Found {len(results)} memory(ies):\n")
            for i, r in enumerate(results, 1):
                text_preview = r['text'][:80]
                text_preview = clean_text(text_preview)
                if len(r['text']) > 80:
                    text_preview += "..."
                access_info = f" | Accessed: {r.get('access_count', 0)}x" if r.get('access_count', 0) > 0 else ""
                print(f"{i}. [{r['category']}] {text_preview}")
                print(f"   ID: {r['id']} | Scope: {r['scope']} | Importance: {r['importance']}{access_info}")
    
    elif command == "update":
        if len(sys.argv) < 3:
            print("Usage: agent-memory.py update <memory_id> [--text <text>] [--category <category>] [--importance <0-1>] [--scope <scope>]")
            sys.exit(1)
        
        memory_id = sys.argv[2]
        text = None
        category = None
        importance = None
        scope = None
        
        i = 3
        while i < len(sys.argv):
            if sys.argv[i] == "--text" and i + 1 < len(sys.argv):
                text = sys.argv[i + 1]
                i += 2
            elif sys.argv[i] == "--category" and i + 1 < len(sys.argv):
                category = sys.argv[i + 1]
                i += 2
            elif sys.argv[i] == "--importance" and i + 1 < len(sys.argv):
                importance = float(sys.argv[i + 1])
                i += 2
            elif sys.argv[i] == "--scope" and i + 1 < len(sys.argv):
                scope = sys.argv[i + 1]
                i += 2
            else:
                i += 1
        
        success = update_memory(memory_id, text, category, importance, scope)
        if not success:
            sys.exit(1)
    
    elif command == "stats":
        show_stats()
    
    elif command == "config":
        # Show current config
        print("Current configuration:")
        for k, v in _CONFIG["retrieval"].items():
            print(f"  {k}: {v}")

    elif command == "export":
        output_path = None
        i = 2
        while i < len(sys.argv):
            if sys.argv[i] == "--output" and i + 1 < len(sys.argv):
                output_path = sys.argv[i + 1]
                i += 2
            else:
                i += 1
        export_memories(output_path)

    elif command == "import":
        if len(sys.argv) < 3:
            print("Usage: agent-memory.py import <file> [--overwrite]")
            sys.exit(1)
        input_path = sys.argv[2]
        overwrite = "--overwrite" in sys.argv
        import_memories(input_path, overwrite)

    else:
        print(f"Unknown command: {command}")
        print(__doc__)
        sys.exit(1)

if __name__ == "__main__":
    main()
