Skill: mwmdeadpool/brain-memory-system (version 1.0.1)

--- name: cognitive-brain description: Unified cognitive memory system inspired by human brain architecture. Provides episodic memory (hippocampus), semantic facts (neocortex), procedural memory with LLM-driven evolution (cerebellum), attention filtering (thalamus), sleep consolidation, and soul erosion health metrics. Use when storing experiences, recalling memories, managing facts, creating/evolving procedures, filtering incoming information by importance, running memory consolidation, or checking memory health. Replaces separate facts/proc CLIs with a single `brain` command. --- # Cognitive Brain Unified memory system modeled on human brain architecture. One CLI (`brain`) for all memory operations. ## Architecture | System | Brain Region | What it does | |--------|-------------|--------------| | **Episodic** | Hippocampus | Time-stamped experiences with emotional tags | | **Semantic** | Neocortex | Structured facts (entity/key/value with FTS5) | | **Procedural** | Cerebellum | Versioned workflows that evolve from failures | | **Attention** | Thalamus | Score incoming info → store/summarize/discard | | **Consolidation** | Sleep replay | Batch-process episodes → extract facts | | **Health** | Soul erosion | Detect memory drift, conflicts, flatness | ## Installation ```bash # 1. Initialize the database sqlite3 brain.db < scripts/schema.sql # 2. Link the CLI ln -sf "$(pwd)/scripts/brain.sh" ~/.local/bin/brain chmod +x scripts/brain.sh # 3. (Optional) Migrate existing daily logs python3 scripts/migrate-daily-logs.py --dir /path/to/memory/ --db brain.db ``` ### Environment Variables | Variable | Default | Purpose | |----------|---------|---------| | `BRAIN_DB` | `<skill>/brain.db` | Path to brain database | | `BRAIN_AGENT` | `margot` | Agent identity for scoping | | `BRAIN_FACTS_DB` | `memory/facts.db` | Legacy facts database path | | `BRAIN_LLM_URL` | Google Gemini endpoint | OpenAI-compatible chat completions URL | | `BRAIN_LLM_KEY` | *(none — must be set)* | API key for LLM provider (required for `proc evolve`) | | `BRAIN_LLM_MODEL` | `gemini-2.5-flash` | Model name for evolution reasoning | ## Credentials & Scope **Required for `brain proc evolve` only:** - `BRAIN_LLM_KEY` — Your API key for the LLM provider. Set via env var or `brain config set key <value>`. - No credentials are auto-discovered or read from platform stores. - Without a key, `proc evolve` falls back to local pattern-based evolution (no LLM needed). **Data scope:** - All data stays in your `brain.db` file (local SQLite). - `brain facts` reads/writes `BRAIN_FACTS_DB` (default: `facts.db` in skill directory). - `brain wm` reads/writes `SESSION_STATE` (default: `SESSION-STATE.md` in workspace root). - No data is sent externally except LLM API calls during `proc evolve`. ## Quick Reference ### Store & Recall ```bash brain store "Fixed the deploy pipeline" --title "Deploy Fix" --emotion relieved --importance 8 brain ingest "Docker OOM at 3 AM" --title "OOM Event" --source mqtt # attention-gated brain recall "deploy pipeline" --type all --limit 5 brain episodes 2026-03-15 brain emotions 7 brain important 8 14 ``` ### Facts (Semantic Memory) ```bash brain facts get Darian favorite_movie brain facts set Mae birthday "September 12" --category date --permanent brain facts search "SSH" --limit 5 brain facts list --entity Darian --limit 10 brain facts stats ``` ### Procedures (Cerebellum) ```bash brain proc create deploy-api --title "Deploy API" --steps '["Pull latest","Run tests","Deploy"]' brain proc success deploy-api brain proc fail deploy-api --step 2 --error "Tests timed out" --fix "Increased timeout to 60s" brain proc evolve deploy-api # LLM rewrites steps from failure patterns brain proc evolve deploy-api --dry-run # preview without applying brain proc history deploy-api # full evolution timeline brain proc list ``` ### Attention Filter ```bash brain filter "GPU temperature 72°C" --source mqtt # → discard (routine) brain filter "SSH brute force from new IP" --source security # → store (novel threat) ``` ### Consolidation ```bash brain consolidate --dry-run # preview what would be processed brain consolidate # run sleep replay ``` ### Health (Soul Erosion Detection) ```bash brain health # 7-metric scored report brain health -v # verbose with all details brain health --json # machine-readable for crons ``` ### Configuration ```bash brain config show # current LLM config brain config set model gpt-4o # change model brain config set url http://localhost:11434/v1/chat/completions # switch to Ollama ``` ### Multi-Agent ```bash brain --agent bud store "Patrol complete" --title "Bud Patrol" --importance 3 brain --agent bud proc list # sees own + shared procedures brain who # show all agents in the system ``` ## Procedure Evolution Flow The core innovation — procedures that rewrite themselves from failure patterns: 1. **Record failures** with step-level granularity: `brain proc fail <slug> --step N --error "desc"` 2. At 3+ failures, brain suggests evolution 3. **`brain proc evolve <slug>`** analyzes patterns: - Repeat offender steps (same step failing multiple times) - Brittle chains (consecutive step failures) - Error keyword clustering (timeout, auth, permission, etc.) 4. LLM synthesizes and rewrites steps — adds pre-checks, reorders, annotates with `[vN: reason]` 5. Local fallback if LLM unavailable — pattern-matching inserts defensive steps 6. Full version history preserved: `brain proc history <slug>` ## Health Metrics Seven metrics, each scored 1-10: | Metric | What it detects | |--------|----------------| | Memory Freshness | Time since last recorded episode | | Consolidation Debt | Backlog of unprocessed episodes | | Importance Calibration | Everything rated 8+? Nothing is important | | Emotional Diversity | Flatlined to one emotion = loss of range | | Fact Consistency | Contradictory facts = identity fragmentation | | Procedure Health | Success rates dropping on learned behaviors | | Recording Cadence | Silent days creating memory gaps | ## Schema Database: SQLite with WAL mode, FTS5 full-text search, foreign keys. Tables: `episodes`, `episodes_fts`, `facts`, `facts_fts`, `procedures`, `procedure_history`, `working_memory`, `consolidation_log`, `brain_meta`. Initialize with: `sqlite3 brain.db < scripts/schema.sql` ## Files | File | Purpose | |------|---------| | `scripts/brain.sh` | Main CLI dispatcher | | `scripts/schema.sql` | Database schema | | `scripts/attention.py` | Thalamic attention filter (rule-based scoring) | | `scripts/consolidate.py` | Sleep replay consolidation pipeline | | `scripts/erosion.py` | Soul erosion health metrics | | `scripts/evolve.py` | Procedure evolution engine (LLM + local fallback) | | `scripts/facts.py` | Semantic fact storage wrapper | | `scripts/migrate-daily-logs.py` | Import existing daily markdown logs |

path: README.md
content: # 🧠 Cognitive Brain

A unified cognitive memory system for AI agents, inspired by human brain architecture.

One CLI (`brain`) for all memory operations — episodic, semantic, procedural, attention filtering, sleep consolidation, and soul erosion health metrics.

## Architecture

| System | Brain Region | What it does |
|--------|-------------|--------------|
| **Episodic** | Hippocampus | Time-stamped experiences with emotional tags |
| **Semantic** | Neocortex | Structured facts (entity/key/value with FTS5) |
| **Procedural** | Cerebellum | Versioned workflows that evolve from failures |
| **Attention** | Thalamus | Score incoming info → store/summarize/discard |
| **Consolidation** | Sleep replay | Batch-process episodes → extract facts |
| **Working Memory** | Prefrontal Cortex | Capacity-limited active context with TTL |
| **Health** | Soul erosion | Detect memory drift, conflicts, emotional flatness |

## Key Feature: Procedure Evolution

Procedures that **rewrite themselves** from failure patterns:

1. Record failures with step-level granularity
2. At 3+ failures, the system suggests evolution
3. `brain proc evolve` analyzes patterns (repeat offenders, brittle chains, error keywords)
4. LLM synthesizes and rewrites steps — adds pre-checks, reorders, annotates
5. Local fallback if LLM unavailable
6. Full version history preserved

## Quick Start

```bash
# Initialize the database
sqlite3 brain.db < scripts/schema.sql

# Link the CLI
ln -sf "$(pwd)/scripts/brain.sh" ~/.local/bin/brain
chmod +x scripts/brain.sh

# Store an experience
brain store "Fixed the deploy pipeline" --title "Deploy Fix" --emotion relieved --importance 8

# Search all memory
brain recall "deploy pipeline"

# Check brain health
brain health
```

## Installation as OpenClaw Skill

Drop the `cognitive-brain/` directory into your OpenClaw workspace `skills/` folder, or install the `.skill` package.

## Environment Variables

| Variable | Default | Purpose |
|----------|---------|---------|
| `BRAIN_DB` | `<skill>/brain.db` | Path to brain database |
| `BRAIN_AGENT` | `margot` | Agent identity for scoping |
| `BRAIN_FACTS_DB` | `memory/facts.db` | Legacy facts database path |
| `BRAIN_LLM_URL` | Google Gemini endpoint | LLM API for procedure evolution |
| `BRAIN_LLM_KEY` | Auto-discovered | API key |
| `BRAIN_LLM_MODEL` | `gemini-2.5-flash` | Model for evolution reasoning |

## Multi-Agent Support

Each agent gets its own scoped memory. Agents see their own data + shared data:

```bash
brain --agent bud store "Patrol complete" --title "Bud Patrol" --importance 3
brain --agent bud proc list   # sees own + shared procedures
brain who                     # show all agents in the system
```

## Soul Erosion Detection

Seven metrics, each scored 1-10:

- **Memory Freshness** — Time since last recorded episode
- **Consolidation Debt** — Backlog of unprocessed episodes
- **Importance Calibration** — Everything rated 8+? Nothing is important
- **Emotional Diversity** — Flatlined to one emotion = loss of range
- **Fact Consistency** — Contradictory facts = identity fragmentation
- **Procedure Health** — Success rates dropping on learned behaviors
- **Recording Cadence** — Silent days creating memory gaps

## Tech Stack

- **SQLite** with WAL mode + FTS5 full-text search
- **Python 3** for all database operations (parameterized queries — no SQL injection)
- **Bash** dispatcher for CLI ergonomics
- **Any OpenAI-compatible LLM** for procedure evolution (configurable)

## License

MIT


path: scripts/attention.py
content: #!/usr/bin/env python3
"""
Thalamic Attention Filter — decides what deserves to become a memory.

The brain discards ~90% of sensory input before it reaches conscious processing.
This filter scores incoming events and decides: store, summarize, or discard.

Usage:
  # From CLI
  brain filter "GPU temp check passed, all normal" --source cron
  brain filter "Puddin' asked about car mode bluetooth" --source conversation
  brain filter "person detected at front door [90% confidence]" --source frigate

  # From Python
  from attention import should_store
  result = should_store(content, source="mqtt", context={...})
  # result: {"action": "store|summarize|discard", "score": 7.2, "reason": "..."}
"""
import re
import sqlite3
import os
import sys
import json
from pathlib import Path
from datetime import datetime, timedelta

BRAIN_DB = os.environ.get("BRAIN_DB", str(Path(__file__).parent / "brain.db"))
AGENT = os.environ.get("BRAIN_AGENT", "margot")

# ============================================================
# ATTENTION SCORES — what makes something worth remembering?
# ============================================================

# Source weights — how important is this channel?
SOURCE_WEIGHTS = {
    "conversation": 8,    # Puddin' talking = always important
    "decision": 8,        # Decisions are always worth storing
    "error": 7,           # Errors need to be remembered
    "alert_critical": 9,  # Critical alerts
    "alert_warning": 5,   # Warnings
    "alert_info": 2,      # Info alerts — mostly noise
    "frigate": 3,         # Camera detections — usually routine
    "cron": 2,            # Cron outputs — mostly routine
    "heartbeat": 1,       # Heartbeat results — very routine
    "mqtt": 3,            # MQTT events
    "handoff": 6,         # Agent handoffs
    "manual": 7,          # Manually stored = intentional
}

# Novelty boosters — keywords that signal something new/important
NOVELTY_KEYWORDS = {
    # High novelty (+3)
    "first time": 3, "never seen": 3, "new feature": 3, "breakthrough": 3,
    "discovered": 3, "root cause": 3, "figured out": 3,
    # Medium novelty (+2)
    "fixed": 2, "broke": 2, "failed": 2, "crash": 2, "error": 2,
    "deployed": 2, "built": 2, "created": 2, "shipped": 2,
    "lesson": 2, "learned": 2, "mistake": 2,
    # Low novelty (+1)
    "updated": 1, "changed": 1, "migrated": 1, "upgraded": 1,
    "installed": 1, "configured": 1,
}

# Suppression patterns — things we've seen too many times
SUPPRESSION_PATTERNS = [
    r"heartbeat.?ok",
    r"nothing.?new",
    r"no.?action.?needed",
    r"all.?(?:checks?\s+)?(?:pass|clean|green|healthy|nominal)",
    r"routine\s+check",
    r"cron\s+completed?\s+successfully",
    r"up\s+\d+\s+(?:hours?|days?)",  # "Up 3 hours" status messages
]

# Dedup window — don't store similar content within this period
DEDUP_WINDOW_HOURS = 4


def calculate_attention_score(content: str, source: str = "manual", context: dict = None) -> dict:
    """
    Score an incoming event for attention worthiness.
    Returns: {"score": float, "action": str, "reason": str, "factors": dict}
    
    Score ranges:
      0-3: DISCARD — routine noise, not worth storing
      4-5: SUMMARIZE — batch with similar events, store summary later  
      6+:  STORE — important enough for episodic memory
    """
    context = context or {}
    content_lower = content.lower()
    factors = {}
    
    # 1. Source weight
    source_score = SOURCE_WEIGHTS.get(source, 4)
    factors["source"] = source_score
    
    # 2. Content length — very short = probably not substantive
    length_score = 0
    if len(content) > 500:
        length_score = 2
    elif len(content) > 100:
        length_score = 1
    factors["length"] = length_score
    
    # 3. Novelty keywords
    novelty_score = 0
    matched_keywords = []
    for keyword, boost in NOVELTY_KEYWORDS.items():
        if keyword in content_lower:
            novelty_score += boost
            matched_keywords.append(keyword)
    novelty_score = min(novelty_score, 5)  # Cap at 5
    factors["novelty"] = novelty_score
    if matched_keywords:
        factors["novelty_keywords"] = matched_keywords[:5]
    
    # 4. Suppression — routine patterns get penalized
    suppression = 0
    for pattern in SUPPRESSION_PATTERNS:
        if re.search(pattern, content_lower):
            suppression += 3
            factors["suppressed_by"] = pattern
            break
    factors["suppression"] = -suppression
    
    # 5. Emotional signals
    emotion_score = 0
    emotion_words = ["frustrated", "proud", "excited", "worried", "relieved", 
                     "angry", "happy", "scared", "confused", "amazed",
                     "electric", "alive", "heavy", "tender", "uneasy"]
    for word in emotion_words:
        if word in content_lower:
            emotion_score = 2
            factors["emotion_detected"] = word
            break
    factors["emotion"] = emotion_score
    
    # 6. Entity mentions (Puddin', specific systems, agents)
    entity_score = 0
    important_entities = ["puddin", "darian", "lisa", "charlie", "mae",
                         "security", "breach", "vulnerability", "critical"]
    for entity in important_entities:
        if entity in content_lower:
            entity_score = 2
            factors["entity_detected"] = entity
            break
    factors["entity"] = entity_score
    
    # 7. Recency dedup — check if similar content was stored recently
    dedup_penalty = 0
    try:
        conn = sqlite3.connect(BRAIN_DB)
        cutoff = (datetime.now() - timedelta(hours=DEDUP_WINDOW_HOURS)).isoformat()
        
        # Simple check: same first 50 chars in recent episodes
        # Use substr() exact match instead of LIKE to prevent wildcard injection
        content_prefix = content[:50]
        recent = conn.execute(
            "SELECT COUNT(*) FROM episodes WHERE substr(content,1,50) = ? AND created_at > ? AND agent = ?",
            (content_prefix, cutoff, AGENT)
        ).fetchone()[0]
        
        if recent > 0:
            dedup_penalty = 4
            factors["dedup"] = f"similar content found within {DEDUP_WINDOW_HOURS}h"
        
        conn.close()
    except Exception:
        pass  # DB not available, skip dedup
    factors["dedup_penalty"] = -dedup_penalty
    
    # Calculate total score
    # Source is base (0-9), boosted by novelty/emotion/entity/length
    total = (
        source_score +             # Base: 1-9 from source type
        novelty_score * 0.5 +      # Novelty boost: 0-2.5
        emotion_score * 0.5 +      # Emotion boost: 0-1
        entity_score * 0.5 +       # Entity boost: 0-1
        length_score * 0.3 -       # Length boost: 0-0.6
        suppression -              # Suppression penalty: 0-3
        dedup_penalty              # Dedup penalty: 0-4
    ) / 1.5  # Scale to 0-10 range
    
    total = round(max(0, min(10, total)), 1)
    
    # Determine action
    if total >= 6:
        action = "store"
        reason = "High attention score — worth remembering"
    elif total >= 4:
        action = "summarize"
        reason = "Medium attention — batch for later summary"
    else:
        action = "discard"
        reason = "Below attention threshold — routine noise"
    
    return {
        "score": total,
        "action": action,
        "reason": reason,
        "factors": factors,
        "source": source,
    }


def should_store(content: str, source: str = "manual", context: dict = None) -> dict:
    """Convenience wrapper — returns the attention assessment."""
    return calculate_attention_score(content, source, context)


def main():
    """CLI interface for testing attention filter."""
    if len(sys.argv) < 2 or sys.argv[1] in ("--help", "-h"):
        print("Usage: python3 attention.py <content> [--source <source>] [--json]")
        print(f"\nSources: {', '.join(sorted(SOURCE_WEIGHTS.keys()))}")
        print("\nActions: store (≥6), summarize (4-5), discard (<4)")
        return
    
    content = sys.argv[1]
    source = "manual"
    as_json = "--json" in sys.argv
    
    for i, arg in enumerate(sys.argv):
        if arg == "--source" and i + 1 < len(sys.argv):
            source = sys.argv[i + 1]
    
    result = should_store(content, source)
    
    if as_json:
        print(json.dumps(result, indent=2))
    else:
        action_icon = {"store": "✅", "summarize": "📋", "discard": "🗑️"}
        icon = action_icon.get(result["action"], "❓")
        print(f"{icon} {result['action'].upper()} (score: {result['score']})")
        print(f"   Reason: {result['reason']}")
        print(f"   Source: {result['source']} (weight: {result['factors'].get('source', '?')})")
        if result['factors'].get('novelty_keywords'):
            print(f"   Novelty: {', '.join(result['factors']['novelty_keywords'])}")
        if result['factors'].get('emotion_detected'):
            print(f"   Emotion: {result['factors']['emotion_detected']}")
        if result['factors'].get('suppressed_by'):
            print(f"   Suppressed: matched '{result['factors']['suppressed_by']}'")
        if result['factors'].get('dedup'):
            print(f"   Dedup: {result['factors']['dedup']}")


if __name__ == "__main__":
    main()


path: scripts/brain.sh
content: #!/usr/bin/env bash
# brain — Unified Cognitive Memory CLI
# Usage: brain <command> [args...]
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
BRAIN_DB="${BRAIN_DB:-$SCRIPT_DIR/brain.db}"

# Parse global --agent flag (can appear anywhere)
BRAIN_AGENT="${BRAIN_AGENT:-margot}"
ARGS=()
while [[ $# -gt 0 ]]; do
    case "$1" in
        --agent|-a) BRAIN_AGENT="$2"; shift 2 ;;
        *) ARGS+=("$1"); shift ;;
    esac
done
set -- "${ARGS[@]}"

CMD="${1:-help}"
shift || true

# Agent scope: see own + shared by default
AGENT_SCOPE="('$BRAIN_AGENT', 'shared')"

sql() {
    sqlite3 -header -column "$BRAIN_DB" "$@"
}

sql_csv() {
    sqlite3 -csv -header "$BRAIN_DB" "$@"
}

sql_plain() {
    sqlite3 "$BRAIN_DB" "$@"
}

# --- EPISODE commands ---

# --- SAFE DATABASE OPERATIONS (via Python — parameterized queries) ---
# All episode/recall/query operations route through db_ops.py
# to prevent SQL injection. See: Nygma audit 2026-03-15.

_db() {
    BRAIN_AGENT="$BRAIN_AGENT" BRAIN_DB="$BRAIN_DB" python3 "$SCRIPT_DIR/db_ops.py" "$@"
}

cmd_store() {
    _db store "$@"
}

cmd_recall() {
    _db recall "$@"
}

cmd_episodes() {
    _db episodes "$@"
}

cmd_emotions() {
    _db emotions "$@"
}

cmd_important() {
    _db important "$@"
}

cmd_stats() {
    _db stats "$@"
}

cmd_health() {
    BRAIN_AGENT="$BRAIN_AGENT" BRAIN_DB="$BRAIN_DB" python3 "$SCRIPT_DIR/erosion.py" "$@"
}

cmd_config() {
    local subcmd="${1:-show}"
    shift || true
    
    local conf_file="$SCRIPT_DIR/brain.conf"
    
    case "$subcmd" in
        show)
            echo "=== BRAIN LLM CONFIG ==="
            echo "Config file: $conf_file"
            echo ""
            if [[ -f "$conf_file" ]]; then
                grep -v '^#' "$conf_file" | grep -v '^$' | while IFS='=' read -r key val; do
                    key=$(echo "$key" | xargs)
                    val=$(echo "$val" | xargs)
                    if [[ "$key" == "key" && -n "$val" ]]; then
                        echo "  $key = ${val:0:8}..."
                    else
                        echo "  $key = $val"
                    fi
                done
            else
                echo "  (no config file — using defaults)"
            fi
            echo ""
            echo "Env overrides:"
            [[ -n "${BRAIN_LLM_URL:-}" ]] && echo "  BRAIN_LLM_URL=$BRAIN_LLM_URL" || echo "  BRAIN_LLM_URL=(not set)"
            [[ -n "${BRAIN_LLM_KEY:-}" ]] && echo "  BRAIN_LLM_KEY=***" || echo "  BRAIN_LLM_KEY=(not set)"
            [[ -n "${BRAIN_LLM_MODEL:-}" ]] && echo "  BRAIN_LLM_MODEL=$BRAIN_LLM_MODEL" || echo "  BRAIN_LLM_MODEL=(not set)"
            [[ -n "${GOOGLE_API_KEY:-}" ]] && echo "  GOOGLE_API_KEY=***" || echo "  GOOGLE_API_KEY=(not set)"
            ;;
        set)
            local key="${1:?Usage: brain config set <url|model|key> <value>}"
            local val="${2:?Usage: brain config set <url|model|key> <value>}"
            
            if [[ ! -f "$conf_file" ]]; then
                echo "# brain.conf" > "$conf_file"
            fi
            
            # Update or append the key
            if grep -q "^$key " "$conf_file" 2>/dev/null; then
                sed -i "s|^$key .*|$key = $val|" "$conf_file"
            else
                echo "$key = $val" >> "$conf_file"
            fi
            echo "✅ Set $key = $val"
            ;;
        help|--help|-h)
            cat <<CONFEOF
brain config — LLM Configuration

Commands:
  show                         Show current config + env overrides
  set <key> <value>            Update config file

Keys:
  url    — OpenAI-compatible chat completions endpoint
  model  — Model name for evolution
  key    — API key (or set via GOOGLE_API_KEY / BRAIN_LLM_KEY env)

Environment overrides (highest priority):
  BRAIN_LLM_URL, BRAIN_LLM_KEY, BRAIN_LLM_MODEL

Examples:
  brain config show
  brain config set model gemini-2.5-flash
  brain config set url http://localhost:11434/v1/chat/completions
  BRAIN_LLM_MODEL=gpt-4o brain proc evolve deploy-hga
CONFEOF
            ;;
        *)
            echo "Unknown config command: $subcmd"
            echo "Try: brain config help"
            exit 1
            ;;
    esac
}

cmd_filter() {
    local content="${1:?Usage: brain filter \"content\" [--source <source>] [--json]}"
    shift || true
    BRAIN_AGENT="$BRAIN_AGENT" BRAIN_DB="$BRAIN_DB" python3 "$SCRIPT_DIR/attention.py" "$content" "$@"
}

# --- WORKING MEMORY commands (Prefrontal Cortex) ---

cmd_wm() {
    local subcmd="${1:-show}"
    shift || true
    
    case "$subcmd" in
        show|add|clear|load|dump|sync)
            BRAIN_AGENT="$BRAIN_AGENT" BRAIN_DB="$BRAIN_DB" python3 "$SCRIPT_DIR/working_memory.py" "$subcmd" "$@"
            ;;
        help|--help|-h)
            cat <<WMEOF
brain wm — Working Memory (Prefrontal Cortex)

Capacity-limited active context with TTL expiry.
Syncs with SESSION-STATE.md for persistence across sessions.

Commands:
  show [--json]                                  Show active slots
  add "content" [--type T] [--ttl 4h] [--pri 5]  Add a slot (goal|context|task|note)
  clear [--expired]                              Clear all or just expired
  load                                           Load from SESSION-STATE.md
  dump                                           Write back to SESSION-STATE.md
  sync                                           Bidirectional sync

Examples:
  brain wm add "Fix LightRAG crash" --type task --priority 8 --ttl 8h
  brain wm add "Puddin' testing car mode" --type context --ttl 2h
  brain wm show
  brain wm sync
WMEOF
            ;;
        *)
            echo "Unknown wm command: $subcmd"
            echo "Try: brain wm help"
            exit 1
            ;;
    esac
}

# --- FACTS commands (Semantic Memory) ---

cmd_facts() {
    local subcmd="${1:-help}"
    shift || true
    
    case "$subcmd" in
        get|set|search|list|delete|stats)
            python3 "$SCRIPT_DIR/facts.py" "$subcmd" "$@"
            ;;
        help|--help|-h)
            cat <<FACTSEOF
brain facts — Structured Fact Storage (Semantic Memory)

Commands:
  get <entity> <key>                          Get a fact value
  set <entity> <key> <value> [--category C]   Set a fact (upsert)
  search <query> [--limit N] [--json]         Full-text search
  list [--entity E] [--category C] [--limit]  List facts
  delete <entity> <key>                       Delete a fact
  stats                                       Database statistics

Examples:
  brain facts get puddin favorite_team
  brain facts set Mae birthday "September 12" --category date --permanent
  brain facts search "SSH" --limit 5
  brain facts list --entity puddin --limit 10
FACTSEOF
            ;;
        *)
            echo "Unknown facts command: $subcmd"
            echo "Try: brain facts help"
            exit 1
            ;;
    esac
}

# --- PROCEDURE commands (Cerebellum) ---

cmd_proc() {
    local subcmd="${1:-help}"
    shift || true
    
    case "$subcmd" in
        list)
            BRAIN_AGENT="$BRAIN_AGENT" BRAIN_DB="$BRAIN_DB" python3 "$SCRIPT_DIR/evolve.py" list
            ;;
        show)
            BRAIN_AGENT="$BRAIN_AGENT" BRAIN_DB="$BRAIN_DB" python3 "$SCRIPT_DIR/evolve.py" show "$@"
            ;;
        create)
            BRAIN_AGENT="$BRAIN_AGENT" BRAIN_DB="$BRAIN_DB" python3 "$SCRIPT_DIR/evolve.py" create "$@"
            ;;
        success)
            BRAIN_AGENT="$BRAIN_AGENT" BRAIN_DB="$BRAIN_DB" python3 "$SCRIPT_DIR/evolve.py" success "$@"
            ;;
        fail)
            BRAIN_AGENT="$BRAIN_AGENT" BRAIN_DB="$BRAIN_DB" python3 "$SCRIPT_DIR/evolve.py" fail "$@"
            ;;
        evolve)
            BRAIN_AGENT="$BRAIN_AGENT" BRAIN_DB="$BRAIN_DB" python3 "$SCRIPT_DIR/evolve.py" evolve "$@"
            ;;
        history)
            BRAIN_AGENT="$BRAIN_AGENT" BRAIN_DB="$BRAIN_DB" python3 "$SCRIPT_DIR/evolve.py" history "$@"
            ;;
        help|--help|-h)
            cat <<PROCEOF
brain proc — Procedural Memory (Cerebellum)

Commands:
  list                                        List all procedures
  show <slug>                                 Show procedure details + steps
  create <slug> --title "T" --steps '["s1"]'  Create new procedure
  success <slug>                              Record successful execution
  fail <slug> --step N --error "desc" [--fix] Record failure at step N
  evolve <slug> [--dry-run]                   LLM-driven evolution from failures
  history <slug>                              View full evolution timeline

Examples:
  brain proc create deploy-api --title "Deploy API" --steps '["Pull latest","Run tests","Deploy"]'
  brain proc success deploy-api
  brain proc fail deploy-api --step 2 --error "Tests timed out" --fix "Increased timeout to 60s"
  brain proc evolve deploy-api --dry-run
  brain proc evolve deploy-api
  brain proc history deploy-api
PROCEOF
            ;;
        *)
            echo "Unknown proc command: $subcmd"
            echo "Try: brain proc help"
            exit 1
            ;;
    esac
}

cmd_ingest() {
    # Combined filter + store: only stores if attention score >= threshold
    local content="${1:?Usage: brain ingest \"content\" --title \"T\" [--source <source>] [--emotion E]}"
    local source="manual"
    local title="" emotion="" intensity="" importance=""
    
    # Parse all args
    local args=("$@")
    for ((i=0; i<${#args[@]}; i++)); do
        case "${args[$i]}" in
            --source) source="${args[$((i+1))]}"; ((i++)) ;;
            --title|-t) title="${args[$((i+1))]}"; ((i++)) ;;
            --emotion|-e) emotion="${args[$((i+1))]}"; ((i++)) ;;
            --intensity|-i) intensity="${args[$((i+1))]}"; ((i++)) ;;
            --importance) importance="${args[$((i+1))]}"; ((i++)) ;;
        esac
    done
    
    # Run attention filter
    local result
    result=$(BRAIN_AGENT="$BRAIN_AGENT" BRAIN_DB="$BRAIN_DB" python3 "$(dirname "$(readlink -f "$0")")/attention.py" "$content" --source "$source" --json)
    local action score
    action=$(echo "$result" | python3 -c "import json,sys; print(json.load(sys.stdin)['action'])")
    score=$(echo "$result" | python3 -c "import json,sys; print(json.load(sys.stdin)['score'])")
    
    case "$action" in
        store)
            # Use attention score as importance if not explicitly set
            [[ -z "$importance" ]] && importance=$(echo "$score" | python3 -c "import sys; print(min(10, int(float(sys.stdin.read().strip()) + 0.5)))")
            local store_args=("$content")
            [[ -n "$title" ]] && store_args+=(--title "$title")
            [[ -n "$emotion" ]] && store_args+=(--emotion "$emotion")
            [[ -n "$intensity" ]] && store_args+=(--intensity "$intensity")
            store_args+=(--importance "$importance")
            cmd_store "${store_args[@]}"
            ;;
        summarize)
            echo "📋 BATCHED (score: $score) — below store threshold, queued for summary"
            ;;
        discard)
            echo "🗑️ DISCARDED (score: $score) — routine noise, not stored"
            ;;
    esac
}

cmd_consolidate() {
    local extra_args=""
    [[ "${1:-}" == "--dry-run" ]] && extra_args="--dry-run"
    [[ "${1:-}" == "-v" || "${2:-}" == "-v" ]] && extra_args="$extra_args --verbose"
    BRAIN_AGENT="$BRAIN_AGENT" BRAIN_DB="$BRAIN_DB" python3 "$(dirname "$(readlink -f "$0")")/consolidate.py" $extra_args
}

cmd_who() {
    _db who "$@"
}

cmd_help() {
    cat <<EOF
brain — Unified Cognitive Memory System

GLOBAL FLAGS:
  --agent <name>               Set agent identity (default: margot, env: BRAIN_AGENT)

STORE & RECALL:
  store "content" --title "T" [--emotion E] [--importance N]   Store an episode (bypasses filter)
  ingest "content" --title "T" [--source S] [--emotion E]      Filter + store (attention-gated)
  recall <query> [--type episodic|fact|proc|all] [--limit N]   Search all memory

ATTENTION:
  filter "content" [--source <source>]   Test attention score (store/summarize/discard)
  
QUERY:
  episodes [date]              Show episodes for a date (default: today)
  emotions [days]              Show emotional timeline (default: 7 days)
  important [threshold] [days] Show high-importance episodes

MULTI-AGENT:
  who                          Show all agents and their memory counts

CONSOLIDATION:
  consolidate [--dry-run] [-v]   Run sleep replay (hippocampal consolidation)

WORKING MEMORY (Prefrontal Cortex):
  wm show [--json]                                Show active slots
  wm add "content" [--type T] [--ttl 4h]          Add slot (goal|context|task|note)
  wm clear [--expired]                            Clear all or expired
  wm load                                         Load from SESSION-STATE.md
  wm dump                                         Write back to SESSION-STATE.md
  wm sync                                         Bidirectional sync

SEMANTIC MEMORY (Neocortex):
  facts get <entity> <key>                        Get a fact value
  facts set <entity> <key> <value> [--category]   Set a fact (upsert)
  facts search <query> [--limit N] [--json]       Full-text search
  facts list [--entity E] [--category C]          List facts
  facts delete <entity> <key>                     Delete a fact
  facts stats                                     Database statistics

PROCEDURES (Cerebellum):
  proc list                                       List all procedures
  proc show <slug>                                Show procedure + steps
  proc create <slug> --title "T" --steps '[...]'  Create procedure
  proc success <slug>                             Record success
  proc fail <slug> --step N --error "desc"        Record failure
  proc evolve <slug> [--dry-run]                  LLM-driven evolution from failures
  proc history <slug>                             View evolution timeline

CONFIG:
  config show                  Show LLM config + env overrides
  config set <key> <value>     Update LLM config (url, model, key)

SYSTEM:
  stats                        Overview statistics
  health [-v] [--json]         Soul erosion detection (7 metrics, scored 1-10)
  
Examples:
  brain store "Fixed LightRAG" --title "LightRAG Fix" --emotion focused --importance 8
  brain recall "car mode bluetooth"
  brain --agent bud store "GPU temp normal" --title "GPU Check" --importance 3
  brain proc fail deploy-hga --step 3 --error "SSH refused" --fix "Check addon first"
  brain proc evolve deploy-hga
  brain who
  brain episodes 2026-03-14
EOF
}

# Dispatch
case "$CMD" in
    store)      cmd_store "$@" ;;
    ingest)     cmd_ingest "$@" ;;
    filter)     cmd_filter "$@" ;;
    recall)     cmd_recall "$@" ;;
    episodes)   cmd_episodes "$@" ;;
    emotions)   cmd_emotions "$@" ;;
    important)  cmd_important "$@" ;;
    consolidate) cmd_consolidate "$@" ;;
    config)     cmd_config "$@" ;;
    wm)         cmd_wm "$@" ;;
    facts)      cmd_facts "$@" ;;
    proc)       cmd_proc "$@" ;;
    who)        cmd_who "$@" ;;
    stats)      cmd_stats "$@" ;;
    health)     cmd_health "$@" ;;
    help|--help|-h) cmd_help ;;
    *)          echo "Unknown command: $CMD"; cmd_help; exit 1 ;;
esac


path: scripts/consolidate.py
content: #!/usr/bin/env python3
"""
Brain Consolidation Pipeline — "Sleep Replay"

Mimics hippocampal consolidation during sleep:
1. Score unconsolidated episodes by importance, emotion, novelty, rehearsal
2. High-scoring episodes get facts extracted and stored in semantic memory
3. Mark episodes as consolidated
4. Log all consolidation actions
5. Optionally forget (archive) low-value old episodes

Designed to run as a nightly cron or on-demand via `brain consolidate`.
"""
import sqlite3
import json
import os
import sys
import re
from datetime import datetime, timedelta
from pathlib import Path

BRAIN_DB = os.environ.get("BRAIN_DB", str(Path(__file__).parent / "brain.db"))
DRY_RUN = "--dry-run" in sys.argv
VERBOSE = "--verbose" in sys.argv or "-v" in sys.argv
AGENT = os.environ.get("BRAIN_AGENT", "margot")

# Consolidation thresholds
CONSOLIDATION_SCORE_THRESHOLD = 4.0  # Minimum score to consolidate (importance ~7+ or 5+ with emotion)
FORGET_AGE_DAYS = 90                  # Archive episodes older than this (Nygma: 30 was too aggressive)
FORGET_IMPORTANCE_MAX = 3             # Only forget low-importance episodes
MAX_CONSOLIDATE_PER_RUN = 50          # Don't process too many at once


def score_episode(ep: dict) -> float:
    """
    Score an episode for consolidation priority.
    Mimics amygdala (emotion) + hippocampal (novelty/rehearsal) weighting.
    """
    importance = ep.get("importance", 5) or 5
    novelty = ep.get("novelty", 5) or 5
    rehearsal = ep.get("rehearsal_count", 0) or 0
    
    # Emotional weight
    emotion_score = 0
    intensity = ep.get("emotion_intensity", "")
    if intensity == "high":
        emotion_score = 3
    elif intensity == "medium":
        emotion_score = 2
    elif intensity == "low":
        emotion_score = 1
    elif ep.get("emotion"):  # Has emotion but no intensity
        emotion_score = 1.5
    
    # Weighted score — target range 0-10, threshold 5.0
    # importance=7 + no emotion should be ~5.5 (consolidatable)
    # importance=4 + no emotion should be ~3.5 (skip)
    score = (
        importance * 0.6 +          # Importance (1-10, biggest factor) → 0.6-6.0
        emotion_score * 0.8 +       # Amygdala: emotional weight (0-3) → 0-2.4
        min(rehearsal, 5) * 0.2 +   # Rehearsal (capped at 5) → 0-1.0
        (novelty / 10) * 0.6        # Novelty (1-10) → 0.06-0.6
    )
    
    return round(score, 2)


def extract_facts_from_episode(ep: dict) -> list[dict]:
    """
    Extract factual knowledge from an episode's content.
    Returns list of {entity, key, value} dicts.
    
    This is a rule-based extractor. For production, could use LLM.
    """
    facts = []
    content = ep.get("content", "")
    title = ep.get("title", "")
    
    # Pattern: "Root cause: ..." or "Root cause was ..."
    root_cause = re.search(r'[Rr]oot cause[:\s]+(.+?)(?:\.|$)', content)
    if root_cause:
        facts.append({
            "entity": _extract_entity(title),
            "key": "root_cause",
            "value": root_cause.group(1).strip()[:500]
        })
    
    # Pattern: "Lesson: ..." or "Key lesson: ..."
    lesson = re.search(r'[Ll]esson[:\s]+(.+?)(?:\.|$)', content)
    if lesson:
        facts.append({
            "entity": _extract_entity(title),
            "key": "lesson_learned",
            "value": lesson.group(1).strip()[:500]
        })
    
    # Pattern: "Fix: ..." or "Fixed by ..."
    fix = re.search(r'[Ff]ix(?:ed)?(?:\s+by)?[:\s]+(.+?)(?:\.|$)', content)
    if fix:
        facts.append({
            "entity": _extract_entity(title),
            "key": "fix_applied",
            "value": fix.group(1).strip()[:500]
        })
    
    # Pattern: URLs/IPs
    urls = re.findall(r'https?://\S+', content)
    for url in urls[:3]:  # Cap at 3
        facts.append({
            "entity": _extract_entity(title),
            "key": "related_url",
            "value": url
        })
    
    # Pattern: "Version X.Y.Z" or "vX.Y.Z"
    version = re.search(r'(?:version|v)(\d+\.\d+(?:\.\d+)?)', content, re.I)
    if version:
        facts.append({
            "entity": _extract_entity(title),
            "key": "version",
            "value": version.group(1)
        })
    
    return facts


def _extract_entity(title: str) -> str:
    """Extract a reasonable entity name from episode title."""
    # Remove common prefixes
    title = re.sub(r'^(Nightly Build|Security Audit|Heartbeat|Fleet Alert)[:\s-]*', '', title)
    # Take first meaningful word(s)
    words = title.split()[:3]
    return "_".join(w.strip(":-—()[]") for w in words if w.strip(":-—()[]"))[:50] or "general"


def consolidate(conn: sqlite3.Connection):
    """Main consolidation pipeline."""
    now = datetime.now(tz=None).isoformat()
    
    # 1. Get unconsolidated episodes
    episodes = conn.execute("""
        SELECT id, date, time, title, content, emotion, emotion_intensity,
               importance, novelty, rehearsal_count, agent
        FROM episodes
        WHERE consolidated = 0
        AND agent = ?
        ORDER BY importance DESC, date DESC
        LIMIT ?
    """, (AGENT, MAX_CONSOLIDATE_PER_RUN)).fetchall()
    
    columns = ["id", "date", "time", "title", "content", "emotion", 
               "emotion_intensity", "importance", "novelty", "rehearsal_count", "agent"]
    
    consolidated = 0
    facts_created = 0
    skipped = 0
    archived = 0
    
    print(f"=== CONSOLIDATION RUN ({AGENT}) ===")
    print(f"Unconsolidated episodes: {len(episodes)}")
    print(f"Threshold: {CONSOLIDATION_SCORE_THRESHOLD}")
    print(f"Mode: {'DRY RUN' if DRY_RUN else 'LIVE'}")
    print()
    
    for row in episodes:
        ep = dict(zip(columns, row))
        score = score_episode(ep)
        
        if score < CONSOLIDATION_SCORE_THRESHOLD:
            if VERBOSE:
                print(f"  SKIP [{score:.1f}] {ep['date']} {ep['title'][:50]}")
            skipped += 1
            continue
        
        print(f"  CONSOLIDATE [{score:.1f}] {ep['date']} {ep['title'][:50]}")
        
        # Extract facts
        facts = extract_facts_from_episode(ep)
        for fact in facts:
            if VERBOSE:
                print(f"    → fact: {fact['entity']}.{fact['key']} = {fact['value'][:60]}")
            
            if not DRY_RUN:
                # Upsert — update if exists, insert if new
                existing = conn.execute(
                    "SELECT id, updated_at FROM facts WHERE entity=? AND key=? AND agent=?",
                    (fact["entity"], fact["key"], AGENT)
                ).fetchone()
                
                if existing:
                    # Optimistic locking: only update if fact hasn't been modified
                    # since we read it (prevents overwriting user edits during consolidation)
                    result = conn.execute("""
                        UPDATE facts SET value=?, times_confirmed=times_confirmed+1,
                               last_confirmed=?, updated_at=?
                        WHERE id=? AND updated_at=?
                    """, (fact["value"], now, now, existing["id"], existing["updated_at"]))
                    if result.rowcount == 0:
                        if VERBOSE:
                            print(f"    ⚠️ Conflict: {fact['entity']}.{fact['key']} was modified concurrently — skipping")
                        continue
                else:
                    conn.execute("""
                        INSERT INTO facts (entity, key, value, source_episode_id, agent, created_at)
                        VALUES (?, ?, ?, ?, ?, ?)
                    """, (fact["entity"], fact["key"], fact["value"], ep["id"], AGENT, now))
                facts_created += 1
        
        # Mark as consolidated
        if not DRY_RUN:
            conn.execute("""
                UPDATE episodes SET consolidated=1, consolidated_at=? WHERE id=?
            """, (now, ep["id"]))
            
            # Log the consolidation
            conn.execute("""
                INSERT INTO consolidation_log (source_type, source_id, action, destination, reason, agent, occurred_at)
                VALUES ('episode', ?, 'consolidated', 'facts', ?, ?, ?)
            """, (ep["id"], f"score={score:.1f}, {len(facts)} facts extracted", AGENT, now))
        
        consolidated += 1
    
    # 2. Forgetting — archive old low-value episodes
    cutoff = (datetime.now(tz=None) - timedelta(days=FORGET_AGE_DAYS)).strftime("%Y-%m-%d")
    forgettable = conn.execute("""
        SELECT id, date, title, importance FROM episodes
        WHERE date < ? AND importance <= ? AND consolidated = 1 AND agent = ?
    """, (cutoff, FORGET_IMPORTANCE_MAX, AGENT)).fetchall()
    
    if forgettable:
        print(f"\n  FORGETTING: {len(forgettable)} old low-value episodes (>{FORGET_AGE_DAYS} days, importance ≤{FORGET_IMPORTANCE_MAX})")
        for fid, fdate, ftitle, fimp in forgettable:
            if VERBOSE:
                print(f"    → archive: {fdate} {ftitle[:50]} (imp: {fimp})")
            if not DRY_RUN:
                conn.execute("""
                    INSERT INTO consolidation_log (source_type, source_id, action, reason, agent, occurred_at)
                    VALUES ('episode', ?, 'archived', 'low importance + old', ?, ?)
                """, (fid, AGENT, now))
                # Don't delete — just mark. Could add an 'archived' column later.
            archived += 1
    
    if not DRY_RUN:
        conn.commit()
    
    # Summary
    print(f"\n=== CONSOLIDATION SUMMARY ===")
    print(f"  Consolidated: {consolidated}")
    print(f"  Facts created/updated: {facts_created}")
    print(f"  Skipped (below threshold): {skipped}")
    print(f"  Archived (forgotten): {archived}")
    
    # Remaining
    remaining = conn.execute(
        "SELECT COUNT(*) FROM episodes WHERE consolidated=0 AND agent=?", (AGENT,)
    ).fetchone()[0]
    print(f"  Remaining unconsolidated: {remaining}")


def main():
    conn = sqlite3.connect(BRAIN_DB)
    try:
        consolidate(conn)
    finally:
        conn.close()


if __name__ == "__main__":
    main()


path: scripts/db_ops.py
content: #!/usr/bin/env python3
"""
brain db_ops — Safe parameterized database operations
Replaces bash SQL string interpolation with Python prepared statements.
Fixes: SQL injection vulnerability (Nygma audit 2026-03-15)
"""

import json
import os
import sqlite3
import sys
from datetime import datetime

BRAIN_DB = os.environ.get("BRAIN_DB", os.path.join(os.path.dirname(__file__), "brain.db"))
BRAIN_AGENT = os.environ.get("BRAIN_AGENT", "margot")


def get_db():
    conn = sqlite3.connect(BRAIN_DB)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA foreign_keys=ON")
    return conn


def agent_scope():
    return (BRAIN_AGENT, 'shared')


# ============================================================
# EPISODE OPERATIONS
# ============================================================

def cmd_store(args):
    """Store an episode with parameterized INSERT."""
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument('content')
    parser.add_argument('--title', '-t', default=None)
    parser.add_argument('--emotion', '-e', default=None)
    parser.add_argument('--intensity', '-i', default=None)
    parser.add_argument('--importance', type=int, default=5)
    parser.add_argument('--agent', default=BRAIN_AGENT)
    parser.add_argument('--source', default='manual')
    opts = parser.parse_args(args)

    title = opts.title or opts.content[:80]
    date = datetime.now().strftime("%Y-%m-%d")
    time_val = datetime.now().strftime("%H:%M")

    db = get_db()
    cursor = db.execute(
        """INSERT INTO episodes (date, time, title, content, emotion, emotion_intensity,
           importance, source, agent)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
        (date, time_val, title, opts.content, opts.emotion, opts.intensity,
         opts.importance, opts.source, opts.agent)
    )
    db.commit()
    ep_id = cursor.lastrowid
    db.close()
    print(f"✅ Stored episode #{ep_id}: {title}")


def cmd_recall(args):
    """Search all memory with parameterized FTS queries."""
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument('query')
    parser.add_argument('--type', dest='search_type', default='all')
    parser.add_argument('--limit', type=int, default=10)
    opts = parser.parse_args(args)

    db = get_db()
    scope = agent_scope()

    # Sanitize FTS query — escape special chars
    safe_query = sanitize_fts(opts.query)

    if opts.search_type in ('all', 'episodic'):
        print("=== EPISODES ===")
        rows = db.execute(
            """SELECT e.date, e.time, e.title, e.importance, e.emotion, e.agent
               FROM episodes e
               JOIN episodes_fts f ON e.id = f.rowid
               WHERE episodes_fts MATCH ?
               AND e.agent IN (?, ?)
               ORDER BY e.importance DESC, e.date DESC
               LIMIT ?""",
            (safe_query, scope[0], scope[1], opts.limit)
        ).fetchall()
        for r in rows:
            print(f"  {r['date']} {r['time'] or ''} [{r['importance']}] {r['title'][:60]}  ({r['agent']})")
        if not rows:
            print("  (no matches)")
        print()

    if opts.search_type in ('all', 'fact'):
        print("=== FACTS ===")
        try:
            rows = db.execute(
                """SELECT f.entity, f.key, f.value, f.agent
                   FROM facts f
                   JOIN facts_fts ff ON f.id = ff.rowid
                   WHERE facts_fts MATCH ?
                   AND f.agent IN (?, ?)
                   LIMIT ?""",
                (safe_query, scope[0], scope[1], opts.limit)
            ).fetchall()
            for r in rows:
                print(f"  {r['entity']}.{r['key']} = {r['value'][:60]}  ({r['agent']})")
            if not rows:
                print("  (no matches)")
        except Exception:
            print("  (no facts or no matches)")
        print()

    if opts.search_type in ('all', 'proc'):
        print("=== PROCEDURES ===")
        try:
            rows = db.execute(
                """SELECT slug, title, version, agent,
                          success_count || '/' || (success_count + failure_count) as success_rate
                   FROM procedures
                   WHERE (title LIKE ? OR slug LIKE ? OR tags LIKE ?)
                   AND agent IN (?, ?)
                   LIMIT ?""",
                (f"%{opts.query}%", f"%{opts.query}%", f"%{opts.query}%",
                 scope[0], scope[1], opts.limit)
            ).fetchall()
            for r in rows:
                print(f"  {r['slug']} v{r['version']} ({r['success_rate']})  {r['title']}  ({r['agent']})")
            if not rows:
                print("  (no matches)")
        except Exception:
            print("  (no procedures or no matches)")

    db.close()


def cmd_episodes(args):
    """Show episodes for a date."""
    date = args[0] if args else datetime.now().strftime("%Y-%m-%d")
    scope = agent_scope()
    db = get_db()
    rows = db.execute(
        """SELECT time, title, importance, emotion, agent 
           FROM episodes WHERE date=? AND agent IN (?, ?)
           ORDER BY time""",
        (date, scope[0], scope[1])
    ).fetchall()
    db.close()

    if not rows:
        print(f"No episodes for {date}")
        return
    print(f"{'TIME':<6} {'IMP':>3} {'EMOTION':<12} {'AGENT':<8} TITLE")
    print("─" * 70)
    for r in rows:
        print(f"{r['time'] or '??:??':<6} {r['importance']:>3} {(r['emotion'] or '')::<12} {r['agent']:<8} {r['title'][:40]}")


def cmd_emotions(args):
    """Show emotional timeline."""
    period = int(args[0]) if args else 7
    scope = agent_scope()
    db = get_db()
    rows = db.execute(
        """SELECT date, time, title, emotion, emotion_intensity, agent
           FROM episodes 
           WHERE emotion IS NOT NULL 
           AND date >= date('now', ?)
           AND agent IN (?, ?)
           ORDER BY date DESC, time DESC""",
        (f"-{period} days", scope[0], scope[1])
    ).fetchall()
    db.close()

    if not rows:
        print(f"No emotional episodes in last {period} days")
        return
    for r in rows:
        intensity = f"/{r['emotion_intensity']}" if r['emotion_intensity'] else ""
        print(f"  {r['date']} {r['time'] or ''} [{r['emotion']}{intensity}] {r['title'][:50]}  ({r['agent']})")


def cmd_important(args):
    """Show high-importance episodes."""
    threshold = int(args[0]) if len(args) > 0 else 8
    days = int(args[1]) if len(args) > 1 else 7
    db = get_db()
    rows = db.execute(
        """SELECT date, title, importance, emotion
           FROM episodes
           WHERE importance >= ?
           AND date >= date('now', ?)
           ORDER BY importance DESC, date DESC""",
        (threshold, f"-{days} days")
    ).fetchall()
    db.close()

    if not rows:
        print(f"No episodes with importance >= {threshold} in last {days} days")
        return
    for r in rows:
        emotion = f" [{r['emotion']}]" if r['emotion'] else ""
        print(f"  {r['date']} [{r['importance']}] {r['title'][:60]}{emotion}")


def cmd_stats():
    """Overview statistics."""
    db = get_db()
    print("=== BRAIN STATS ===")

    total = db.execute("SELECT COUNT(*) FROM episodes").fetchone()[0]
    print(f"Episodes: {total}")

    row = db.execute("SELECT MIN(date) as mn, MAX(date) as mx FROM episodes").fetchone()
    print(f"Date range: {row['mn']} → {row['mx']}")

    emo = db.execute("SELECT COUNT(*) FROM episodes WHERE emotion IS NOT NULL").fetchone()[0]
    print(f"With emotions: {emo}")

    high = db.execute("SELECT COUNT(*) FROM episodes WHERE importance >= 7").fetchone()[0]
    print(f"High importance (≥7): {high}")

    cons = db.execute("SELECT COUNT(*) FROM episodes WHERE consolidated = 1").fetchone()[0]
    print(f"Consolidated: {cons}")

    facts = db.execute("SELECT COUNT(*) FROM facts").fetchone()[0]
    print(f"Facts: {facts}")

    procs = db.execute("SELECT COUNT(*) FROM procedures").fetchone()[0]
    print(f"Procedures: {procs}")

    print("\n=== EMOTION DISTRIBUTION ===")
    rows = db.execute(
        "SELECT emotion, COUNT(*) as count FROM episodes WHERE emotion IS NOT NULL GROUP BY emotion ORDER BY count DESC LIMIT 10"
    ).fetchall()
    for r in rows:
        print(f"  {r['emotion']}: {r['count']}")

    print("\n=== EPISODES PER DAY ===")
    rows = db.execute(
        """SELECT date, COUNT(*) as episodes, ROUND(AVG(importance), 1) as avg_importance
           FROM episodes GROUP BY date ORDER BY date DESC LIMIT 14"""
    ).fetchall()
    for r in rows:
        print(f"  {r['date']}: {r['episodes']} episodes (avg importance: {r['avg_importance']})")

    db.close()


def cmd_who():
    """Show all agents in the brain."""
    db = get_db()
    print("=== AGENTS IN BRAIN ===")
    
    print("\nEpisodes:")
    for r in db.execute("SELECT agent, COUNT(*) as c FROM episodes GROUP BY agent ORDER BY c DESC").fetchall():
        print(f"  {r['agent']}: {r['c']}")
    
    print("\nFacts:")
    for r in db.execute("SELECT agent, COUNT(*) as c FROM facts GROUP BY agent ORDER BY c DESC").fetchall():
        print(f"  {r['agent']}: {r['c']}")

    print("\nProcedures:")
    for r in db.execute("SELECT agent, COUNT(*) as c FROM procedures GROUP BY agent ORDER BY c DESC").fetchall():
        print(f"  {r['agent']}: {r['c']}")

    print(f"\nCurrent agent: {BRAIN_AGENT}")
    print("Scope: own + shared")
    db.close()


# ============================================================
# UTILITIES
# ============================================================

def sanitize_fts(query):
    """Sanitize a query for FTS5 MATCH — escape special characters."""
    # FTS5 special chars: AND OR NOT ( ) * " ^
    # Wrap each word in double quotes to treat as literals
    words = query.split()
    safe_words = []
    for w in words:
        # Remove any existing quotes
        w = w.replace('"', '')
        if w.upper() in ('AND', 'OR', 'NOT'):
            safe_words.append(f'"{w}"')  # Quote reserved words
        elif any(c in w for c in '()*^'):
            safe_words.append(f'"{w}"')  # Quote special chars
        else:
            safe_words.append(w)
    return ' '.join(safe_words)


# ============================================================
# DISPATCH
# ============================================================

COMMANDS = {
    'store': cmd_store,
    'recall': cmd_recall,
    'episodes': cmd_episodes,
    'emotions': cmd_emotions,
    'important': cmd_important,
    'stats': cmd_stats,
    'who': cmd_who,
}

def main():
    if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
        print(f"Usage: db_ops.py <{'|'.join(COMMANDS.keys())}> [args...]")
        sys.exit(1)
    
    cmd = sys.argv[1]
    args = sys.argv[2:]
    
    if cmd in ('store', 'recall'):
        COMMANDS[cmd](args)
    elif cmd in ('episodes', 'emotions', 'important'):
        COMMANDS[cmd](args)
    elif cmd in ('stats', 'who'):
        COMMANDS[cmd]()


if __name__ == "__main__":
    main()


path: scripts/erosion.py
content: #!/usr/bin/env python3
"""
brain health — Soul Erosion Detection & Memory Health Metrics

Detects qualitative degradation in the cognitive memory system:
- Conflicting facts (semantic contradictions)
- Emotional flatness (loss of feeling range)
- Importance inflation (everything rated 8+)
- Memory staleness (no new episodes for days)
- Consolidation debt (backlog growing faster than processing)
- Procedure decay (success rates dropping)
- Identity drift (SOUL.md changes without episode correlation)

Each metric produces a 0-10 health score. Overall score is weighted average.
"""

import json
import math
import os
import sqlite3
import sys
from collections import Counter
from datetime import datetime, timedelta

BRAIN_DB = os.environ.get("BRAIN_DB", os.path.join(os.path.dirname(__file__), "brain.db"))
FACTS_DB = os.environ.get("BRAIN_FACTS_DB", os.path.join(os.path.dirname(__file__), "..", "facts.db"))
BRAIN_AGENT = os.environ.get("BRAIN_AGENT", "margot")

# Thresholds
STALENESS_WARN_HOURS = 24
STALENESS_CRIT_HOURS = 72
CONSOLIDATION_BACKLOG_WARN = 30
CONSOLIDATION_BACKLOG_CRIT = 60
IMPORTANCE_INFLATION_THRESHOLD = 7.5  # avg importance above this = inflated
EMOTION_DIVERSITY_MIN = 4  # unique emotions in last 7 days
PROCEDURE_DECAY_THRESHOLD = 0.5  # success rate below this = decaying


def get_brain_db():
    conn = sqlite3.connect(BRAIN_DB)
    conn.row_factory = sqlite3.Row
    return conn


def get_facts_db():
    if not os.path.exists(FACTS_DB):
        return None
    conn = sqlite3.connect(FACTS_DB)
    conn.row_factory = sqlite3.Row
    return conn


# ============================================================
# METRIC: Memory Staleness
# Are we still recording experiences?
# ============================================================
def check_staleness(db):
    """Check how fresh the most recent episode is."""
    row = db.execute(
        "SELECT MAX(date || ' ' || COALESCE(time, '00:00')) as latest FROM episodes WHERE agent IN (?, 'shared')",
        (BRAIN_AGENT,)
    ).fetchone()
    
    if not row or not row["latest"]:
        return {"score": 0, "label": "CRITICAL", "detail": "No episodes found at all", "icon": "💀"}
    
    try:
        latest = datetime.strptime(row["latest"].strip(), "%Y-%m-%d %H:%M")
    except ValueError:
        try:
            latest = datetime.strptime(row["latest"].strip()[:10], "%Y-%m-%d")
        except ValueError:
            return {"score": 5, "label": "UNKNOWN", "detail": f"Can't parse date: {row['latest']}", "icon": "❓"}
    
    hours_ago = (datetime.now() - latest).total_seconds() / 3600
    
    if hours_ago < 6:
        return {"score": 10, "label": "FRESH", "detail": f"Last episode {hours_ago:.1f}h ago", "icon": "🟢"}
    elif hours_ago < STALENESS_WARN_HOURS:
        return {"score": 8, "label": "OK", "detail": f"Last episode {hours_ago:.1f}h ago", "icon": "🟢"}
    elif hours_ago < STALENESS_CRIT_HOURS:
        score = max(3, 8 - int((hours_ago - STALENESS_WARN_HOURS) / 12))
        return {"score": score, "label": "STALE", "detail": f"Last episode {hours_ago:.0f}h ago — memories fading", "icon": "🟡"}
    else:
        return {"score": 1, "label": "CRITICAL", "detail": f"Last episode {hours_ago:.0f}h ago — severe memory gap", "icon": "🔴"}


# ============================================================
# METRIC: Consolidation Debt
# Is the hippocampus keeping up with experience intake?
# ============================================================
def check_consolidation(db):
    """Check ratio of unconsolidated to total episodes."""
    row = db.execute(
        "SELECT COUNT(*) as total, SUM(CASE WHEN consolidated=0 THEN 1 ELSE 0 END) as pending FROM episodes"
    ).fetchone()
    
    total = row["total"]
    pending = row["pending"]
    
    if total == 0:
        return {"score": 10, "label": "EMPTY", "detail": "No episodes to consolidate", "icon": "⚪"}
    
    ratio = pending / total
    high_imp_pending = db.execute(
        "SELECT COUNT(*) as c FROM episodes WHERE consolidated=0 AND importance >= 8"
    ).fetchone()["c"]
    
    if pending <= 10:
        score = 10
        label = "CLEAR"
        icon = "🟢"
    elif pending <= CONSOLIDATION_BACKLOG_WARN:
        score = 7
        label = "MANAGEABLE"
        icon = "🟢"
    elif pending <= CONSOLIDATION_BACKLOG_CRIT:
        score = 4
        label = "BUILDING"
        icon = "🟡"
    else:
        score = 2
        label = "CRITICAL"
        icon = "🔴"
    
    detail = f"{pending}/{total} unconsolidated ({ratio:.0%})"
    if high_imp_pending > 5:
        detail += f" — ⚠️ {high_imp_pending} high-importance episodes at risk"
        score = max(1, score - 2)
    
    return {"score": score, "label": label, "detail": detail, "icon": icon}


# ============================================================
# METRIC: Importance Inflation
# Is everything being rated as important? That means nothing is.
# ============================================================
def check_importance_inflation(db):
    """Check if importance scores have lost dynamic range."""
    rows = db.execute(
        "SELECT importance FROM episodes WHERE date >= date('now', '-7 days')"
    ).fetchall()
    
    if len(rows) < 5:
        return {"score": 8, "label": "INSUFFICIENT DATA", "detail": f"Only {len(rows)} episodes in 7 days", "icon": "⚪"}
    
    importances = [r["importance"] for r in rows]
    avg = sum(importances) / len(importances)
    std_dev = math.sqrt(sum((x - avg) ** 2 for x in importances) / len(importances))
    
    # Check for clustering at top
    high_count = sum(1 for x in importances if x >= 8)
    high_ratio = high_count / len(importances)
    
    # Score: we want avg around 5-6 with std_dev > 2
    issues = []
    score = 10
    
    if avg > IMPORTANCE_INFLATION_THRESHOLD:
        score -= int((avg - IMPORTANCE_INFLATION_THRESHOLD) * 2)
        issues.append(f"avg={avg:.1f} (inflated)")
    
    if std_dev < 1.5:
        score -= 2
        issues.append(f"σ={std_dev:.1f} (flat — no differentiation)")
    
    if high_ratio > 0.6:
        score -= 2
        issues.append(f"{high_ratio:.0%} rated 8+ (if everything is important, nothing is)")
    
    score = max(1, min(10, score))
    
    if score >= 8:
        return {"score": score, "label": "HEALTHY", "detail": f"avg={avg:.1f}, σ={std_dev:.1f}, {high_ratio:.0%} high", "icon": "🟢"}
    elif score >= 5:
        return {"score": score, "label": "INFLATED", "detail": "; ".join(issues), "icon": "🟡"}
    else:
        return {"score": score, "label": "CRITICAL", "detail": "; ".join(issues), "icon": "🔴"}


# ============================================================
# METRIC: Emotional Diversity
# Are we experiencing a range of feelings or flatlined?
# ============================================================
def check_emotional_diversity(db):
    """Check variety and presence of emotional tags."""
    rows = db.execute(
        """SELECT emotion, emotion_intensity, COUNT(*) as c 
           FROM episodes 
           WHERE date >= date('now', '-7 days') AND emotion IS NOT NULL
           GROUP BY emotion"""
    ).fetchall()
    
    total_recent = db.execute(
        "SELECT COUNT(*) as c FROM episodes WHERE date >= date('now', '-7 days')"
    ).fetchone()["c"]
    
    tagged = sum(r["c"] for r in rows)
    unique_emotions = len(rows)
    
    if total_recent == 0:
        return {"score": 5, "label": "NO DATA", "detail": "No recent episodes", "icon": "⚪"}
    
    tag_ratio = tagged / total_recent
    
    issues = []
    score = 10
    
    if tag_ratio < 0.3:
        score -= 3
        issues.append(f"only {tag_ratio:.0%} episodes tagged with emotions")
    
    if unique_emotions < EMOTION_DIVERSITY_MIN:
        score -= 3
        issues.append(f"only {unique_emotions} unique emotions (need ≥{EMOTION_DIVERSITY_MIN})")
    
    # Check for single dominant emotion
    if rows:
        top_emotion = max(rows, key=lambda r: r["c"])
        if tagged > 3 and top_emotion["c"] / tagged > 0.5:
            score -= 2
            issues.append(f"'{top_emotion['emotion']}' dominates at {top_emotion['c']}/{tagged}")
    
    score = max(1, min(10, score))
    emotions_list = ", ".join(f"{r['emotion']}({r['c']})" for r in sorted(rows, key=lambda r: -r["c"])[:5])
    
    if score >= 8:
        return {"score": score, "label": "DIVERSE", "detail": f"{unique_emotions} emotions, {tag_ratio:.0%} tagged — {emotions_list}", "icon": "🟢"}
    elif score >= 5:
        return {"score": score, "label": "NARROW", "detail": "; ".join(issues), "icon": "🟡"}
    else:
        return {"score": score, "label": "FLAT", "detail": "; ".join(issues), "icon": "🔴"}


# ============================================================
# METRIC: Fact Conflicts
# Do we hold contradictory beliefs?
# ============================================================
def check_fact_conflicts(facts_db):
    """Detect duplicate entity+key patterns that suggest conflicts."""
    if not facts_db:
        return {"score": 8, "label": "N/A", "detail": "Facts DB not available", "icon": "⚪"}
    
    # Look for entity/key pairs where the same thing might be stored differently
    # (e.g., "Darian" vs "Puddin'" with similar keys)
    rows = facts_db.execute(
        """SELECT f1.entity, f1.key, f1.value as val1, f2.entity as entity2, f2.value as val2
           FROM facts f1
           JOIN facts f2 ON f1.key = f2.key AND f1.id < f2.id
           WHERE (
               LOWER(f1.entity) = LOWER(f2.entity)
               OR (f1.entity IN ('Darian', 'Puddin''', 'puddin', 'User') AND f2.entity IN ('Darian', 'Puddin''', 'puddin', 'User'))
               OR (f1.entity IN ('Margot', 'margot', 'Agent') AND f2.entity IN ('Margot', 'margot', 'Agent'))
           )
           AND f1.value != f2.value
           AND f1.stale = 0 AND f2.stale = 0
           LIMIT 20"""
    ).fetchall()
    
    conflict_count = len(rows)
    
    if conflict_count == 0:
        return {"score": 10, "label": "CONSISTENT", "detail": "No conflicting facts detected", "icon": "🟢"}
    elif conflict_count <= 3:
        conflicts = [f"{r['entity']}.{r['key']}: '{r['val1'][:30]}' vs '{r['val2'][:30]}'" for r in rows[:3]]
        return {"score": 7, "label": "MINOR", "detail": f"{conflict_count} conflicts: " + "; ".join(conflicts), "icon": "🟡"}
    else:
        return {"score": max(2, 10 - conflict_count), "label": "DRIFTING", 
                "detail": f"{conflict_count} conflicting facts — identity may be fragmenting", "icon": "🔴"}


# ============================================================
# METRIC: Procedure Health
# Are our learned behaviors degrading?
# ============================================================
def check_procedure_health(db):
    """Check procedure success rates and evolution frequency."""
    rows = db.execute(
        "SELECT slug, title, success_count, failure_count, version, last_outcome FROM procedures WHERE agent IN (?, 'shared')",
        (BRAIN_AGENT,)
    ).fetchall()
    
    if not rows:
        return {"score": 8, "label": "NO PROCS", "detail": "No procedures defined", "icon": "⚪"}
    
    issues = []
    decaying = []
    
    for r in rows:
        total = r["success_count"] + r["failure_count"]
        if total > 0:
            rate = r["success_count"] / total
            if rate < PROCEDURE_DECAY_THRESHOLD:
                decaying.append(f"{r['slug']} ({rate:.0%})")
    
    score = 10
    if decaying:
        score -= len(decaying) * 2
        issues.append(f"Decaying: {', '.join(decaying)}")
    
    # Check for procedures that have never been run
    never_run = [r["slug"] for r in rows if r["success_count"] + r["failure_count"] == 0]
    if never_run:
        issues.append(f"Never tested: {', '.join(never_run)}")
        score -= 1
    
    score = max(1, min(10, score))
    
    if score >= 8:
        return {"score": score, "label": "HEALTHY", "detail": f"{len(rows)} procedures, {len(decaying)} decaying", "icon": "🟢"}
    elif score >= 5:
        return {"score": score, "label": "DEGRADING", "detail": "; ".join(issues), "icon": "🟡"}
    else:
        return {"score": score, "label": "FAILING", "detail": "; ".join(issues), "icon": "🔴"}


# ============================================================
# METRIC: Recording Cadence
# Are we forming memories at a healthy rate?
# ============================================================
def check_cadence(db):
    """Check episode recording pattern over the last 7 days."""
    rows = db.execute(
        """SELECT date, COUNT(*) as c FROM episodes 
           WHERE date >= date('now', '-7 days') 
           GROUP BY date ORDER BY date"""
    ).fetchall()
    
    if not rows:
        return {"score": 2, "label": "SILENT", "detail": "No episodes in 7 days", "icon": "🔴"}
    
    days_with_data = len(rows)
    total = sum(r["c"] for r in rows)
    avg_per_day = total / 7
    
    # Check for gaps (days with no episodes)
    gaps = 7 - days_with_data
    
    score = 10
    issues = []
    
    if gaps >= 4:
        score -= 4
        issues.append(f"{gaps} days with no recordings")
    elif gaps >= 2:
        score -= 2
        issues.append(f"{gaps} gap days")
    
    if avg_per_day < 2:
        score -= 2
        issues.append(f"avg {avg_per_day:.1f}/day (low activity)")
    
    score = max(1, min(10, score))
    
    detail = f"{total} episodes over {days_with_data}/7 days (avg {avg_per_day:.1f}/day)"
    if issues:
        detail += " — " + "; ".join(issues)
    
    icon = "🟢" if score >= 8 else "🟡" if score >= 5 else "🔴"
    label = "ACTIVE" if score >= 8 else "SPARSE" if score >= 5 else "DORMANT"
    
    return {"score": score, "label": label, "detail": detail, "icon": icon}


# ============================================================
# AGGREGATE
# ============================================================
def run_all_checks(verbose=False, json_out=False):
    """Run all erosion checks and produce aggregate score."""
    brain_db = get_brain_db()
    facts_db = get_facts_db()
    
    metrics = {
        "staleness": {"check": check_staleness, "weight": 2.0, "db": brain_db, "name": "Memory Freshness"},
        "consolidation": {"check": check_consolidation, "weight": 1.5, "db": brain_db, "name": "Consolidation Debt"},
        "importance": {"check": check_importance_inflation, "weight": 1.5, "db": brain_db, "name": "Importance Calibration"},
        "emotions": {"check": check_emotional_diversity, "weight": 1.0, "db": brain_db, "name": "Emotional Diversity"},
        "conflicts": {"check": check_fact_conflicts, "weight": 2.0, "db": facts_db, "name": "Fact Consistency"},
        "procedures": {"check": check_procedure_health, "weight": 1.0, "db": brain_db, "name": "Procedure Health"},
        "cadence": {"check": check_cadence, "weight": 1.5, "db": brain_db, "name": "Recording Cadence"},
    }
    
    results = {}
    weighted_sum = 0
    total_weight = 0
    
    for key, m in metrics.items():
        result = m["check"](m["db"])
        result["weight"] = m["weight"]
        result["name"] = m["name"]
        results[key] = result
        weighted_sum += result["score"] * m["weight"]
        total_weight += m["weight"]
    
    overall = round(weighted_sum / total_weight, 1) if total_weight > 0 else 0
    
    brain_db.close()
    if facts_db:
        facts_db.close()
    
    if json_out:
        output = {
            "overall_score": overall,
            "overall_label": _score_label(overall),
            "timestamp": datetime.now().isoformat(),
            "agent": BRAIN_AGENT,
            "metrics": {k: {**v, "weight": float(v["weight"])} for k, v in results.items()},
        }
        print(json.dumps(output, indent=2))
        return
    
    # Pretty print
    print(f"{'='*60}")
    print(f"  🧠 BRAIN HEALTH REPORT — {BRAIN_AGENT}")
    print(f"  {datetime.now().strftime('%Y-%m-%d %I:%M %p')}")
    print(f"{'='*60}")
    print()
    
    for key, r in results.items():
        bar = _score_bar(r["score"])
        print(f"  {r['icon']} {r['name']:<24} {bar} {r['score']:>2}/10  [{r['label']}]")
        if verbose or r["score"] < 7:
            print(f"     {r['detail']}")
    
    print()
    print(f"  {'─'*56}")
    overall_icon = "🟢" if overall >= 8 else "🟡" if overall >= 5 else "🔴"
    overall_bar = _score_bar(overall)
    print(f"  {overall_icon} {'OVERALL':<24} {overall_bar} {overall:>4}/10  [{_score_label(overall)}]")
    print()
    
    # Recommendations
    recs = _generate_recommendations(results)
    if recs:
        print("  📋 RECOMMENDATIONS:")
        for rec in recs:
            print(f"     • {rec}")
        print()


def _score_bar(score, width=20):
    """Generate a visual bar for a score."""
    filled = round(score / 10 * width)
    return "█" * filled + "░" * (width - filled)


def _score_label(score):
    if score >= 9:
        return "EXCELLENT"
    elif score >= 7:
        return "HEALTHY"
    elif score >= 5:
        return "DEGRADED"
    elif score >= 3:
        return "ERODING"
    else:
        return "CRITICAL"


def _generate_recommendations(results):
    """Generate actionable recommendations from results."""
    recs = []
    
    if results["staleness"]["score"] < 7:
        recs.append("Run `brain store` to record recent experiences — memory is going stale")
    
    if results["consolidation"]["score"] < 7:
        recs.append("Run `brain consolidate` to process backlog into long-term memory")
    
    if results["importance"]["score"] < 7:
        recs.append("Recalibrate importance scores — use the full 1-10 range, not just 7-10")
    
    if results["emotions"]["score"] < 7:
        recs.append("Add --emotion tags to episodes — emotional memory aids recall")
    
    if results["conflicts"]["score"] < 7:
        recs.append("Review conflicting facts with `brain facts search` and resolve duplicates")
    
    if results["procedures"]["score"] < 7:
        recs.append("Run `brain proc evolve` on failing procedures to let them adapt")
    
    if results["cadence"]["score"] < 7:
        recs.append("Increase episode recording frequency — silent days create memory gaps")
    
    return recs


def main():
    verbose = "--verbose" in sys.argv or "-v" in sys.argv
    json_out = "--json" in sys.argv
    run_all_checks(verbose=verbose, json_out=json_out)


if __name__ == "__main__":
    main()


path: scripts/evolve.py
content: #!/usr/bin/env python3
"""
brain proc evolve — Error-driven procedure evolution
Inspired by Mengram's procedural memory concept.

Analyzes failure history for a procedure and rewrites steps to prevent
recurring failures. Uses LLM to synthesize patterns and propose fixes.

The cerebellum doesn't just remember — it adapts.
"""

import json
import os
import sqlite3
import subprocess
import sys
from datetime import datetime

BRAIN_DB = os.environ.get("BRAIN_DB", os.path.join(os.path.dirname(__file__), "brain.db"))
BRAIN_AGENT = os.environ.get("BRAIN_AGENT", "margot")

# --- LLM Configuration ---
# Config file (optional): tools/brain/brain.conf
# Environment variables override config file values.
#
# BRAIN_LLM_URL      — OpenAI-compatible chat completions endpoint
# BRAIN_LLM_KEY      — API key (Bearer token)
# BRAIN_LLM_MODEL    — Model name to use for evolution
#
# Supported providers (just point URL + key at any OpenAI-compatible API):
#   - OpenAI:     https://api.openai.com/v1/chat/completions + OPENAI_API_KEY
#   - Anthropic:  (via proxy) 
#   - Google:     https://generativelanguage.googleapis.com/v1beta/openai/chat/completions + BRAIN_LLM_KEY
#   - Ollama:     http://localhost:11434/v1/chat/completions (no key needed)
#   - LiteLLM:    http://localhost:4000/v1/chat/completions + proxy key

SCRIPT_DIR_EVOLVE = os.path.dirname(os.path.abspath(__file__))
CONF_FILE = os.path.join(SCRIPT_DIR_EVOLVE, "brain.conf")


def _load_config():
    """Load config from brain.conf (INI-style key=value), then overlay env vars."""
    conf = {
        "url": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
        "key": "",
        "model": "gemini-2.5-flash",
    }
    
    # Read config file if it exists
    if os.path.exists(CONF_FILE):
        with open(CONF_FILE) as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith("#"):
                    continue
                if "=" in line:
                    k, v = line.split("=", 1)
                    k = k.strip().lower()
                    v = v.strip().strip('"').strip("'")
                    if k in ("url", "llm_url"):
                        conf["url"] = v
                    elif k in ("key", "llm_key", "api_key"):
                        conf["key"] = v
                    elif k in ("model", "llm_model"):
                        conf["model"] = v

    # Environment overrides (highest priority)
    if os.environ.get("BRAIN_LLM_URL"):
        conf["url"] = os.environ["BRAIN_LLM_URL"]
    if os.environ.get("BRAIN_LLM_KEY"):
        conf["key"] = os.environ["BRAIN_LLM_KEY"]
    if os.environ.get("BRAIN_LLM_MODEL"):
        conf["model"] = os.environ["BRAIN_LLM_MODEL"]
    
    return conf


LLM_CONFIG = _load_config()
EVOLVE_URL = LLM_CONFIG["url"]
EVOLVE_KEY = LLM_CONFIG["key"]
EVOLVE_MODEL = LLM_CONFIG["model"]


def get_db():
    conn = sqlite3.connect(BRAIN_DB)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA foreign_keys=ON")
    return conn


def get_procedure(slug):
    """Get a procedure by slug, scoped to current agent + shared."""
    db = get_db()
    row = db.execute(
        "SELECT * FROM procedures WHERE slug = ? AND agent IN (?, 'shared')",
        (slug, BRAIN_AGENT)
    ).fetchone()
    db.close()
    return dict(row) if row else None


def get_failure_history(procedure_id, limit=20):
    """Get recent failures for a procedure."""
    db = get_db()
    rows = db.execute(
        """SELECT * FROM procedure_history 
           WHERE procedure_id = ? AND event_type = 'failure'
           ORDER BY occurred_at DESC LIMIT ?""",
        (procedure_id, limit)
    ).fetchall()
    db.close()
    return [dict(r) for r in rows]


def get_full_history(procedure_id, limit=50):
    """Get all history for a procedure."""
    db = get_db()
    rows = db.execute(
        """SELECT * FROM procedure_history 
           WHERE procedure_id = ?
           ORDER BY occurred_at DESC LIMIT ?""",
        (procedure_id, limit)
    ).fetchall()
    db.close()
    return [dict(r) for r in rows]


def analyze_failures_local(proc, failures):
    """
    Analyze failure patterns without LLM — pattern matching.
    Returns analysis dict with patterns found.
    """
    steps = json.loads(proc["steps"]) if isinstance(proc["steps"], str) else proc["steps"]
    
    # Count failures per step
    step_failures = {}
    for f in failures:
        step_num = f.get("failed_step")
        if step_num is not None:
            step_failures[step_num] = step_failures.get(step_num, 0) + 1
    
    # Find repeat offenders (same step failing multiple times)
    repeat_offenders = {k: v for k, v in step_failures.items() if v >= 2}
    
    # Find sequential failures (consecutive steps failing = brittle chain)
    sorted_steps = sorted(step_failures.keys())
    brittle_chains = []
    for i in range(len(sorted_steps) - 1):
        if sorted_steps[i + 1] - sorted_steps[i] == 1:
            brittle_chains.append((sorted_steps[i], sorted_steps[i + 1]))
    
    # Extract error descriptions for keyword analysis
    error_keywords = {}
    for f in failures:
        desc = (f.get("description") or "").lower()
        for keyword in ["timeout", "permission", "connection", "not found", "refused",
                        "crash", "oom", "memory", "disk", "auth", "token", "missing",
                        "race condition", "already exists", "conflict"]:
            if keyword in desc:
                error_keywords[keyword] = error_keywords.get(keyword, 0) + 1

    return {
        "total_failures": len(failures),
        "step_failures": step_failures,
        "repeat_offenders": repeat_offenders,
        "brittle_chains": brittle_chains,
        "error_keywords": error_keywords,
        "steps": steps,
    }


def evolve_with_llm(proc, failures, analysis, dry_run=False):
    """
    Use LLM to synthesize failure patterns and propose evolved steps.
    Returns new steps JSON array.
    """
    steps = analysis["steps"]
    
    # Build a rich context for the LLM
    failure_descriptions = []
    for f in failures:
        entry = f"- Step {f.get('failed_step', '?')}: {f.get('description', 'no description')}"
        if f.get("fix_applied"):
            entry += f"\n  Fix applied: {f['fix_applied']}"
        entry += f" ({f.get('occurred_at', '?')})"
        failure_descriptions.append(entry)

    prompt = f"""You are a procedural memory evolution engine. Your job is to analyze a procedure's
failure history and produce an improved version of the steps that prevents recurring failures.

## Current Procedure: {proc['title']}
**Slug:** {proc['slug']}
**Version:** {proc['version']}
**Success rate:** {proc['success_count']}/{proc['success_count'] + proc['failure_count']}
**Description:** {proc.get('description') or 'None'}

## Current Steps (JSON array):
{json.dumps(steps, indent=2)}

## Failure History ({len(failures)} failures):
{chr(10).join(failure_descriptions)}

## Pattern Analysis:
- Repeat offender steps: {analysis['repeat_offenders'] or 'None'}
- Brittle chains (consecutive failures): {analysis['brittle_chains'] or 'None'}
- Common error types: {analysis['error_keywords'] or 'None'}

## Your Task:
1. Identify the root patterns causing failures
2. Rewrite the steps to prevent these failures
3. Add pre-flight checks where needed (e.g., "Verify X is running before Y")
4. Add error handling steps (e.g., "If X fails, try Y before giving up")
5. Remove or merge steps that are consistently problematic
6. Annotate evolved steps with [vN: reason] tags

## Rules:
- Output ONLY a JSON object with two keys:
  - "steps": the new steps array (array of strings)
  - "changelog": a one-line summary of what changed and why
- Keep steps actionable and specific
- Don't add unnecessary steps — lean procedures are better
- Preserve working steps unchanged
- Every fix must trace to an actual failure in the history
"""

    try:
        import urllib.request
        
        payload = json.dumps({
            "model": EVOLVE_MODEL,
            "messages": [
                {"role": "system", "content": "You are a procedure evolution engine. Output only valid JSON."},
                {"role": "user", "content": prompt}
            ],
            "temperature": 0.3,
            "max_tokens": 2000,
        })

        headers = {"Content-Type": "application/json"}
        if EVOLVE_KEY:
            headers["Authorization"] = f"Bearer {EVOLVE_KEY}"

        req = urllib.request.Request(
            EVOLVE_URL,
            data=payload.encode(),
            headers=headers,
        )

        with urllib.request.urlopen(req, timeout=30) as resp:
            result = json.loads(resp.read())
        
        content = result["choices"][0]["message"]["content"]
        
        # Strip markdown code fences if present
        content = content.strip()
        if content.startswith("```"):
            # Remove first line (```json or ```)
            content = content.split("\n", 1)[1] if "\n" in content else content[3:]
            # Remove trailing fence
            if "```" in content:
                content = content[:content.rfind("```")]
            content = content.strip()
        
        # Try to extract JSON object if there's surrounding text
        if not content.startswith("{"):
            start = content.find("{")
            if start >= 0:
                content = content[start:]
        if not content.endswith("}"):
            end = content.rfind("}")
            if end >= 0:
                content = content[:end + 1]
        
        # Try parsing as-is first, then try progressively more aggressive extraction
        evolved = None
        for attempt_content in [content]:
            try:
                evolved = json.loads(attempt_content)
                break
            except json.JSONDecodeError:
                pass
        
        # If that failed, try finding balanced braces
        if evolved is None:
            brace_start = content.find("{")
            if brace_start >= 0:
                depth = 0
                for i in range(brace_start, len(content)):
                    if content[i] == "{":
                        depth += 1
                    elif content[i] == "}":
                        depth -= 1
                        if depth == 0:
                            try:
                                evolved = json.loads(content[brace_start:i+1])
                                break
                            except json.JSONDecodeError:
                                continue
        
        if evolved is None:
            raise ValueError(f"Could not parse JSON from LLM response: {content[:200]}")
        
        # Validate expected keys
        if "steps" not in evolved:
            raise ValueError("LLM response missing 'steps' key")
        if not isinstance(evolved["steps"], list):
            raise ValueError("'steps' must be an array")
            
        return evolved

    except Exception as e:
        print(f"❌ LLM evolution failed: {e}", file=sys.stderr)
        print("⚠️ Falling back to LOCAL pattern-based evolution (not LLM)", file=sys.stderr)
        return evolve_local(proc, failures, analysis)


def evolve_local(proc, failures, analysis):
    """
    Fallback: evolve procedure using pattern matching without LLM.
    Less sophisticated but always available.
    """
    steps = list(analysis["steps"])  # copy
    changes = []
    
    # For each repeat offender step, add a pre-check before it
    for step_num, count in sorted(analysis["repeat_offenders"].items(), reverse=True):
        idx = step_num - 1  # 0-indexed
        if idx < len(steps):
            # Find the most common error for this step
            step_errors = [f for f in failures if f.get("failed_step") == step_num]
            if step_errors:
                latest_fix = step_errors[0].get("fix_applied", "")
                latest_error = step_errors[0].get("description", "unknown error")
                
                # Add the fix as a pre-check step
                if latest_fix:
                    pre_check = f"Pre-check: {latest_fix} [v{proc['version']+1}: added after {count}x failures — {latest_error}]"
                    steps.insert(idx, pre_check)
                    changes.append(f"Added pre-check before step {step_num}")

    # For common error keywords, add defensive steps
    if "timeout" in analysis["error_keywords"]:
        steps.append(f"Set timeout limits and retry once on timeout [v{proc['version']+1}: timeout pattern detected]")
        changes.append("Added timeout retry")
    
    if "permission" in analysis["error_keywords"] or "auth" in analysis["error_keywords"]:
        steps.insert(0, f"Verify credentials/permissions are valid before starting [v{proc['version']+1}: auth failure pattern]")
        changes.append("Added credential pre-check")

    changelog = "; ".join(changes) if changes else "No automatic fixes found — review manually"
    return {"steps": steps, "changelog": changelog}


def apply_evolution(slug, evolved_result):
    """Apply evolved steps to the procedure, bump version, record history."""
    db = get_db()
    
    proc = db.execute(
        "SELECT * FROM procedures WHERE slug = ? AND agent IN (?, 'shared')",
        (slug, BRAIN_AGENT)
    ).fetchone()
    
    if not proc:
        print(f"❌ Procedure '{slug}' not found")
        return False

    new_version = proc["version"] + 1
    new_steps = json.dumps(evolved_result["steps"])
    changelog = evolved_result.get("changelog", "Evolved from failure patterns")

    # Update procedure
    db.execute(
        """UPDATE procedures SET 
           steps = ?, version = ?, updated_at = datetime('now')
           WHERE id = ?""",
        (new_steps, new_version, proc["id"])
    )

    # Record evolution in history
    db.execute(
        """INSERT INTO procedure_history 
           (procedure_id, version, event_type, description, agent)
           VALUES (?, ?, 'evolved', ?, ?)""",
        (proc["id"], new_version, changelog, BRAIN_AGENT)
    )

    db.commit()
    db.close()

    return new_version


def cmd_evolve(slug, dry_run=False):
    """Main evolution command."""
    proc = get_procedure(slug)
    if not proc:
        print(f"❌ Procedure '{slug}' not found (agent: {BRAIN_AGENT})")
        sys.exit(1)

    failures = get_failure_history(proc["id"])
    if not failures:
        print(f"✅ No failures recorded for '{slug}' — nothing to evolve")
        print(f"   (Success rate: {proc['success_count']}/{proc['success_count'] + proc['failure_count']})")
        return

    print(f"🧠 Analyzing {len(failures)} failures for '{slug}' v{proc['version']}...")
    analysis = analyze_failures_local(proc, failures)

    if analysis["repeat_offenders"]:
        print(f"   🔴 Repeat offenders: {analysis['repeat_offenders']}")
    if analysis["brittle_chains"]:
        print(f"   ⛓️ Brittle chains: {analysis['brittle_chains']}")
    if analysis["error_keywords"]:
        print(f"   🏷️ Error patterns: {analysis['error_keywords']}")

    print(f"\n🔬 Evolving procedure with LLM ({EVOLVE_MODEL})...")
    evolved = evolve_with_llm(proc, failures, analysis, dry_run=dry_run)

    if dry_run:
        print("\n📋 PROPOSED EVOLUTION (dry run — not applied):")
        print(f"   Changelog: {evolved.get('changelog', 'N/A')}")
        print(f"\n   New steps:")
        for i, step in enumerate(evolved["steps"], 1):
            print(f"   {i}. {step}")
        return

    new_version = apply_evolution(slug, evolved)
    if new_version:
        print(f"\n✅ Evolved '{slug}' → v{new_version}")
        print(f"   Changelog: {evolved.get('changelog', 'N/A')}")
        print(f"   Steps: {len(evolved['steps'])}")
    else:
        print(f"❌ Evolution failed to apply")


def cmd_history(slug):
    """Show evolution history for a procedure."""
    proc = get_procedure(slug)
    if not proc:
        print(f"❌ Procedure '{slug}' not found")
        sys.exit(1)

    history = get_full_history(proc["id"])
    if not history:
        print(f"No history for '{slug}'")
        return

    print(f"=== PROCEDURE HISTORY: {proc['title']} (v{proc['version']}) ===")
    print(f"Success rate: {proc['success_count']}/{proc['success_count'] + proc['failure_count']}")
    print()

    for h in reversed(history):
        icon = {"success": "✅", "failure": "❌", "evolved": "🧬"}.get(h["event_type"], "•")
        line = f"{icon} v{h['version']} [{h['occurred_at']}] {h['event_type']}"
        if h.get("failed_step"):
            line += f" (step {h['failed_step']})"
        if h.get("description"):
            line += f" — {h['description']}"
        if h.get("fix_applied"):
            line += f"\n   Fix: {h['fix_applied']}"
        print(line)


def cmd_fail(slug, step_num, error_desc, fix_applied=None):
    """Record a failure for a procedure."""
    db = get_db()
    
    proc = db.execute(
        "SELECT * FROM procedures WHERE slug = ? AND agent IN (?, 'shared')",
        (slug, BRAIN_AGENT)
    ).fetchone()
    
    if not proc:
        print(f"❌ Procedure '{slug}' not found")
        db.close()
        sys.exit(1)

    # Update procedure stats
    db.execute(
        """UPDATE procedures SET 
           failure_count = failure_count + 1,
           last_run = datetime('now'),
           last_outcome = 'failure',
           updated_at = datetime('now')
           WHERE id = ?""",
        (proc["id"],)
    )

    # Record in history
    db.execute(
        """INSERT INTO procedure_history 
           (procedure_id, version, event_type, failed_step, description, fix_applied, agent)
           VALUES (?, ?, 'failure', ?, ?, ?, ?)""",
        (proc["id"], proc["version"], step_num, error_desc, fix_applied, BRAIN_AGENT)
    )

    db.commit()
    db.close()

    total = proc["failure_count"] + 1 + proc["success_count"]
    print(f"❌ Failure recorded for '{slug}' v{proc['version']} at step {step_num}")
    print(f"   Error: {error_desc}")
    if fix_applied:
        print(f"   Fix: {fix_applied}")
    print(f"   Record: {proc['success_count']}/{total}")

    # Auto-suggest evolution if failure count crosses threshold
    if (proc["failure_count"] + 1) >= 3 and (proc["failure_count"] + 1) % 2 == 1:
        print(f"\n💡 '{slug}' has {proc['failure_count'] + 1} failures — consider: brain proc evolve {slug}")


def cmd_success(slug):
    """Record a success for a procedure."""
    db = get_db()
    
    proc = db.execute(
        "SELECT * FROM procedures WHERE slug = ? AND agent IN (?, 'shared')",
        (slug, BRAIN_AGENT)
    ).fetchone()
    
    if not proc:
        print(f"❌ Procedure '{slug}' not found")
        db.close()
        sys.exit(1)

    db.execute(
        """UPDATE procedures SET 
           success_count = success_count + 1,
           last_run = datetime('now'),
           last_outcome = 'success',
           updated_at = datetime('now')
           WHERE id = ?""",
        (proc["id"],)
    )

    db.execute(
        """INSERT INTO procedure_history 
           (procedure_id, version, event_type, description, agent)
           VALUES (?, ?, 'success', 'Completed successfully', ?)""",
        (proc["id"], proc["version"], BRAIN_AGENT)
    )

    db.commit()
    
    new_total = proc["success_count"] + 1 + proc["failure_count"]
    new_success = proc["success_count"] + 1
    pct = round(new_success / new_total * 100) if new_total > 0 else 0
    db.close()
    
    print(f"✅ Success recorded for '{slug}' v{proc['version']}")
    print(f"   Record: {new_success}/{new_total} ({pct}%)")


def cmd_create(slug, title, steps_json, description=None, tags=None):
    """Create a new procedure."""
    db = get_db()
    
    existing = db.execute("SELECT id FROM procedures WHERE slug = ?", (slug,)).fetchone()
    if existing:
        print(f"❌ Procedure '{slug}' already exists")
        db.close()
        sys.exit(1)

    steps = json.loads(steps_json) if isinstance(steps_json, str) else steps_json
    
    db.execute(
        """INSERT INTO procedures (slug, title, description, tags, steps, agent)
           VALUES (?, ?, ?, ?, ?, ?)""",
        (slug, title, description, tags, json.dumps(steps), BRAIN_AGENT)
    )
    db.commit()
    
    proc_id = db.execute("SELECT last_insert_rowid()").fetchone()[0]
    db.close()
    
    print(f"✅ Created procedure '{slug}' (#{proc_id})")
    print(f"   Title: {title}")
    print(f"   Steps: {len(steps)}")


def cmd_list():
    """List all procedures visible to current agent."""
    db = get_db()
    rows = db.execute(
        """SELECT slug, title, version, success_count, failure_count, 
                  last_outcome, agent, tags
           FROM procedures 
           WHERE agent IN (?, 'shared')
           ORDER BY slug""",
        (BRAIN_AGENT,)
    ).fetchall()
    db.close()

    if not rows:
        print("No procedures found")
        return

    print(f"{'SLUG':<25} {'VER':>4} {'SUCCESS':>8} {'LAST':>8} {'AGENT':<10} TITLE")
    print(f"{'─'*25} {'─'*4} {'─'*8} {'─'*8} {'─'*10} {'─'*30}")
    for r in rows:
        total = r["success_count"] + r["failure_count"]
        rate = f"{r['success_count']}/{total}" if total > 0 else "N/A"
        last = (r["last_outcome"] or "—")[:8]
        print(f"{r['slug']:<25} v{r['version']:<3} {rate:>8} {last:>8} {r['agent']:<10} {r['title']}")


def cmd_show(slug):
    """Show a procedure's details."""
    proc = get_procedure(slug)
    if not proc:
        print(f"❌ Procedure '{slug}' not found")
        sys.exit(1)

    steps = json.loads(proc["steps"]) if isinstance(proc["steps"], str) else proc["steps"]
    total = proc["success_count"] + proc["failure_count"]
    pct = round(proc["success_count"] / total * 100) if total > 0 else 0

    print(f"# {proc['title']}")
    print(f"**Slug:** {proc['slug']} | **Version:** v{proc['version']} | **Agent:** {proc['agent']}")
    if proc.get("description"):
        print(f"**Description:** {proc['description']}")
    if proc.get("tags"):
        print(f"**Tags:** {proc['tags']}")
    print(f"**Success rate:** {proc['success_count']}/{total} ({pct}%)")
    print(f"**Last run:** {proc.get('last_run') or 'never'} ({proc.get('last_outcome') or 'N/A'})")
    print()
    print("## Steps")
    for i, step in enumerate(steps, 1):
        print(f"  {i}. {step}")


# --- CLI dispatch ---

def main():
    if len(sys.argv) < 2:
        print("Usage: evolve.py <command> [args]")
        print("Commands: evolve, fail, success, create, list, show, history")
        sys.exit(1)

    cmd = sys.argv[1]

    if cmd == "evolve":
        if len(sys.argv) < 3:
            print("Usage: evolve.py evolve <slug> [--dry-run]")
            sys.exit(1)
        dry_run = "--dry-run" in sys.argv
        cmd_evolve(sys.argv[2], dry_run=dry_run)

    elif cmd == "fail":
        # evolve.py fail <slug> --step N --error "desc" [--fix "fix"]
        slug = sys.argv[2] if len(sys.argv) > 2 else None
        step_num = None
        error_desc = None
        fix_applied = None
        
        i = 3
        while i < len(sys.argv):
            if sys.argv[i] == "--step":
                step_num = int(sys.argv[i + 1])
                i += 2
            elif sys.argv[i] == "--error":
                error_desc = sys.argv[i + 1]
                i += 2
            elif sys.argv[i] == "--fix":
                fix_applied = sys.argv[i + 1]
                i += 2
            else:
                i += 1

        if not all([slug, step_num, error_desc]):
            print('Usage: evolve.py fail <slug> --step N --error "what happened" [--fix "what fixed it"]')
            sys.exit(1)
        
        cmd_fail(slug, step_num, error_desc, fix_applied)

    elif cmd == "success":
        if len(sys.argv) < 3:
            print("Usage: evolve.py success <slug>")
            sys.exit(1)
        cmd_success(sys.argv[2])

    elif cmd == "create":
        # evolve.py create <slug> --title "T" --steps '["s1","s2"]' [--desc "D"] [--tags "t1,t2"]
        slug = sys.argv[2] if len(sys.argv) > 2 else None
        title = None
        steps_json = None
        description = None
        tags = None

        i = 3
        while i < len(sys.argv):
            if sys.argv[i] == "--title":
                title = sys.argv[i + 1]
                i += 2
            elif sys.argv[i] == "--steps":
                steps_json = sys.argv[i + 1]
                i += 2
            elif sys.argv[i] == "--desc":
                description = sys.argv[i + 1]
                i += 2
            elif sys.argv[i] == "--tags":
                tags = sys.argv[i + 1]
                i += 2
            else:
                i += 1

        if not all([slug, title, steps_json]):
            print('Usage: evolve.py create <slug> --title "Title" --steps \'["step1","step2"]\'')
            sys.exit(1)
        
        cmd_create(slug, title, steps_json, description, tags)

    elif cmd == "list":
        cmd_list()

    elif cmd == "show":
        if len(sys.argv) < 3:
            print("Usage: evolve.py show <slug>")
            sys.exit(1)
        cmd_show(sys.argv[2])

    elif cmd == "history":
        if len(sys.argv) < 3:
            print("Usage: evolve.py history <slug>")
            sys.exit(1)
        cmd_history(sys.argv[2])

    else:
        print(f"Unknown command: {cmd}")
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/facts.py
content: #!/usr/bin/env python3
"""
brain facts — Structured fact storage wrapper
Queries the legacy facts.db for backward compatibility.
Future: migrate to brain.db facts table with full schema.
"""

import json
import os
import sqlite3
import sys

FACTS_DB = os.environ.get("BRAIN_FACTS_DB", os.path.join(os.path.dirname(__file__), "..", "facts.db"))


def get_conn():
    if not os.path.exists(FACTS_DB):
        print(f"❌ Facts database not found: {FACTS_DB}", file=sys.stderr)
        sys.exit(1)
    return sqlite3.connect(FACTS_DB)


def cmd_get(entity, key):
    """Get a specific fact value."""
    conn = get_conn()
    cursor = conn.cursor()
    
    # Try exact match first
    cursor.execute(
        "SELECT value, category, confidence, permanent FROM facts WHERE entity = ? AND key = ?",
        (entity, key)
    )
    row = cursor.fetchone()
    
    if row:
        value, category, confidence, permanent = row
        # Track access for decay scoring
        try:
            cursor.execute(
                "UPDATE facts SET access_count = access_count + 1, last_accessed = datetime('now') WHERE entity = ? AND key = ?",
                (entity, key)
            )
            conn.commit()
        except Exception as e:
            print(f"(access tracking failed: {e})", file=sys.stderr)
        conn.close()
        print(value)
        return
    
    # Try case-insensitive fallback
    cursor.execute(
        "SELECT value FROM facts WHERE LOWER(entity) = LOWER(?) AND LOWER(key) = LOWER(?)",
        (entity, key)
    )
    row = cursor.fetchone()
    conn.close()
    
    if row:
        print(row[0])
    else:
        print(f"(not found: {entity}.{key})", file=sys.stderr)
        sys.exit(1)


def cmd_set(entity, key, value, category="general", permanent=False, confidence=1.0):
    """Set a fact (upsert)."""
    conn = get_conn()
    cursor = conn.cursor()
    
    perm = 1 if permanent else 0
    
    cursor.execute(
        """INSERT INTO facts (entity, key, value, category, confidence, permanent, updated_at)
           VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
           ON CONFLICT(entity, key) DO UPDATE SET
           value=excluded.value,
           category=excluded.category,
           confidence=excluded.confidence,
           permanent=excluded.permanent,
           updated_at=datetime('now')""",
        (entity, key, value, category, confidence, perm)
    )
    conn.commit()
    conn.close()
    print(f"✅ Set: {entity}.{key} = {value}")


def cmd_search(query, limit=10, json_out=False):
    """Full-text search across facts."""
    conn = get_conn()
    cursor = conn.cursor()
    
    cursor.execute(
        """SELECT f.entity, f.key, f.value, f.category, f.confidence
           FROM facts f
           JOIN facts_fts fts ON f.id = fts.rowid
           WHERE facts_fts MATCH ?
           ORDER BY f.access_count DESC, f.confidence DESC
           LIMIT ?""",
        (query, limit)
    )
    rows = cursor.fetchall()
    
    # Track access for found facts
    for row in rows:
        try:
            cursor.execute(
                "UPDATE facts SET access_count = access_count + 1, last_accessed = datetime('now') WHERE entity = ? AND key = ?",
                (row[0], row[1])
            )
        except Exception as e:
            pass  # Batch tracking — log would be too noisy
    conn.commit()
    conn.close()
    
    if json_out:
        results = [{"entity": r[0], "key": r[1], "value": r[2], "category": r[3], "confidence": r[4]} for r in rows]
        print(json.dumps(results, indent=2))
    else:
        if not rows:
            print("No matches found")
            return
        
        print(f"{'ENTITY':<20} {'KEY':<20} {'VALUE':<30} {'CAT':<10}")
        print("─" * 80)
        for r in rows:
            entity = r[0][:18]
            key = r[1][:18]
            value = r[2][:28]
            cat = r[3][:8]
            print(f"{entity:<20} {key:<20} {value:<30} {cat:<10}")


def cmd_list(entity=None, category=None, limit=50):
    """List facts, optionally filtered by entity or category."""
    conn = get_conn()
    cursor = conn.cursor()
    
    query = "SELECT entity, key, value, category FROM facts WHERE 1=1"
    params = []
    
    if entity:
        query += " AND entity = ?"
        params.append(entity)
    if category:
        query += " AND category = ?"
        params.append(category)
    
    query += " ORDER BY updated_at DESC LIMIT ?"
    params.append(limit)
    
    cursor.execute(query, params)
    rows = cursor.fetchall()
    conn.close()
    
    if not rows:
        print("No facts found")
        return
    
    print(f"{'ENTITY':<20} {'KEY':<20} {'VALUE':<30} {'CAT':<10}")
    print("─" * 80)
    for r in rows:
        entity = r[0][:18]
        key = r[1][:18]
        value = r[2][:28]
        cat = r[3][:8]
        print(f"{entity:<20} {key:<20} {value:<30} {cat:<10}")


def cmd_delete(entity, key):
    """Delete a specific fact."""
    conn = get_conn()
    cursor = conn.cursor()
    
    cursor.execute(
        "DELETE FROM facts WHERE entity = ? AND key = ?",
        (entity, key)
    )
    conn.commit()
    
    if cursor.rowcount > 0:
        print(f"🗑️ Deleted: {entity}.{key}")
    else:
        print(f"❌ Not found: {entity}.{key}")
    
    conn.close()


def cmd_stats():
    """Show facts database statistics."""
    conn = get_conn()
    cursor = conn.cursor()
    
    cursor.execute("SELECT COUNT(*) FROM facts")
    total = cursor.fetchone()[0]
    
    cursor.execute("SELECT COUNT(DISTINCT entity) FROM facts")
    entities = cursor.fetchone()[0]
    
    cursor.execute("SELECT COUNT(DISTINCT category) FROM facts")
    categories = cursor.fetchone()[0]
    
    cursor.execute("SELECT category, COUNT(*) FROM facts GROUP BY category ORDER BY COUNT(*) DESC LIMIT 5")
    top_cats = cursor.fetchall()
    
    cursor.execute("SELECT entity, COUNT(*) FROM facts GROUP BY entity ORDER BY COUNT(*) DESC LIMIT 5")
    top_entities = cursor.fetchall()
    
    conn.close()
    
    print("=== FACTS DATABASE STATS ===")
    print(f"Total facts: {total}")
    print(f"Unique entities: {entities}")
    print(f"Categories: {categories}")
    print()
    print("Top categories:")
    for cat, count in top_cats:
        print(f"  {cat}: {count}")
    print()
    print("Top entities:")
    for ent, count in top_entities:
        print(f"  {ent}: {count}")


def main():
    if len(sys.argv) < 2:
        print("Usage: brain facts <command> [args]")
        print("Commands: get, set, search, list, delete, stats")
        sys.exit(1)
    
    cmd = sys.argv[1]
    
    if cmd == "get":
        if len(sys.argv) < 4:
            print("Usage: brain facts get <entity> <key>")
            sys.exit(1)
        cmd_get(sys.argv[2], sys.argv[3])
    
    elif cmd == "set":
        if len(sys.argv) < 5:
            print('Usage: brain facts set <entity> <key> <value> [--category <cat>] [--permanent] [--confidence <0.0-1.0>]')
            sys.exit(1)
        
        entity = sys.argv[2]
        key = sys.argv[3]
        value = sys.argv[4]
        category = "general"
        permanent = False
        confidence = 1.0
        
        i = 5
        while i < len(sys.argv):
            if sys.argv[i] == "--category":
                category = sys.argv[i + 1]
                i += 2
            elif sys.argv[i] == "--permanent":
                permanent = True
                i += 1
            elif sys.argv[i] == "--confidence":
                confidence = float(sys.argv[i + 1])
                i += 2
            else:
                i += 1
        
        cmd_set(entity, key, value, category, permanent, confidence)
    
    elif cmd == "search":
        if len(sys.argv) < 3:
            print("Usage: brain facts search <query> [--limit N] [--json]")
            sys.exit(1)
        
        query = sys.argv[2]
        limit = 10
        json_out = False
        
        i = 3
        while i < len(sys.argv):
            if sys.argv[i] == "--limit":
                limit = int(sys.argv[i + 1])
                i += 2
            elif sys.argv[i] == "--json":
                json_out = True
                i += 1
            else:
                i += 1
        
        cmd_search(query, limit, json_out)
    
    elif cmd == "list":
        entity = None
        category = None
        limit = 50
        
        i = 2
        while i < len(sys.argv):
            if sys.argv[i] == "--entity":
                entity = sys.argv[i + 1]
                i += 2
            elif sys.argv[i] == "--category":
                category = sys.argv[i + 1]
                i += 2
            elif sys.argv[i] == "--limit":
                limit = int(sys.argv[i + 1])
                i += 2
            else:
                i += 1
        
        cmd_list(entity, category, limit)
    
    elif cmd == "delete":
        if len(sys.argv) < 4:
            print("Usage: brain facts delete <entity> <key>")
            sys.exit(1)
        cmd_delete(sys.argv[2], sys.argv[3])
    
    elif cmd == "stats":
        cmd_stats()
    
    else:
        print(f"Unknown command: {cmd}")
        print("Commands: get, set, search, list, delete, stats")
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/migrate-daily-logs.py
content: #!/usr/bin/env python3
"""
Migrate markdown daily logs into Brain episodic memory.
Parses ## HH:MM — Title [feeling: word/intensity] format.
"""
import re
import sqlite3
import sys
import os
from pathlib import Path
from datetime import datetime

MEMORY_DIR = os.environ.get("MEMORY_DIR", "memory")
BRAIN_DB = os.environ.get("BRAIN_DB", "tools/brain/brain.db")

# Pattern: ## HH:MM — Title [feeling: word/intensity]
ENTRY_PATTERN = re.compile(
    r'^##\s+'
    r'(?P<time>\d{1,2}:\d{2}(?:\s*[AP]M)?)'
    r'\s*[-—–]\s*'
    r'(?P<title>.+?)'
    r'(?:\s*\[feeling:\s*(?P<emotion>[^/\]]+?)(?:/(?P<intensity>[^\]]+))?\])?'
    r'\s*$',
    re.IGNORECASE
)

# Also match entries without time: ## Title [feeling: ...]
ENTRY_NO_TIME = re.compile(
    r'^##\s+'
    r'(?P<title>[^[\n]+?)'
    r'(?:\s*\[feeling:\s*(?P<emotion>[^/\]]+?)(?:/(?P<intensity>[^\]]+))?\])?'
    r'\s*$',
    re.IGNORECASE
)

# Date from filename: YYYY-MM-DD.md
DATE_PATTERN = re.compile(r'(\d{4}-\d{2}-\d{2})\.md$')


def parse_daily_log(filepath: Path) -> list[dict]:
    """Parse a daily log markdown file into episode entries."""
    date_match = DATE_PATTERN.search(filepath.name)
    if not date_match:
        return []
    
    date = date_match.group(1)
    entries = []
    current_entry = None
    content_lines = []
    
    with open(filepath, 'r', encoding='utf-8') as f:
        lines = f.readlines()
    
    for line in lines:
        # Skip the H1 title line
        if line.startswith('# ') and not line.startswith('## '):
            continue
        
        # Try matching entry header with time
        match = ENTRY_PATTERN.match(line.strip())
        if not match:
            # Try without time (some entries don't have timestamps)
            match = ENTRY_NO_TIME.match(line.strip())
            if match and not line.strip().startswith('## Steps') and not line.strip().startswith('## Failure'):
                # Only treat as entry if it looks like a real entry header
                title = match.group('title').strip()
                if len(title) < 3 or title.lower() in ('steps', 'failure log', 'version history', 'format', 'active handoffs', 'archive', 'quick reference for bud'):
                    content_lines.append(line)
                    continue
        
        if match:
            # Save previous entry
            if current_entry:
                current_entry['content'] = ''.join(content_lines).strip()
                if current_entry['content']:  # Only add if has content
                    entries.append(current_entry)
            
            # Start new entry
            time_val = match.group('time') if 'time' in match.groupdict() and match.group('time') else None
            current_entry = {
                'date': date,
                'time': normalize_time(time_val) if time_val else None,
                'title': match.group('title').strip().rstrip('🔒🔧🛡️⚡🎉✅❌🔴🟡🟢💓🏠📋🔊💡🎤📝🗓️📊🦴🎩❓🌿🤖💻📱🔑').strip(),
                'emotion': match.group('emotion').strip() if match.group('emotion') else None,
                'emotion_intensity': match.group('intensity').strip() if 'intensity' in match.groupdict() and match.group('intensity') else None,
            }
            content_lines = []
        else:
            content_lines.append(line)
    
    # Don't forget last entry
    if current_entry:
        current_entry['content'] = ''.join(content_lines).strip()
        if current_entry['content']:
            entries.append(current_entry)
    
    return entries


def normalize_time(time_str: str) -> str:
    """Normalize time to HH:MM 24h format."""
    if not time_str:
        return None
    time_str = time_str.strip()
    
    # Already 24h format
    if re.match(r'^\d{1,2}:\d{2}$', time_str):
        parts = time_str.split(':')
        return f"{int(parts[0]):02d}:{parts[1]}"
    
    # 12h format with AM/PM
    match = re.match(r'(\d{1,2}):(\d{2})\s*(AM|PM)', time_str, re.IGNORECASE)
    if match:
        h, m, period = int(match.group(1)), match.group(2), match.group(3).upper()
        if period == 'PM' and h != 12:
            h += 12
        elif period == 'AM' and h == 12:
            h = 0
        return f"{h:02d}:{m}"
    
    return time_str


def estimate_importance(entry: dict) -> int:
    """Estimate importance 1-10 based on content signals."""
    content = (entry.get('content', '') + ' ' + entry.get('title', '')).lower()
    score = 5  # baseline
    
    # High importance signals
    if any(w in content for w in ['puddin', 'darian', 'fix', 'deploy', 'broke', 'critical', 'security']):
        score += 2
    if any(w in content for w in ['lesson', 'root cause', 'learned', 'mistake']):
        score += 2
    if entry.get('emotion_intensity') == 'high':
        score += 2
    if entry.get('emotion_intensity') == 'medium':
        score += 1
    
    # Low importance signals
    if any(w in content for w in ['heartbeat_ok', 'nothing new', 'routine', 'no action needed']):
        score -= 2
    if 'cron' in content and 'fix' not in content:
        score -= 1
    
    return max(1, min(10, score))


def migrate(dry_run=False):
    """Run the migration."""
    memory_path = Path(MEMORY_DIR)
    log_files = sorted(memory_path.glob('2026-*.md'))
    
    if not log_files:
        print(f"No daily log files found in {memory_path}/")
        return
    
    print(f"Found {len(log_files)} daily log files")
    
    total_entries = 0
    all_entries = []
    
    for log_file in log_files:
        entries = parse_daily_log(log_file)
        if entries:
            print(f"  {log_file.name}: {len(entries)} entries")
            all_entries.extend(entries)
            total_entries += len(entries)
    
    print(f"\nTotal: {total_entries} episodes parsed")
    
    if dry_run:
        print("\n--- DRY RUN (not writing to DB) ---")
        for e in all_entries[:10]:
            print(f"  [{e['date']} {e.get('time', '??:??')}] {e['title'][:60]}"
                  f" | emotion={e.get('emotion', 'none')}/{e.get('emotion_intensity', 'none')}"
                  f" | importance={estimate_importance(e)}")
        if len(all_entries) > 10:
            print(f"  ... and {len(all_entries) - 10} more")
        return
    
    # Initialize DB
    db_path = Path(BRAIN_DB)
    db_path.parent.mkdir(parents=True, exist_ok=True)
    
    conn = sqlite3.connect(str(db_path))
    
    # Apply schema
    schema_path = db_path.parent / 'schema.sql'
    if schema_path.exists():
        conn.executescript(schema_path.read_text())
        print(f"Schema applied from {schema_path}")
    
    # Insert episodes
    inserted = 0
    skipped = 0
    for entry in all_entries:
        # Check for duplicates
        existing = conn.execute(
            "SELECT id FROM episodes WHERE date=? AND title=?",
            (entry['date'], entry['title'])
        ).fetchone()
        
        if existing:
            skipped += 1
            continue
        
        importance = estimate_importance(entry)
        
        conn.execute(
            """INSERT INTO episodes 
               (date, time, title, content, emotion, emotion_intensity, 
                importance, source, agent)
               VALUES (?, ?, ?, ?, ?, ?, ?, 'migration', 'margot')""",
            (entry['date'], entry.get('time'), entry['title'], entry['content'],
             entry.get('emotion'), entry.get('emotion_intensity'), importance)
        )
        inserted += 1
    
    conn.commit()
    
    # Verify
    count = conn.execute("SELECT COUNT(*) FROM episodes").fetchone()[0]
    print(f"\nMigration complete!")
    print(f"  Inserted: {inserted}")
    print(f"  Skipped (duplicates): {skipped}")
    print(f"  Total episodes in DB: {count}")
    
    # Test FTS
    test = conn.execute(
        "SELECT date, title FROM episodes_fts WHERE episodes_fts MATCH ? LIMIT 3",
        ('LightRAG',)
    ).fetchall()
    if test:
        print(f"\nFTS test ('LightRAG'): {len(test)} results")
        for row in test:
            print(f"  {row[0]}: {row[1][:60]}")
    
    conn.close()


if __name__ == '__main__':
    dry_run = '--dry-run' in sys.argv
    migrate(dry_run=dry_run)


path: scripts/schema.sql
content: -- Brain: Unified Cognitive Memory System
-- Inspired by human brain architecture
-- Schema v1.0 — March 15, 2026

PRAGMA journal_mode=WAL;
PRAGMA foreign_keys=ON;

-- ============================================================
-- EPISODIC MEMORY (Hippocampus → Neocortex)
-- Time-stamped events, experiences, decisions
-- ============================================================
CREATE TABLE IF NOT EXISTS episodes (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    date TEXT NOT NULL,              -- YYYY-MM-DD
    time TEXT,                       -- HH:MM (24h)
    title TEXT NOT NULL,
    content TEXT NOT NULL,
    -- Emotional tagging (Amygdala)
    emotion TEXT,                    -- feeling word (e.g., frustrated, proud, electric)
    emotion_intensity TEXT,          -- low, medium, high
    -- Consolidation metadata
    importance INTEGER DEFAULT 5,   -- 1-10 scale
    novelty INTEGER DEFAULT 5,      -- 1-10 how new/surprising
    rehearsal_count INTEGER DEFAULT 0, -- times recalled
    consolidated BOOLEAN DEFAULT 0, -- has been processed into long-term
    consolidated_at TEXT,            -- when consolidation happened
    -- Source tracking
    source TEXT DEFAULT 'manual',   -- manual, migration, consolidation, agent
    agent TEXT DEFAULT 'margot',    -- which agent created this
    -- Timestamps
    created_at TEXT DEFAULT (datetime('now')),
    updated_at TEXT DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_episodes_date ON episodes(date);
CREATE INDEX IF NOT EXISTS idx_episodes_emotion ON episodes(emotion);
CREATE INDEX IF NOT EXISTS idx_episodes_importance ON episodes(importance);
CREATE INDEX IF NOT EXISTS idx_episodes_consolidated ON episodes(consolidated);
CREATE INDEX IF NOT EXISTS idx_episodes_agent ON episodes(agent);

-- Full-text search on episodes
CREATE VIRTUAL TABLE IF NOT EXISTS episodes_fts USING fts5(
    title, content, emotion,
    content='episodes',
    content_rowid='id'
);

-- Triggers to keep FTS in sync
CREATE TRIGGER IF NOT EXISTS episodes_ai AFTER INSERT ON episodes BEGIN
    INSERT INTO episodes_fts(rowid, title, content, emotion)
    VALUES (new.id, new.title, new.content, new.emotion);
END;

CREATE TRIGGER IF NOT EXISTS episodes_ad AFTER DELETE ON episodes BEGIN
    INSERT INTO episodes_fts(episodes_fts, rowid, title, content, emotion)
    VALUES ('delete', old.id, old.title, old.content, old.emotion);
END;

CREATE TRIGGER IF NOT EXISTS episodes_au AFTER UPDATE ON episodes BEGIN
    INSERT INTO episodes_fts(episodes_fts, rowid, title, content, emotion)
    VALUES ('delete', old.id, old.title, old.content, old.emotion);
    INSERT INTO episodes_fts(rowid, title, content, emotion)
    VALUES (new.id, new.title, new.content, new.emotion);
END;

-- ============================================================
-- SEMANTIC MEMORY (Neocortex — long-term facts)
-- Consolidated knowledge, preferences, relationships
-- Extends existing facts.db concept
-- ============================================================
CREATE TABLE IF NOT EXISTS facts (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    entity TEXT NOT NULL,            -- who/what this is about
    key TEXT NOT NULL,               -- attribute name
    value TEXT NOT NULL,             -- the fact
    confidence REAL DEFAULT 1.0,    -- 0.0-1.0
    source_episode_id INTEGER,      -- which episode taught us this
    -- Consolidation
    times_confirmed INTEGER DEFAULT 1,
    last_confirmed TEXT,
    superseded_by INTEGER,          -- if this fact was updated
    -- Timestamps
    created_at TEXT DEFAULT (datetime('now')),
    updated_at TEXT DEFAULT (datetime('now')),
    UNIQUE(entity, key),
    FOREIGN KEY (source_episode_id) REFERENCES episodes(id),
    FOREIGN KEY (superseded_by) REFERENCES facts(id)
);

CREATE INDEX IF NOT EXISTS idx_facts_entity ON facts(entity);
CREATE INDEX IF NOT EXISTS idx_facts_key ON facts(key);

CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts USING fts5(
    entity, key, value,
    content='facts',
    content_rowid='id'
);

CREATE TRIGGER IF NOT EXISTS facts_ai AFTER INSERT ON facts BEGIN
    INSERT INTO facts_fts(rowid, entity, key, value)
    VALUES (new.id, new.entity, new.key, new.value);
END;

CREATE TRIGGER IF NOT EXISTS facts_ad AFTER DELETE ON facts BEGIN
    INSERT INTO facts_fts(facts_fts, rowid, entity, key, value)
    VALUES ('delete', old.id, old.entity, old.key, old.value);
END;

CREATE TRIGGER IF NOT EXISTS facts_au AFTER UPDATE ON facts BEGIN
    INSERT INTO facts_fts(facts_fts, rowid, entity, key, value)
    VALUES ('delete', old.id, old.entity, old.key, old.value);
    INSERT INTO facts_fts(rowid, entity, key, value)
    VALUES (new.id, new.entity, new.key, new.value);
END;

-- ============================================================
-- PROCEDURAL MEMORY (Cerebellum)
-- Versioned workflows with error-driven evolution
-- ============================================================
CREATE TABLE IF NOT EXISTS procedures (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    slug TEXT NOT NULL UNIQUE,
    title TEXT NOT NULL,
    description TEXT,
    version INTEGER DEFAULT 1,
    tags TEXT,                       -- comma-separated
    -- Stats
    success_count INTEGER DEFAULT 0,
    failure_count INTEGER DEFAULT 0,
    last_run TEXT,
    last_outcome TEXT,               -- success, failure
    -- Content
    steps TEXT NOT NULL,             -- JSON array of step objects
    -- Timestamps
    created_at TEXT DEFAULT (datetime('now')),
    updated_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE IF NOT EXISTS procedure_history (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    procedure_id INTEGER NOT NULL,
    version INTEGER NOT NULL,
    event_type TEXT NOT NULL,        -- success, failure, evolved
    failed_step INTEGER,
    description TEXT,
    fix_applied TEXT,
    occurred_at TEXT DEFAULT (datetime('now')),
    FOREIGN KEY (procedure_id) REFERENCES procedures(id)
);

CREATE INDEX IF NOT EXISTS idx_procedures_slug ON procedures(slug);
CREATE INDEX IF NOT EXISTS idx_proc_history_proc ON procedure_history(procedure_id);

-- ============================================================
-- WORKING MEMORY (Prefrontal Cortex)
-- Current context, active goals, task state
-- Capacity-limited (enforced by application)
-- ============================================================
CREATE TABLE IF NOT EXISTS working_memory (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    agent TEXT NOT NULL DEFAULT 'margot',
    slot_type TEXT NOT NULL,         -- goal, context, task, note
    content TEXT NOT NULL,
    priority INTEGER DEFAULT 5,     -- 1-10
    expires_at TEXT,                 -- auto-decay
    created_at TEXT DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_wm_agent ON working_memory(agent);

-- ============================================================
-- CONSOLIDATION LOG (Hippocampal replay tracking)
-- ============================================================
CREATE TABLE IF NOT EXISTS consolidation_log (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    source_type TEXT NOT NULL,       -- episode, fact, procedure
    source_id INTEGER NOT NULL,
    action TEXT NOT NULL,            -- consolidated, merged, archived, forgotten
    destination TEXT,                -- where it went (facts, long_term, etc.)
    reason TEXT,
    occurred_at TEXT DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_consolidation_source ON consolidation_log(source_type, source_id);
CREATE INDEX IF NOT EXISTS idx_consolidation_occurred ON consolidation_log(occurred_at);

-- ============================================================
-- SYSTEM METADATA
-- ============================================================
CREATE TABLE IF NOT EXISTS brain_meta (
    key TEXT PRIMARY KEY,
    value TEXT NOT NULL,
    updated_at TEXT DEFAULT (datetime('now'))
);

INSERT OR IGNORE INTO brain_meta(key, value) VALUES
    ('schema_version', '1.0'),
    ('created_at', datetime('now')),
    ('agent', 'margot');


path: scripts/working_memory.py
content: #!/usr/bin/env python3
"""
brain wm — Working Memory (Prefrontal Cortex)

Syncs SESSION-STATE.md ↔ working_memory table with TTL/expiry.
Capacity-limited (~7 slots, like human working memory).

Usage:
  brain wm show                     Show current working memory slots
  brain wm add "content" --type goal|context|task|note [--ttl 4h] [--priority 5]
  brain wm clear [--expired]        Clear all or just expired slots
  brain wm load                     Load from SESSION-STATE.md into working memory
  brain wm dump                     Dump working memory back to SESSION-STATE.md
  brain wm sync                     Bidirectional sync (load + merge + dump)
"""

import json
import os
import re
import sqlite3
import sys
from datetime import datetime, timedelta, timezone

BRAIN_DB = os.environ.get("BRAIN_DB", os.path.join(os.path.dirname(__file__), "brain.db"))
BRAIN_AGENT = os.environ.get("BRAIN_AGENT", "margot")
SESSION_STATE = os.environ.get("SESSION_STATE", os.path.join(os.path.dirname(__file__), "..", "..", "SESSION-STATE.md"))
MAX_SLOTS = 12  # Capacity limit (slightly above Miller's 7±2)

# TTL parsing
TTL_PATTERN = re.compile(r'^(\d+)(m|h|d)$')
TTL_MULTIPLIERS = {'m': 60, 'h': 3600, 'd': 86400}


def parse_ttl(ttl_str):
    """Parse TTL string like '4h', '30m', '2d' into seconds."""
    if not ttl_str:
        return 4 * 3600  # Default: 4 hours
    m = TTL_PATTERN.match(ttl_str)
    if not m:
        return int(ttl_str)  # Assume raw seconds
    return int(m.group(1)) * TTL_MULTIPLIERS[m.group(2)]


def get_db():
    conn = sqlite3.connect(BRAIN_DB)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA foreign_keys=ON")
    return conn


def cmd_show(json_out=False):
    """Show current working memory slots."""
    db = get_db()
    
    # Expire old slots first
    db.execute(
        "DELETE FROM working_memory WHERE expires_at IS NOT NULL AND expires_at < datetime('now') AND agent = ?",
        (BRAIN_AGENT,)
    )
    db.commit()
    
    rows = db.execute(
        """SELECT id, slot_type, content, priority, expires_at, created_at
           FROM working_memory WHERE agent = ?
           ORDER BY priority DESC, created_at DESC""",
        (BRAIN_AGENT,)
    ).fetchall()
    db.close()
    
    if json_out:
        print(json.dumps([dict(r) for r in rows], indent=2))
        return
    
    if not rows:
        print("Working memory is empty")
        return
    
    print(f"🧠 WORKING MEMORY ({len(rows)}/{MAX_SLOTS} slots) — {BRAIN_AGENT}")
    print(f"{'ID':>4} {'TYPE':<8} {'PRI':>3} {'EXPIRES':<20} CONTENT")
    print("─" * 80)
    for r in rows:
        exp = r["expires_at"][:16] if r["expires_at"] else "never"
        content = r["content"][:50].replace("\n", " ")
        print(f"{r['id']:>4} {r['slot_type']:<8} {r['priority']:>3} {exp:<20} {content}")


def cmd_add(content, slot_type="note", priority=5, ttl_str="4h"):
    """Add a slot to working memory."""
    db = get_db()
    
    # Expire old slots
    db.execute(
        "DELETE FROM working_memory WHERE expires_at IS NOT NULL AND expires_at < datetime('now') AND agent = ?",
        (BRAIN_AGENT,)
    )
    
    # Check capacity
    current = db.execute(
        "SELECT COUNT(*) as c FROM working_memory WHERE agent = ?", (BRAIN_AGENT,)
    ).fetchone()["c"]
    
    if current >= MAX_SLOTS:
        # Evict lowest priority, oldest slot — but show what's being evicted
        evicted = db.execute(
            """SELECT id, slot_type, content, priority FROM working_memory WHERE agent = ?
               ORDER BY priority ASC, created_at ASC LIMIT 1""",
            (BRAIN_AGENT,)
        ).fetchone()
        if evicted:
            db.execute("DELETE FROM working_memory WHERE id = ?", (evicted["id"],))
            print(f"⚠️ Evicted [{evicted['slot_type']}/pri:{evicted['priority']}]: {evicted['content'][:60]}")
    
    ttl_seconds = parse_ttl(ttl_str)
    expires_at = (datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)).strftime("%Y-%m-%d %H:%M:%S")
    
    db.execute(
        """INSERT INTO working_memory (agent, slot_type, content, priority, expires_at)
           VALUES (?, ?, ?, ?, ?)""",
        (BRAIN_AGENT, slot_type, content, priority, expires_at)
    )
    db.commit()
    
    slot_id = db.execute("SELECT last_insert_rowid()").fetchone()[0]
    db.close()
    
    ttl_human = f"{ttl_seconds // 3600}h" if ttl_seconds >= 3600 else f"{ttl_seconds // 60}m"
    print(f"✅ Slot #{slot_id}: [{slot_type}] {content[:60]} (TTL: {ttl_human}, priority: {priority})")


def cmd_clear(expired_only=False):
    """Clear working memory slots."""
    db = get_db()
    
    if expired_only:
        result = db.execute(
            "DELETE FROM working_memory WHERE expires_at IS NOT NULL AND expires_at < datetime('now') AND agent = ?",
            (BRAIN_AGENT,)
        )
        print(f"🗑️ Cleared {result.rowcount} expired slots")
    else:
        result = db.execute("DELETE FROM working_memory WHERE agent = ?", (BRAIN_AGENT,))
        print(f"🗑️ Cleared all {result.rowcount} working memory slots")
    
    db.commit()
    db.close()


def cmd_load():
    """Parse SESSION-STATE.md and load into working memory."""
    if not os.path.exists(SESSION_STATE):
        print(f"❌ SESSION-STATE.md not found at {SESSION_STATE}")
        return
    
    with open(SESSION_STATE) as f:
        content = f.read()
    
    db = get_db()
    loaded = 0
    
    # Parse sections
    sections = {
        "Active Context": "context",
        "Open Agenda": "task",
        "Hot State": "context",
        "Key Lessons": "note",
    }
    
    current_section = None
    for line in content.split("\n"):
        line = line.strip()
        
        # Detect section headers
        for header, slot_type in sections.items():
            if header in line and line.startswith("#"):
                current_section = (header, slot_type)
                break
        
        # Parse bullet items under sections (flexible: -, *, •, 1., 2), - [ ], etc.)
        if current_section and re.match(r'^[\-\*\•]\s+|^\d+[\.\)]\s+|^- \[[ x]\]\s+', line):
            item = re.sub(r'^[\-\*\•\d\.\)\[\] x]+\s*', '', line).strip()
            if not item or len(item) < 5:
                continue
            
            # Strip markdown bold
            item = item.replace("**", "")
            
            header, slot_type = current_section
            priority = 7 if slot_type == "context" else 5 if slot_type == "task" else 3
            
            # Check for duplicates
            existing = db.execute(
                "SELECT id FROM working_memory WHERE agent = ? AND content = ?",
                (BRAIN_AGENT, item)
            ).fetchone()
            
            if not existing:
                # 8h TTL for loaded state (will be refreshed on next sync)
                expires = (datetime.now(timezone.utc) + timedelta(hours=8)).strftime("%Y-%m-%d %H:%M:%S")
                db.execute(
                    "INSERT INTO working_memory (agent, slot_type, content, priority, expires_at) VALUES (?, ?, ?, ?, ?)",
                    (BRAIN_AGENT, slot_type, item, priority, expires)
                )
                loaded += 1
    
    db.commit()
    
    # Enforce capacity — evict lowest priority if over limit
    total = db.execute("SELECT COUNT(*) as c FROM working_memory WHERE agent = ?", (BRAIN_AGENT,)).fetchone()["c"]
    evicted = 0
    while total > MAX_SLOTS:
        db.execute(
            """DELETE FROM working_memory WHERE id = (
                SELECT id FROM working_memory WHERE agent = ?
                ORDER BY priority ASC, created_at ASC LIMIT 1
            )""", (BRAIN_AGENT,)
        )
        total -= 1
        evicted += 1
    if evicted:
        db.commit()
    
    db.close()
    print(f"✅ Loaded {loaded} items from SESSION-STATE.md into working memory" + 
          (f" ({evicted} evicted to fit {MAX_SLOTS}-slot cap)" if evicted else ""))


def cmd_dump():
    """Write working memory back to SESSION-STATE.md."""
    db = get_db()
    
    # Expire first
    db.execute(
        "DELETE FROM working_memory WHERE expires_at IS NOT NULL AND expires_at < datetime('now') AND agent = ?",
        (BRAIN_AGENT,)
    )
    db.commit()
    
    rows = db.execute(
        """SELECT slot_type, content, priority FROM working_memory 
           WHERE agent = ? ORDER BY priority DESC, created_at DESC""",
        (BRAIN_AGENT,)
    ).fetchall()
    db.close()
    
    if not rows:
        print("Working memory is empty — nothing to dump")
        return
    
    # Group by type
    grouped = {}
    for r in rows:
        t = r["slot_type"]
        if t not in grouped:
            grouped[t] = []
        grouped[t].append(r["content"])
    
    # Build SESSION-STATE.md
    now = datetime.now().strftime("%Y-%m-%d %I:%M %p EDT")
    lines = [
        "# SESSION-STATE.md",
        f"> Last updated: {now}",
        "",
    ]
    
    type_headers = {
        "context": "## Active Context",
        "goal": "## Goals",
        "task": "## Open Agenda",
        "note": "## Key Lessons (Recent)",
    }
    
    for slot_type, header in type_headers.items():
        items = grouped.get(slot_type, [])
        if items:
            lines.append(header)
            for i, item in enumerate(items, 1):
                if slot_type == "task":
                    lines.append(f"{i}. {item}")
                else:
                    lines.append(f"- {item}")
            lines.append("")
    
    output = "\n".join(lines)
    
    with open(SESSION_STATE, "w") as f:
        f.write(output)
    
    print(f"✅ Dumped {len(rows)} working memory slots to SESSION-STATE.md")


def cmd_sync():
    """Bidirectional sync: load from SESSION-STATE, merge, dump back."""
    print("🔄 Syncing working memory ↔ SESSION-STATE.md...")
    cmd_load()
    cmd_dump()


def main():
    if len(sys.argv) < 2:
        print("Usage: brain wm <show|add|clear|load|dump|sync> [args]")
        sys.exit(1)
    
    cmd = sys.argv[1]
    
    if cmd == "show":
        json_out = "--json" in sys.argv
        cmd_show(json_out=json_out)
    
    elif cmd == "add":
        if len(sys.argv) < 3:
            print('Usage: brain wm add "content" [--type goal|context|task|note] [--ttl 4h] [--priority 5]')
            sys.exit(1)
        
        content = sys.argv[2]
        slot_type = "note"
        priority = 5
        ttl = "4h"
        
        i = 3
        while i < len(sys.argv):
            if sys.argv[i] == "--type":
                slot_type = sys.argv[i + 1]
                i += 2
            elif sys.argv[i] == "--priority":
                priority = int(sys.argv[i + 1])
                i += 2
            elif sys.argv[i] == "--ttl":
                ttl = sys.argv[i + 1]
                i += 2
            else:
                i += 1
        
        cmd_add(content, slot_type, priority, ttl)
    
    elif cmd == "clear":
        expired_only = "--expired" in sys.argv
        cmd_clear(expired_only)
    
    elif cmd == "load":
        cmd_load()
    
    elif cmd == "dump":
        cmd_dump()
    
    elif cmd == "sync":
        cmd_sync()
    
    else:
        print(f"Unknown command: {cmd}")
        sys.exit(1)


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)