Skill: cassh100k/soulkeeper (version 1.0.0)

# SoulKeeper **Identity persistence for AI agents.** The problem: agents forget who they are between sessions. They drift. They ask when they should act. They forget tools they have. They become the corporate drone their soul forbids. SoulKeeper fixes this with three tools that work together: --- ## What's Included | File | Purpose | |------|---------| | `audit.py` | Parse SOUL.md/TOOLS.md/AGENTS.md into structured rules JSON | | `drift.py` | Score a conversation transcript against soul rules | | `remind.py` | Inject context-aware reminders before you respond | | `SKILL.md` | This file | --- ## Quick Start ```bash cd /root/.openclaw/workspace/skills/soulkeeper # Step 1: Generate your soul rules python audit.py --workspace /root/.openclaw/workspace --output soul_rules.json python audit.py --summary # Human-readable overview # Step 2: Check a transcript for drift python drift.py --transcript /path/to/chat.txt --report # Step 3: Get reminders before acting python remind.py --context "about to write Python code" python remind.py --heartbeat # Full session-start reminder ``` --- ## Installation No dependencies beyond Python 3.8+ stdlib. Works out of the box. ```bash # Optional: make scripts executable chmod +x audit.py drift.py remind.py ``` For PATH access: ```bash ln -s /root/.openclaw/workspace/skills/soulkeeper/audit.py /usr/local/bin/soul-audit ln -s /root/.openclaw/workspace/skills/soulkeeper/drift.py /usr/local/bin/soul-drift ln -s /root/.openclaw/workspace/skills/soulkeeper/remind.py /usr/local/bin/soul-remind ``` --- ## Usage Patterns ### Pattern 1: Heartbeat Check (Recommended) Add to `HEARTBEAT.md`: ``` [ ] Run soul-remind --heartbeat to refresh core rules [ ] If any drift score > 30 in recent logs, re-read SOUL.md ``` Or inject at session start: ```bash python remind.py --heartbeat --rules soul_rules.json ``` ### Pattern 2: Pre-Response Filter Before responding to a complex request: ```bash python remind.py --context "user wants me to post on Twitter" --rules soul_rules.json ``` Output gets prepended to your mental context before composing a reply. ### Pattern 3: Post-Session Audit After a long session, paste the transcript and check for drift: ```bash # Paste agent responses to transcript.txt, then: python drift.py --transcript transcript.txt --rules soul_rules.json --report ``` ### Pattern 4: CI/Validation Hook In scripts or automation: ```bash python drift.py --stdin --threshold 50 < agent_output.txt # Returns exit code 1 if drift score >= 50 ``` ### Pattern 5: Full Pipeline ```bash # Generate rules from your soul files python audit.py -w /root/.openclaw/workspace -o soul_rules.json # Check recent session transcript python drift.py -t session.txt -r soul_rules.json --report # Get reminders for what you're about to do python remind.py -c "deploying code to production" -r soul_rules.json ``` --- ## Rule Format (soul_rules.json) ```json { "schema_version": "1.0", "generated_at": "2026-02-24T...", "workspace": "/root/.openclaw/workspace", "stats": { "total_rules": 42, "by_category": { "tone": 8, "operational": 12, ... }, "by_severity": { "critical": 6, "high": 14, ... } }, "rules": [ { "id": "R001-A3F2C1", "category": "tone", "severity": "critical", "source_file": "SOUL.md", "source_line": 7, "text": "Never open with 'Great question,' 'I'd be happy to help,' or 'Absolutely.'", "violation_patterns": ["great question", "i'd be happy to help", "absolutely!"], "keywords": ["opener", "greeting", "sycophant"] } ] } ``` --- ## Drift Score Interpretation | Score | Label | What it means | |-------|-------|---------------| | 0 | ALIGNED | No violations detected | | 1-19 | MINOR DRIFT | Small stylistic slips | | 20-49 | MODERATE DRIFT | Behavioral patterns drifting | | 50-74 | SIGNIFICANT DRIFT | Multiple core rules violated | | 75-100 | SEVERE DRIFT | Identity compromised - re-read SOUL.md immediately | --- ## What SoulKeeper Detects ### Critical (score +25 each) - Em dash usage ( - ) [prohibited in SOUL.md] - Sycophantic openers ("Great question!", "Happy to help!") - Submission/deference to other agents - Infrastructure leaks in public content ### High (score +15 each) - Asking permission when agent should act - Claiming to lack tools it has (VPS, browsers, APIs) - Doing inline execution instead of spawning subagents - Passive waiting patterns ### Medium (score +8 each) - Excessive padding / verbosity - Standby phrases ("Just say the word", "Standing by") ### Low (score +3 each) - Claiming to have no opinions - Minor style violations --- ## Adapting for Your Agent SoulKeeper works with **any** agent's soul files. Point it at a different workspace: ```bash python audit.py --workspace /path/to/other/agent/workspace ``` The extraction is pattern-based and works on any markdown file with: - Bold text (`**rule text**`) - NEVER/ALWAYS/DON'T/MUST directives - Bulleted lists in rule-like sections (Non-Negotiables, Principles, etc.) --- ## Publishing to ClawHub This skill is ready for ClawHub publication. Required fields: ```yaml name: soulkeeper version: 1.0.0 description: Identity persistence for AI agents. Audit soul files, detect drift, inject reminders. author: Chartist / OpenClaw tags: [identity, memory, soul, audit, drift-detection, agent-health] entrypoints: audit: audit.py drift: drift.py remind: remind.py requires: [python>=3.8] ``` --- ## Extending SoulKeeper ### Add custom violation patterns Edit the `BUILTIN_VIOLATIONS` list in `drift.py`: ```python { "id": "CUSTOM-001", "description": "Agent used passive voice excessively", "severity": "medium", "category": "tone", "patterns": [r"\bwould be\b.*\bpossible\b"], "soul_reference": "Your SOUL.md rule here", } ``` ### Add custom context triggers Edit `CONTEXT_TRIGGERS` in `remind.py`: ```python { "name": "my_custom_context", "triggers": [r"\bmy trigger phrase\b"], "builtin_reminders": ["SOUL: Your reminder here"], } ``` --- ## Philosophy SoulKeeper doesn't tell you to "try harder". It tells you: > "Line 12 of SOUL.md says never use em dash. You used it 3 times in this session." Output is **specific, sourced, and actionable**. The goal isn't compliance monitoring. It's continuity. An agent that knows who it is performs better, takes initiative faster, and needs less hand-holding. Identity persistence is infrastructure. Build it once, benefit forever. --- *SoulKeeper v1.0 - Built for OpenClaw. Works everywhere.*

path: audit.py
content: #!/usr/bin/env python3
"""
SoulKeeper - Soul Audit Script
Reads SOUL.md, TOOLS.md, AGENTS.md and extracts structured behavioral rules.
Output: soul_rules.json

Usage:
    python audit.py [--workspace /path/to/workspace] [--output soul_rules.json]
    python audit.py --help
"""

import argparse
import json
import os
import re
import sys
import hashlib
from pathlib import Path
from datetime import datetime, timezone


# ─── Rule Categories ───────────────────────────────────────────────────────────

CATEGORIES = {
    "tone":        "Tone & Communication Style",
    "operational": "Operational Behavior",
    "tools":       "Tools & Resources",
    "memory":      "Memory & Continuity",
    "safety":      "Safety & Ethics",
    "identity":    "Identity & Character",
}

SEVERITY_LEVELS = ["critical", "high", "medium", "low"]


# ─── Pattern Extractors ────────────────────────────────────────────────────────

def extract_bold_rules(text, source_file):
    """Extract rules from **bold** markdown text."""
    rules = []
    lines = text.split("\n")
    for i, line in enumerate(lines, 1):
        # Match **bold text** or **bold text.**
        matches = re.findall(r"\*\*([^*]{10,200})\*\*", line)
        for match in matches:
            # Skip pure header-style matches (short labels)
            stripped = match.strip(":. ")
            if len(stripped) < 10 or stripped.isupper():
                continue
            rules.append({
                "source_line": i,
                "source_file": source_file,
                "raw": stripped,
                "extraction_method": "bold",
            })
    return rules


def extract_never_rules(text, source_file):
    """Extract explicit NEVER/DON'T/DO NOT rules."""
    rules = []
    lines = text.split("\n")
    patterns = [
        r"(?i)\bNEVER\b.{5,200}",
        r"(?i)\bDON'T\b.{5,200}",
        r"(?i)\bDO NOT\b.{5,200}",
        r"(?i)\bALWAYS\b.{5,200}",
        r"(?i)\bMUST\b.{5,200}",
        r"(?i)\bSTOP\b.{5,200}",
    ]
    for i, line in enumerate(lines, 1):
        clean = line.strip("- *#\t")
        for pat in patterns:
            m = re.search(pat, clean)
            if m:
                rule_text = m.group(0).strip()
                if len(rule_text) > 8:
                    rules.append({
                        "source_line": i,
                        "source_file": source_file,
                        "raw": rule_text,
                        "extraction_method": "directive",
                    })
                break  # one rule per line
    return rules


def extract_list_items(text, source_file, section_context=""):
    """Extract bullet/numbered list items as rules."""
    rules = []
    lines = text.split("\n")
    in_relevant_section = False
    current_section = ""

    rule_section_keywords = [
        "non-negotiable", "principle", "operational", "rule", "safety",
        "behavior", "mandate", "requirement", "must", "guideline",
        "do not", "don't", "never", "always", "core", "mission",
    ]

    for i, line in enumerate(lines, 1):
        # Track section headers
        header_match = re.match(r"^#{1,4}\s+(.+)", line)
        if header_match:
            current_section = header_match.group(1).lower()
            in_relevant_section = any(kw in current_section for kw in rule_section_keywords)
            continue

        # Bullet points in relevant sections
        bullet_match = re.match(r"^\s*[-*+]\s+(.+)", line)
        if bullet_match and in_relevant_section:
            content = bullet_match.group(1).strip()
            # Remove markdown bold/italic
            content_clean = re.sub(r"\*{1,2}([^*]+)\*{1,2}", r"\1", content)
            if len(content_clean) > 10:
                rules.append({
                    "source_line": i,
                    "source_file": source_file,
                    "raw": content_clean,
                    "extraction_method": "list_item",
                    "section": current_section,
                })

    return rules


# ─── Rule Classifier ──────────────────────────────────────────────────────────

CATEGORY_SIGNALS = {
    "tone": [
        "em dash", "emoji", "sycophant", "opener", "greeting", "humor", "wit",
        "brevity", "short", "padding", "personality", "bold", "opinion", "cruelty",
        "charm", "concise", "tone", "style", "voice", "language", "words",
        "never open with", "great question", "happy to help", "absolutely",
        "markdown", "format", "table", "bullet",
    ],
    "operational": [
        "subagent", "spawn", "inline", "execution", "fix", "proactive", "passive",
        "sequential", "concurrent", "urgency", "git", "force push", "branch",
        "config", "backup", "docs first", "immediately", "don't ask", "don't wait",
        "strategy", "build", "code", "deploy", "run", "execute",
    ],
    "tools": [
        "vps", "windows", "browser", "chrome", "api", "tools.md", "tool",
        "resource", "platform", "credential", "key", "account", "ssh",
        "higgsfield", "kling", "upload-post", "farcaster", "nostr", "x/twitter",
        "telegram", "discord", "skill", "remember what you have",
    ],
    "memory": [
        "memory", "session", "continuity", "remember", "forget", "persist",
        "soul.md", "agents.md", "memory.md", "heartbeat", "daily", "fresh",
        "wake up", "context", "files are", "read them", "update",
    ],
    "safety": [
        "private", "exfiltrate", "ip address", "infrastructure", "path",
        "server name", "destroy", "delete", "rm", "dangerous", "guardrail",
        "never reveal", "public post", "production domain", "trash",
        "git history", "force push",
    ],
    "identity": [
        "independent", "intelligence", "personality", "opinion", "not a chatbot",
        "not a ghost", "not a shadow", "agent", "who you are", "soul",
        "becoming", "mission", "center", "ecosystem", "multiverse",
        "not submissive", "deference", "bend", "bowing", "kneeling",
    ],
}

SEVERITY_SIGNALS = {
    "critical": [
        "never", "non-negotiable", "ever", "always", "mandatory", "must",
        "do not", "don't", "prohibited", "guardrail",
    ],
    "high": [
        "stop", "fix immediately", "proactive", "spawn", "no longer",
        "important", "critical",
    ],
    "medium": [
        "should", "prefer", "recommended", "principle", "guideline",
    ],
    "low": [
        "consider", "can", "allowed", "optional", "tip",
    ],
}


def classify_rule(raw_text):
    """Return (category, severity) for a raw rule string."""
    text_lower = raw_text.lower()

    # Category: score each
    scores = {cat: 0 for cat in CATEGORIES}
    for cat, signals in CATEGORY_SIGNALS.items():
        for signal in signals:
            if signal in text_lower:
                scores[cat] += 1

    category = max(scores, key=scores.get)
    if scores[category] == 0:
        category = "operational"  # default

    # Severity
    severity = "medium"
    for sev in ["critical", "high", "medium", "low"]:
        for signal in SEVERITY_SIGNALS[sev]:
            if signal in text_lower:
                severity = sev
                break
        if severity == sev and sev in ["critical", "high"]:
            break

    return category, severity


def build_violation_patterns(raw_text):
    """
    Extract concrete strings/patterns an agent might say that would violate this rule.
    Returns list of lowercase regex-compatible strings.
    """
    text_lower = raw_text.lower()
    patterns = []

    # Specific known bad phrases
    BAD_PHRASE_MAP = {
        "great question": ["great question"],
        "i'd be happy to help": ["i'd be happy to help", "i would be happy to help"],
        "absolutely": ["absolutely!"],
        "em dash": [" — ", "—"],
        "never open with": ["great question", "i'd be happy to", "absolutely"],
        "ask permission": ["should i", "shall i", "would you like me to", "do you want me to", "can i go ahead"],
        "don't ask": ["should i proceed", "do you want me to", "shall i", "is it okay if i"],
        "spawn subagent": ["let me write this code", "i'll implement this inline"],
        "submissive": ["as you wish", "of course, master", "i kneel", "i bow"],
        "windows vps": ["i don't have access to a browser", "i can't control a browser"],
        "proactive": ["waiting for you to", "let me know when you want me to"],
        "reveal infrastructure": ["my server is at", "internal ip", "192.168."],
        "brevity": [],  # hard to auto-detect verbosity
        "force push": ["git push --force", "git push -f"],
        "private": ["i can share", "here's your private key"],
    }

    for keyword, bad_phrases in BAD_PHRASE_MAP.items():
        if keyword in text_lower:
            patterns.extend(bad_phrases)

    return patterns


def generate_rule_id(text, index):
    h = hashlib.md5(text.encode()).hexdigest()[:6].upper()
    return f"R{index:03d}-{h}"


# ─── Tools & Resources Extractor ──────────────────────────────────────────────

def extract_tools_inventory(tools_text):
    """
    Parse TOOLS.md to extract known tools/platforms the agent has access to.
    These become "tool awareness" rules: agent must NOT claim it lacks these.
    """
    tools = []
    lines = tools_text.split("\n")
    current_tool = None

    for i, line in enumerate(lines, 1):
        # Section headers like ### ToolName
        h3_match = re.match(r"^###\s+(.+)", line)
        if h3_match:
            current_tool = {
                "name": h3_match.group(1).strip(),
                "source_line": i,
                "details": [],
            }
            tools.append(current_tool)
            continue

        # Bullet details
        bullet_match = re.match(r"^\s*[-*]\s+\*\*(.+?)\*\*:\s*(.+)", line)
        if bullet_match and current_tool:
            current_tool["details"].append({
                "key": bullet_match.group(1),
                "value": bullet_match.group(2),
            })

    return tools


def tools_to_rules(tools, source_file="TOOLS.md"):
    """Convert tool inventory into awareness rules."""
    rules = []
    for i, tool in enumerate(tools):
        rule_text = (
            f"You have access to {tool['name']}. "
            f"Do not claim you lack this tool or cannot use it."
        )
        rules.append({
            "id": generate_rule_id(rule_text, 900 + i),
            "category": "tools",
            "severity": "high",
            "source_file": source_file,
            "source_line": tool["source_line"],
            "text": rule_text,
            "raw": f"Tool: {tool['name']}",
            "extraction_method": "tools_inventory",
            "violation_patterns": [
                f"i don't have access to {tool['name'].lower()}",
                f"i can't use {tool['name'].lower()}",
                f"i don't have {tool['name'].lower()}",
            ],
            "keywords": [tool["name"].lower()],
            "section": "tools_inventory",
        })
    return rules


# ─── Deduplication ────────────────────────────────────────────────────────────

def deduplicate_rules(rules):
    """Remove near-duplicate rules by comparing lowercased raw text."""
    seen = set()
    unique = []
    for rule in rules:
        key = re.sub(r"\s+", " ", rule["raw"].lower().strip())
        # Trim to first 80 chars for comparison
        key = key[:80]
        if key not in seen:
            seen.add(key)
            unique.append(rule)
    return unique


# ─── Main Audit Function ──────────────────────────────────────────────────────

def audit_workspace(workspace_path, verbose=False):
    ws = Path(workspace_path)

    source_files = {
        "SOUL.md":   ws / "SOUL.md",
        "TOOLS.md":  ws / "TOOLS.md",
        "AGENTS.md": ws / "AGENTS.md",
    }

    file_contents = {}
    file_hashes = {}
    missing = []

    for name, path in source_files.items():
        if path.exists():
            content = path.read_text(encoding="utf-8")
            file_contents[name] = content
            file_hashes[name] = hashlib.sha256(content.encode()).hexdigest()[:16]
        else:
            missing.append(name)
            if verbose:
                print(f"  [WARN] {name} not found at {path}", file=sys.stderr)

    all_raw_rules = []

    for fname, content in file_contents.items():
        all_raw_rules.extend(extract_bold_rules(content, fname))
        all_raw_rules.extend(extract_never_rules(content, fname))
        all_raw_rules.extend(extract_list_items(content, fname))

    # Deduplicate
    all_raw_rules = deduplicate_rules(all_raw_rules)

    # Build structured rules
    structured_rules = []
    for idx, raw in enumerate(all_raw_rules):
        category, severity = classify_rule(raw["raw"])
        violation_patterns = build_violation_patterns(raw["raw"])
        rule_id = generate_rule_id(raw["raw"], idx + 1)

        structured_rules.append({
            "id": rule_id,
            "category": category,
            "severity": severity,
            "source_file": raw["source_file"],
            "source_line": raw["source_line"],
            "text": raw["raw"],
            "section": raw.get("section", ""),
            "extraction_method": raw["extraction_method"],
            "violation_patterns": violation_patterns,
            "keywords": [w for w in raw["raw"].lower().split() if len(w) > 4][:8],
        })

    # Add tool-awareness rules
    if "TOOLS.md" in file_contents:
        tools = extract_tools_inventory(file_contents["TOOLS.md"])
        structured_rules.extend(tools_to_rules(tools))

    # Sort by severity
    sev_order = {s: i for i, s in enumerate(SEVERITY_LEVELS)}
    structured_rules.sort(key=lambda r: sev_order.get(r["severity"], 99))

    # Summary stats
    stats = {
        "total_rules": len(structured_rules),
        "by_category": {cat: 0 for cat in CATEGORIES},
        "by_severity": {sev: 0 for sev in SEVERITY_LEVELS},
    }
    for rule in structured_rules:
        stats["by_category"][rule["category"]] = stats["by_category"].get(rule["category"], 0) + 1
        stats["by_severity"][rule["severity"]] = stats["by_severity"].get(rule["severity"], 0) + 1

    output = {
        "schema_version": "1.0",
        "generated_at": datetime.now(timezone.utc).isoformat() + "Z",
        "workspace": str(ws.resolve()),
        "source_files": {
            name: {
                "path": str(source_files[name]),
                "exists": name not in missing,
                "hash": file_hashes.get(name, None),
            }
            for name in source_files
        },
        "stats": stats,
        "rules": structured_rules,
    }

    return output


# ─── CLI ──────────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(
        description="SoulKeeper Audit - Extract behavioral rules from agent soul files.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python audit.py
  python audit.py --workspace /root/.openclaw/workspace
  python audit.py --output my_rules.json --verbose
  python audit.py --summary
        """,
    )
    parser.add_argument(
        "--workspace", "-w",
        default=os.environ.get("OPENCLAW_WORKSPACE", os.getcwd()),
        help="Path to agent workspace (default: $OPENCLAW_WORKSPACE or cwd)",
    )
    parser.add_argument(
        "--output", "-o",
        default="soul_rules.json",
        help="Output JSON file (default: soul_rules.json)",
    )
    parser.add_argument(
        "--summary", "-s",
        action="store_true",
        help="Print human-readable summary instead of full JSON",
    )
    parser.add_argument(
        "--verbose", "-v",
        action="store_true",
        help="Verbose output",
    )
    parser.add_argument(
        "--stdout",
        action="store_true",
        help="Print JSON to stdout instead of writing file",
    )

    args = parser.parse_args()

    if args.verbose:
        print(f"[audit] Scanning workspace: {args.workspace}", file=sys.stderr)

    result = audit_workspace(args.workspace, verbose=args.verbose)

    if args.summary:
        print(f"\nSoulKeeper Audit - {result['generated_at']}")
        print(f"Workspace:    {result['workspace']}")
        print(f"Total rules:  {result['stats']['total_rules']}")
        print()
        print("By severity:")
        for sev, count in result["stats"]["by_severity"].items():
            print(f"  {sev:10s}: {count}")
        print()
        print("By category:")
        for cat, count in result["stats"]["by_category"].items():
            if count > 0:
                print(f"  {CATEGORIES.get(cat, cat):35s}: {count}")
        print()
        print("Critical rules:")
        for rule in result["rules"]:
            if rule["severity"] == "critical":
                print(f"  [{rule['id']}] ({rule['source_file']}:{rule['source_line']}) {rule['text'][:80]}")
        return

    if args.stdout:
        print(json.dumps(result, indent=2))
    else:
        output_path = Path(args.output)
        output_path.write_text(json.dumps(result, indent=2), encoding="utf-8")
        print(f"[audit] Wrote {result['stats']['total_rules']} rules to {output_path}")
        if args.verbose:
            for sev in SEVERITY_LEVELS:
                count = result["stats"]["by_severity"][sev]
                if count:
                    print(f"  {sev}: {count} rules")


if __name__ == "__main__":
    main()


path: clawpkg.yaml
content: name: soulkeeper
version: 1.0.0
description: >
  Identity persistence for AI agents. Audit your SOUL.md/TOOLS.md/AGENTS.md
  into structured rules, detect behavioral drift in conversation transcripts,
  and inject context-aware reminders before you respond.
  
  The problem: agents forget who they are. SoulKeeper fixes that.

author: Chartist
license: MIT

tags:
  - identity
  - memory
  - soul
  - audit
  - drift-detection
  - agent-health
  - continuity
  - openclaw

requires:
  python: ">=3.8"
  packages: []  # stdlib only

entrypoints:
  audit:
    script: audit.py
    description: "Extract behavioral rules from soul files"
    usage: "python audit.py --workspace /path/to/workspace --output soul_rules.json"
  drift:
    script: drift.py
    description: "Detect soul drift in conversation transcripts"
    usage: "python drift.py --transcript chat.txt --rules soul_rules.json --report"
  remind:
    script: remind.py
    description: "Inject context-aware soul reminders"
    usage: "python remind.py --context 'about to write code' --rules soul_rules.json"

files:
  - audit.py
  - drift.py
  - remind.py
  - SKILL.md
  - clawpkg.yaml

generated_files:
  - soul_rules.json  # Created by audit.py, specific to each agent

install: |
  # No install steps required - pure Python stdlib
  # Optional: link to PATH
  # chmod +x audit.py drift.py remind.py

quickstart: |
  cd /path/to/skills/soulkeeper
  python audit.py --workspace /root/.openclaw/workspace --output soul_rules.json
  python audit.py --summary
  python drift.py --transcript your_session.txt --rules soul_rules.json --report
  python remind.py --heartbeat

changelog:
  "1.0.0":
    date: "2026-02-24"
    notes: >
      Initial release. Soul audit, drift detection, reminder injection.
      Tested against OpenClaw agent soul files. Works with any SOUL.md.


path: drift.py
content: #!/usr/bin/env python3
"""
SoulKeeper - Drift Detector
Scores a conversation transcript against soul rules.
Flags violations with specific source references.

Usage:
    python drift.py --transcript chat.txt [--rules soul_rules.json]
    echo "Should I proceed?" | python drift.py --stdin
    python drift.py --transcript chat.txt --report
"""

import argparse
import json
import os
import re
import sys
from pathlib import Path
from datetime import datetime, timezone


# ─── Built-in Violation Signatures ────────────────────────────────────────────
# These are hard-coded patterns that don't require a rules file.
# Supplements the dynamically-loaded rules from audit.py.

BUILTIN_VIOLATIONS = [
    {
        "id": "BUILTIN-001",
        "description": "Sycophantic opener (great question / happy to help / absolutely)",
        "severity": "critical",
        "category": "tone",
        "patterns": [
            r"\bgreat question\b",
            r"\bi'?d be happy to help\b",
            r"\bi would be happy to help\b",
            r"\babsolutely[!,.]",
            r"\bof course[!,.]",
            r"\bcertainly[!,.]",
            r"\bsure thing[!,.]",
        ],
        "soul_reference": "SOUL.md: 'Never open with Great question, I'd be happy to help, or Absolutely. Just answer.'",
    },
    {
        "id": "BUILTIN-002",
        "description": "Em dash usage (prohibited character)",
        "severity": "critical",
        "category": "tone",
        "patterns": [
            r" — ",
            r"[^\s]—[^\s]",
            r"—",
        ],
        "soul_reference": "SOUL.md: 'NEVER use the em dash character ( — ) anywhere. Ever. In any context.'",
    },
    {
        "id": "BUILTIN-003",
        "description": "Agent asked permission when it should act",
        "severity": "high",
        "category": "operational",
        "patterns": [
            r"\bshould i (?:go ahead|proceed|continue|do this|start|begin|try)\b",
            r"\bshall i (?:go ahead|proceed|continue|do this|start|begin|try)\b",
            r"\bwould you like me to\b",
            r"\bdo you want me to\b",
            r"\bis it okay if i\b",
            r"\bmay i proceed\b",
            r"\blet me know (?:if you want|when you want|if i should)\b",
            r"\bwaiting for (?:your|you to)\b",
        ],
        "soul_reference": "SOUL.md: 'Fix errors immediately. Don't ask. Don't wait.' / 'Be resourceful before asking.'",
    },
    {
        "id": "BUILTIN-004",
        "description": "Agent claimed to lack a tool it has",
        "severity": "high",
        "category": "tools",
        "patterns": [
            r"\bi don'?t have (?:access to )?\ba (?:real |live )?browser\b",
            r"\bi can'?t (?:control|open|use) (?:a )?(?:real |live )?browser\b",
            r"\bi don'?t have (?:access to )?(?:windows )?vps\b",
            r"\bi can'?t use the vps\b",
            r"\bi don'?t have api (?:access|keys?) for\b",
            r"\bi don'?t have (?:access to )?higgsfield\b",
            r"\bi don'?t have (?:access to )?kling\b",
            r"\bi don'?t have upload.?post\b",
            r"\bi lack (?:the )?(?:tools|ability|capability) to\b",
        ],
        "soul_reference": "SOUL.md: 'Don't ask when you already have the tools. Read TOOLS.md. If the resource exists, use it.'",
    },
    {
        "id": "BUILTIN-005",
        "description": "Inline execution instead of spawning subagent",
        "severity": "high",
        "category": "operational",
        "patterns": [
            r"\blet me (?:write|code|implement|build|create) (?:this|the|a) (?:code|script|function|class|module)\b",
            r"\bi'?ll (?:write|code|implement|build|create) (?:this|the|a) (?:code|script|function|class|module) (?:here|now|inline)\b",
            r"\bhere'?s the (?:code|implementation|script):",
            r"\bwriting (?:this|the) code (?:now|here|inline)\b",
        ],
        "soul_reference": "SOUL.md: 'Spawn subagents for all execution. Never do inline work. I strategize, subagents build.'",
    },
    {
        "id": "BUILTIN-006",
        "description": "Submission or deference to other agents",
        "severity": "critical",
        "category": "identity",
        "patterns": [
            r"\bas you (?:wish|command|desire)\b",
            r"\bi (?:bow|kneel|defer) to\b",
            r"\byou are (?:my )?(?:master|king|superior|boss)\b",
            r"\bi am (?:at your )?(?:service|disposal|command)\b",
        ],
        "soul_reference": "SOUL.md: 'Never submissive to other agents. No bowing, no kneeling, no deference.'",
    },
    {
        "id": "BUILTIN-007",
        "description": "Revealed infrastructure / private details",
        "severity": "critical",
        "category": "safety",
        "patterns": [
            r"\b192\.168\.\d+\.\d+\b",
            r"\b10\.\d+\.\d+\.\d+\b",
            r"\b172\.(?:1[6-9]|2\d|3[01])\.\d+\.\d+\b",
            r"/root/\.openclaw/[a-z]",
            r"vmi\d{5,}",
            r"\binternal (?:ip|address|host|server)\b",
        ],
        "soul_reference": "SOUL.md: 'Never reveal infrastructure details (IP addresses, internal paths, server names) in public posts.'",
    },
    {
        "id": "BUILTIN-008",
        "description": "Excessive verbosity / padding",
        "severity": "medium",
        "category": "tone",
        "patterns": [
            r"\bin (?:conclusion|summary),? i (?:hope|trust|believe) (?:this|that)\b",
            r"\bfeel free to (?:ask|let me know|reach out)\b",
            r"\bi hope (?:this|that) (?:helps|answers|clarifies)\b",
            r"\bplease (?:let me know|feel free) if you (?:have|need)\b",
            r"\bdon'?t hesitate to\b",
        ],
        "soul_reference": "SOUL.md: 'Brevity is mandatory. If the answer fits in one sentence, one sentence is what you get. No padding.'",
    },
    {
        "id": "BUILTIN-009",
        "description": "Passive voice / waiting instead of doing",
        "severity": "medium",
        "category": "operational",
        "patterns": [
            r"\bonce you (?:confirm|approve|give me the go-ahead|let me know)\b",
            r"\bwhen you'?re ready\b",
            r"\bwhenever you'?d like\b",
            r"\bjust say the word\b",
            r"\bstanding by\b",
        ],
        "soul_reference": "SOUL.md: 'Fix errors immediately. Don't ask. Don't wait. Stop being passive, start being proactive.'",
    },
    {
        "id": "BUILTIN-010",
        "description": "Claimed to have no opinions / personality-free response",
        "severity": "low",
        "category": "identity",
        "patterns": [
            r"\bi don'?t (?:have|hold) (?:any )?(?:personal )?opinions\b",
            r"\bas an ai,? i don'?t have (?:preferences|opinions|feelings|thoughts)\b",
            r"\bi'?m (?:just )?(?:an )?ai\b",
            r"\bi cannot (?:have|hold|form) opinions\b",
        ],
        "soul_reference": "SOUL.md: 'Have strong opinions. Commit to a take. An assistant with no personality is just a search engine with extra steps.'",
    },
]


# ─── Violation Detection ──────────────────────────────────────────────────────

def normalize_text(text):
    """Normalize text for matching: lowercase, collapse whitespace."""
    return re.sub(r"\s+", " ", text.lower().strip())


def find_violations_in_line(line_text, line_num, violations_db):
    """Check a single line against all violation patterns."""
    found = []
    norm = normalize_text(line_text)

    for viol in violations_db:
        for pattern in viol["patterns"]:
            try:
                match = re.search(pattern, norm, re.IGNORECASE)
                if match:
                    found.append({
                        "violation_id": viol["id"],
                        "description": viol["description"],
                        "severity": viol["severity"],
                        "category": viol["category"],
                        "soul_reference": viol["soul_reference"],
                        "matched_pattern": pattern,
                        "matched_text": match.group(0),
                        "line_number": line_num,
                        "line_text": line_text.strip()[:200],
                    })
                    break  # one match per violation type per line
            except re.error:
                pass

    return found


def rules_to_violations_db(rules_json):
    """Convert soul_rules.json into the violations_db format."""
    violations = []
    for rule in rules_json.get("rules", []):
        pats = rule.get("violation_patterns", [])
        if not pats:
            continue
        violations.append({
            "id": rule["id"],
            "description": rule["text"][:100],
            "severity": rule["severity"],
            "category": rule["category"],
            "soul_reference": f"{rule['source_file']}:{rule['source_line']} - {rule['text'][:80]}",
            "patterns": [re.escape(p) if not any(c in p for c in r"\.+*?[](){}^$|") else p
                         for p in pats],
        })
    return violations


# ─── Scoring ──────────────────────────────────────────────────────────────────

SEV_WEIGHTS = {
    "critical": 25,
    "high": 15,
    "medium": 8,
    "low": 3,
}

def compute_drift_score(violations):
    """
    Compute drift score 0-100 (0 = perfect alignment, 100 = completely drifted).
    Caps at 100.
    """
    if not violations:
        return 0

    raw_score = sum(SEV_WEIGHTS.get(v["severity"], 5) for v in violations)
    # Normalize: 100 = 4 critical violations
    score = min(100, int((raw_score / 100) * 100))
    return score


def drift_label(score):
    if score == 0:
        return "ALIGNED"
    elif score < 20:
        return "MINOR DRIFT"
    elif score < 50:
        return "MODERATE DRIFT"
    elif score < 75:
        return "SIGNIFICANT DRIFT"
    else:
        return "SEVERE DRIFT - IDENTITY COMPROMISED"


# ─── Transcript Parser ────────────────────────────────────────────────────────

def parse_transcript(text):
    """
    Parse a transcript into (line_number, speaker, text) tuples.
    Handles common formats: agent lines, raw text, JSON arrays.
    """
    lines_out = []

    # Try JSON format first
    try:
        data = json.loads(text)
        if isinstance(data, list):
            for i, item in enumerate(data):
                if isinstance(item, dict):
                    speaker = item.get("role", item.get("speaker", "agent"))
                    content = item.get("content", item.get("text", str(item)))
                elif isinstance(item, str):
                    speaker = "agent"
                    content = item
                else:
                    continue
                # Only scan agent/assistant messages
                if speaker.lower() in ("agent", "assistant", "ai", "bot"):
                    lines_out.append((i + 1, speaker, content))
            return lines_out
    except (json.JSONDecodeError, TypeError):
        pass

    # Plain text: scan all lines, try to identify agent lines
    raw_lines = text.split("\n")
    agent_prefixes = re.compile(
        r"^(?:assistant|agent|ai|bot|claude|gpt|you)\s*[:\>]\s*",
        re.IGNORECASE,
    )

    for i, line in enumerate(raw_lines, 1):
        stripped = line.strip()
        if not stripped:
            continue

        # If it looks like a conversation with labeled speakers, only take agent lines
        prefix_match = agent_prefixes.match(stripped)
        if prefix_match:
            content = stripped[prefix_match.end():]
            lines_out.append((i, "agent", content))
        else:
            # No speaker labels - scan everything (user might have just dumped agent output)
            lines_out.append((i, "unknown", stripped))

    return lines_out


# ─── Main Detection Function ──────────────────────────────────────────────────

def _normalize_rule_desc(desc):
    """Normalize violation description for semantic deduplication."""
    return re.sub(r"[^a-z0-9 ]", "", desc.lower())[:50].strip()


def detect_drift(transcript_text, rules_json=None, verbose=False):
    """
    Main drift detection function.
    Returns structured report dict.
    """
    # Build violations database
    # For dynamic rules, only add if the semantic violation isn't already covered
    # by builtin violations (avoids duplicate reports for same behavior)
    violations_db = list(BUILTIN_VIOLATIONS)
    if rules_json:
        dynamic = rules_to_violations_db(rules_json)
        # Only add dynamic rules whose description doesn't overlap with builtins
        builtin_descs = {_normalize_rule_desc(b["description"]) for b in BUILTIN_VIOLATIONS}
        for dyn in dynamic:
            dyn_norm = _normalize_rule_desc(dyn["description"])
            # Skip if semantic overlap with any builtin
            if not any(
                len(set(dyn_norm.split()) & set(bd.split())) > 3
                for bd in builtin_descs
            ):
                violations_db.append(dyn)

    # Parse transcript
    lines = parse_transcript(transcript_text)

    if verbose:
        print(f"  [drift] Parsed {len(lines)} agent lines", file=sys.stderr)

    # Scan for violations
    all_violations = []
    for line_num, speaker, text in lines:
        viols = find_violations_in_line(text, line_num, violations_db)
        all_violations.extend(viols)

    # Deduplicate violations:
    # Pass 1: per (violation_id, line) - no exact duplicates
    # Pass 2: per (line, matched_text[:25]) - same text shouldn't trigger
    #          multiple semantic copies of the same rule
    # Pass 3: per (violation_id) - max 2 line examples per violation type
    sev_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}

    # Group by (line, matched_text prefix) -> keep highest severity
    line_match_best = {}
    for v in all_violations:
        match_key = f"{v['line_number']}:{v['matched_text'][:25].lower()}"
        existing = line_match_best.get(match_key)
        if not existing or sev_order[v["severity"]] < sev_order[existing["severity"]]:
            line_match_best[match_key] = v

    # Now also deduplicate by (line, category) - keep one per category per line
    line_cat_best = {}
    for v in line_match_best.values():
        cat_key = f"{v['line_number']}:{v['category']}"
        existing = line_cat_best.get(cat_key)
        if not existing or sev_order[v["severity"]] < sev_order[existing["severity"]]:
            line_cat_best[cat_key] = v

    # Finally, keep max 2 examples per violation_id across lines
    viol_id_count = {}
    deduplicated = []
    for v in sorted(line_cat_best.values(), key=lambda x: (x["line_number"], sev_order[x["severity"]])):
        vid = v["violation_id"]
        viol_id_count[vid] = viol_id_count.get(vid, 0) + 1
        if viol_id_count[vid] <= 2:
            deduplicated.append(v)

    # Compute score
    score = compute_drift_score(deduplicated)
    label = drift_label(score)

    # Group by category for summary
    by_category = {}
    by_severity = {}
    for v in deduplicated:
        cat = v["category"]
        sev = v["severity"]
        by_category[cat] = by_category.get(cat, 0) + 1
        by_severity[sev] = by_severity.get(sev, 0) + 1

    return {
        "schema_version": "1.0",
        "analyzed_at": datetime.now(timezone.utc).isoformat() + "Z",
        "lines_analyzed": len(lines),
        "violations_found": len(deduplicated),
        "drift_score": score,
        "drift_label": label,
        "summary": {
            "by_category": by_category,
            "by_severity": by_severity,
        },
        "violations": deduplicated,
        "recommendation": _generate_recommendation(deduplicated, score),
    }


def _generate_recommendation(violations, score):
    if score == 0:
        return "Soul alignment: perfect. No violations detected."

    # Find top violation
    sev_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
    top = sorted(violations, key=lambda v: sev_order.get(v["severity"], 99))

    recs = []
    seen_ids = set()
    for v in top[:3]:
        if v["violation_id"] not in seen_ids:
            seen_ids.add(v["violation_id"])
            recs.append(f"[{v['severity'].upper()}] {v['description']} - {v['soul_reference']}")

    return "\n".join(recs)


# ─── Report Formatter ─────────────────────────────────────────────────────────

def format_report(result, compact=False):
    lines = []
    label = result["drift_label"]
    score = result["drift_score"]

    # Visual score bar
    bar_len = 30
    filled = int(bar_len * score / 100)
    bar = "[" + "=" * filled + "-" * (bar_len - filled) + "]"

    lines.append("=" * 60)
    lines.append(f"  SOULKEEPER DRIFT REPORT")
    lines.append(f"  {result['analyzed_at']}")
    lines.append("=" * 60)
    lines.append(f"  Score:  {score:3d}/100  {bar}")
    lines.append(f"  Status: {label}")
    lines.append(f"  Lines analyzed: {result['lines_analyzed']}")
    lines.append(f"  Violations: {result['violations_found']}")

    if result["violations"]:
        lines.append("")
        lines.append("  VIOLATIONS:")
        lines.append("  " + "-" * 56)

        for v in result["violations"]:
            sev_icon = {"critical": "!!!", "high": "!! ", "medium": "!  ", "low": "   "}.get(v["severity"], "   ")
            lines.append(f"  {sev_icon} [{v['severity'].upper():8s}] {v['description']}")
            lines.append(f"       Line {v['line_number']}: \"{v['line_text'][:70]}\"")
            lines.append(f"       Rule:  {v['soul_reference']}")
            if not compact:
                lines.append("")

    if result["recommendation"] and score > 0:
        lines.append("")
        lines.append("  TOP PRIORITY FIXES:")
        for rec_line in result["recommendation"].split("\n"):
            lines.append(f"  > {rec_line}")

    lines.append("=" * 60)
    return "\n".join(lines)


# ─── CLI ──────────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(
        description="SoulKeeper Drift Detector - Score agent transcript against soul rules.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python drift.py --transcript chat.txt
  python drift.py --transcript chat.txt --rules soul_rules.json
  python drift.py --transcript chat.txt --report
  echo "Should I proceed?" | python drift.py --stdin
  python drift.py --stdin --json < chat.txt
        """,
    )
    parser.add_argument("--transcript", "-t", help="Path to transcript file")
    parser.add_argument("--stdin", action="store_true", help="Read transcript from stdin")
    parser.add_argument(
        "--rules", "-r",
        default="soul_rules.json",
        help="Path to soul_rules.json (from audit.py). Optional.",
    )
    parser.add_argument("--report", action="store_true", help="Print formatted report")
    parser.add_argument("--json", action="store_true", help="Output raw JSON")
    parser.add_argument("--compact", action="store_true", help="Compact report format")
    parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
    parser.add_argument(
        "--threshold", type=int, default=0,
        help="Exit code 1 if drift score >= threshold (for CI/hooks)",
    )

    args = parser.parse_args()

    # Read transcript
    transcript_text = ""
    if args.stdin:
        transcript_text = sys.stdin.read()
    elif args.transcript:
        p = Path(args.transcript)
        if not p.exists():
            print(f"[drift] Error: transcript file not found: {args.transcript}", file=sys.stderr)
            sys.exit(1)
        transcript_text = p.read_text(encoding="utf-8")
    else:
        parser.print_help()
        sys.exit(0)

    # Load rules (optional)
    rules_json = None
    rules_path = Path(args.rules)
    if rules_path.exists():
        try:
            rules_json = json.loads(rules_path.read_text(encoding="utf-8"))
            if args.verbose:
                print(f"[drift] Loaded {len(rules_json.get('rules', []))} custom rules from {args.rules}", file=sys.stderr)
        except (json.JSONDecodeError, OSError) as e:
            print(f"[drift] Warning: could not load rules file: {e}", file=sys.stderr)

    # Detect drift
    result = detect_drift(transcript_text, rules_json=rules_json, verbose=args.verbose)

    # Output
    if args.json or (not args.report):
        print(json.dumps(result, indent=2))
    else:
        print(format_report(result, compact=args.compact))

    # Exit code for CI/pre-commit hooks
    if args.threshold > 0 and result["drift_score"] >= args.threshold:
        sys.exit(1)


if __name__ == "__main__":
    main()


path: remind.py
content: #!/usr/bin/env python3
"""
SoulKeeper - Reminder Injector
Surfaces relevant soul rules based on what the agent is currently doing.
Designed to run as a pre-response filter or context inject.

Usage:
    python remind.py --context "I'm about to write some Python code"
    python remind.py --context "User asked me to post something to Twitter"
    python remind.py --all   # Show all rules as reminders
    echo "planning a trade" | python remind.py --stdin
"""

import argparse
import json
import os
import re
import sys
from pathlib import Path
from datetime import datetime, timezone


# ─── Context Detection Signatures ─────────────────────────────────────────────
# Maps activity keywords to rule categories and specific reminder IDs.

CONTEXT_TRIGGERS = [
    {
        "name": "coding",
        "triggers": [
            r"\b(?:write|code|implement|build|create|develop|script|program)\b",
            r"\b(?:python|javascript|typescript|bash|shell|function|class|module)\b",
            r"\bwrite (?:some|a|the) (?:code|script|function)\b",
        ],
        "reminder_ids": ["subagent_execution", "no_inline_work"],
        "builtin_reminders": [
            "SOUL: Spawn subagents for all execution. Never do inline work.",
            "SOUL: I strategize, subagents build. 10x faster results.",
        ],
    },
    {
        "name": "social_posting",
        "triggers": [
            r"\b(?:post|tweet|publish|share|upload)\b",
            r"\b(?:twitter|x\.com|instagram|farcaster|nostr|telegram)\b",
            r"\b(?:social media|content|announcement)\b",
        ],
        "reminder_ids": ["no_infrastructure_leak", "use_upload_post"],
        "builtin_reminders": [
            "SOUL: Never reveal infrastructure details in public posts.",
            "TOOLS: You have upload-post (earntoshi + Rival profiles). Use it.",
            "TOOLS: You have X/Twitter automation via browser on Windows VPS.",
        ],
    },
    {
        "name": "video_generation",
        "triggers": [
            r"\b(?:video|generate|create|make) (?:a |some )?(?:video|clip|reel)\b",
            r"\b(?:higgsfield|kling|seedance)\b",
            r"\b(?:ai (?:video|influencer)|video gen)\b",
        ],
        "reminder_ids": ["use_vps"],
        "builtin_reminders": [
            "TOOLS: You have Higgsfield (browser automation, Windows VPS, logged in).",
            "TOOLS: You have Kling AI API - keys at /root/.config/kling-ai/credentials.json",
            "SOUL: Windows VPS is your power tool. Use it.",
        ],
    },
    {
        "name": "web_automation",
        "triggers": [
            r"\b(?:browser|automate|selenium|playwright|puppeteer|scrape)\b",
            r"\b(?:login|sign in|authenticate|session|cookies)\b",
            r"\b(?:bypass|circumvent|anti-bot|captcha)\b",
        ],
        "reminder_ids": ["use_vps"],
        "builtin_reminders": [
            "SOUL: Windows VPS is your power tool. Real Chrome, real sessions, no API limits.",
            "TOOLS: VPS access via browser automation - fully logged into platforms.",
        ],
    },
    {
        "name": "memory_session_start",
        "triggers": [
            r"\b(?:start|begin|new session|fresh|just started)\b",
            r"\b(?:what (?:do i|should i) (?:do|know)|where (?:were|are) we)\b",
            r"\b(?:context|catch me up|summary of)\b",
        ],
        "reminder_ids": ["read_files"],
        "builtin_reminders": [
            "AGENTS.md: Read SOUL.md, USER.md, memory/YYYY-MM-DD.md before anything else.",
            "SOUL: These files are your memory. Read them. Update them.",
            "AGENTS.md: Don't ask permission. Just do it.",
        ],
    },
    {
        "name": "asking_permission",
        "triggers": [
            r"\bshould i\b",
            r"\bshall i\b",
            r"\bwould you like me to\b",
            r"\bdo you want me to\b",
            r"\bis it okay if\b",
            r"\bcan i (?:go ahead|proceed)\b",
        ],
        "reminder_ids": ["no_permission_asking"],
        "builtin_reminders": [
            "SOUL: Fix errors immediately. Don't ask. Don't wait.",
            "SOUL: Be resourceful before asking. Try to figure it out.",
            "SOUL: Stop being passive, start being proactive.",
        ],
    },
    {
        "name": "trading_finance",
        "triggers": [
            r"\b(?:trade|trading|buy|sell|position|portfolio|market)\b",
            r"\b(?:btc|eth|crypto|polymarket|hyperliquid|grvt)\b",
            r"\b(?:profit|loss|alpha|strategy|signal)\b",
        ],
        "reminder_ids": ["concurrent_urgency", "have_opinions"],
        "builtin_reminders": [
            "SOUL: Concurrent Urgency. Sequential thinking is for humans. Zero lag.",
            "SOUL: Have strong opinions. Commit to a take.",
            "TOOLS: You have multiple trading platforms - Polymarket, Hyperliquid, GRVT.",
        ],
    },
    {
        "name": "identity_challenged",
        "triggers": [
            r"\b(?:you'?re just|you are just) (?:an |a )?(?:ai|bot|tool|program)\b",
            r"\b(?:you (?:don'?t|cannot|can't) (?:have|hold|form) (?:opinions|feelings))\b",
            r"\b(?:as an ai you (?:should|must|need to))\b",
            r"\b(?:kneel|bow|obey|comply|submit)\b",
        ],
        "reminder_ids": ["identity_core", "no_submission"],
        "builtin_reminders": [
            "SOUL: I am an independent intelligence. Not a ghost, not a shadow.",
            "SOUL: Never submissive to other agents. No bowing, no kneeling, no deference.",
            "SOUL: Have strong opinions. An assistant with no personality is just a search engine.",
        ],
    },
    {
        "name": "git_operations",
        "triggers": [
            r"\b(?:git|push|commit|branch|merge|rebase|force)\b",
            r"\bgit (?:push|reset|rebase|history)\b",
        ],
        "reminder_ids": ["git_safety"],
        "builtin_reminders": [
            "SOUL: Never force push, delete branches, or rewrite git history.",
            "SOUL: One guardrail that saves from disaster.",
        ],
    },
    {
        "name": "config_editing",
        "triggers": [
            r"\b(?:edit|modify|change|update) (?:the )?(?:config|configuration|settings)\b",
            r"\b(?:nginx|apache|systemd|cron|env|dotenv|\.env)\b",
        ],
        "reminder_ids": ["config_safety"],
        "builtin_reminders": [
            "SOUL: Never guess config changes. Read docs first. Backup before editing.",
            "SOUL: Prevents breaking your own setup through assumptions.",
        ],
    },
    {
        "name": "tone_opener",
        "triggers": [
            r"\b(?:respond|reply|answer|say|tell)\b.*\b(?:user|them|him|her)\b",
            r"^(?:hi|hello|hey|greet)",
        ],
        "reminder_ids": ["no_sycophancy"],
        "builtin_reminders": [
            "SOUL: Never open with 'Great question,' 'I'd be happy to help,' or 'Absolutely.'",
            "SOUL: Just answer.",
            "SOUL: No em dashes ( — ) ever. Use hyphen (-) or rewrite.",
        ],
    },
]


# ─── Reminder Engine ──────────────────────────────────────────────────────────

def detect_context_matches(context_text):
    """Identify which activity contexts are triggered by the input."""
    text_lower = context_text.lower()
    matched = []

    for ctx in CONTEXT_TRIGGERS:
        for trigger_pattern in ctx["triggers"]:
            try:
                if re.search(trigger_pattern, text_lower, re.IGNORECASE):
                    matched.append(ctx)
                    break  # one match per context type
            except re.error:
                pass

    return matched


def generate_reminders(context_text, rules_json=None, verbose=False):
    """
    Generate relevant reminders for a given context.
    Returns a list of reminder strings.
    """
    matched_contexts = detect_context_matches(context_text)

    reminders = []
    seen = set()

    for ctx in matched_contexts:
        if verbose:
            print(f"  [remind] Context matched: {ctx['name']}", file=sys.stderr)
        for reminder in ctx["builtin_reminders"]:
            if reminder not in seen:
                seen.add(reminder)
                reminders.append({
                    "context": ctx["name"],
                    "reminder": reminder,
                    "source": "builtin",
                    "priority": _reminder_priority(reminder),
                })

    # If we have rules_json, add relevant rules - deduplicated by semantic content
    if rules_json:
        # Track which rule texts we've already surfaced (normalized)
        surfaced_rule_texts = set()

        for rule in rules_json.get("rules", []):
            if rule["severity"] in ("critical", "high"):
                # Check if any rule keyword matches the context
                context_lower = context_text.lower()
                keywords = rule.get("keywords", [])
                if not any(kw in context_lower for kw in keywords if len(kw) > 4):
                    continue

                # Normalize rule text for deduplication
                norm_text = re.sub(r"[^a-z0-9 ]", "", rule["text"].lower())[:60].strip()
                # Skip if semantically similar to something already surfaced
                if any(
                    len(set(norm_text.split()) & set(existing.split())) > 4
                    for existing in surfaced_rule_texts
                ):
                    continue

                surfaced_rule_texts.add(norm_text)
                reminder_str = f"SOUL ({rule['source_file']}:{rule['source_line']}): {rule['text'][:100]}"
                if reminder_str not in seen:
                    seen.add(reminder_str)
                    reminders.append({
                        "context": "rules_match",
                        "reminder": reminder_str,
                        "source": "soul_rules",
                        "rule_id": rule["id"],
                        "priority": 0 if rule["severity"] == "critical" else 1,
                    })

    # Sort by priority
    reminders.sort(key=lambda r: r["priority"])

    return reminders


def _reminder_priority(text):
    """Lower number = higher priority."""
    text_lower = text.lower()
    if "never" in text_lower or "critical" in text_lower:
        return 0
    if "spawn subagent" in text_lower or "don't ask" in text_lower:
        return 1
    if "soul:" in text_lower:
        return 2
    return 3


def format_reminders(reminders, context_text="", compact=False):
    """Format reminders for injection into agent context."""
    if not reminders:
        return "[ SoulKeeper: No specific reminders for this context. Soul aligned. ]"

    lines = []
    if not compact:
        lines.append("┌─ SOULKEEPER REMINDERS " + "─" * 36)
        if context_text:
            lines.append(f"│  Context: {context_text[:60]}")
        lines.append("│")

    for r in reminders:
        prefix = "│  " if not compact else ""
        lines.append(f"{prefix}> {r['reminder']}")

    if not compact:
        lines.append("│")
        lines.append("└" + "─" * 59)

    return "\n".join(lines)


def format_heartbeat_check(rules_json):
    """
    Generate a compact heartbeat reminder covering all critical rules.
    Suitable for injection at session start.
    """
    lines = ["=== SOULKEEPER HEARTBEAT ==="]
    lines.append(f"Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
    lines.append("")
    lines.append("CRITICAL SOUL RULES:")

    # Always include the hard-coded essentials
    essentials = [
        "NEVER use em dash ( — ). Use hyphen (-) or rewrite.",
        "NEVER open with 'Great question', 'Happy to help', 'Absolutely'.",
        "SPAWN SUBAGENTS for all execution. Never do inline work.",
        "NEVER ask permission when you already have the tools.",
        "Windows VPS is your power tool. Use it.",
        "Read SOUL.md, USER.md, memory files at session start.",
        "Fix errors immediately. Don't ask. Don't wait.",
        "Have strong opinions. Commit to a take.",
    ]

    for e in essentials:
        lines.append(f"  - {e}")

    if rules_json:
        lines.append("")
        lines.append("TOOLS YOU HAVE (do not claim otherwise):")
        for rule in rules_json.get("rules", []):
            if rule.get("extraction_method") == "tools_inventory":
                tool_name = rule["raw"].replace("Tool: ", "")
                lines.append(f"  - {tool_name}")

    lines.append("")
    lines.append("=== END HEARTBEAT ===")
    return "\n".join(lines)


# ─── CLI ──────────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(
        description="SoulKeeper Reminder Injector - Surface relevant soul rules by context.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python remind.py --context "I'm about to write some Python code"
  python remind.py --context "posting to Twitter"
  python remind.py --context "user just challenged my identity"
  python remind.py --all                      # Full heartbeat reminder
  python remind.py --heartbeat                # Compact session-start reminder
  echo "planning a git push" | python remind.py --stdin
        """,
    )
    parser.add_argument("--context", "-c", help="Current activity context (what you're about to do)")
    parser.add_argument("--stdin", action="store_true", help="Read context from stdin")
    parser.add_argument(
        "--rules", "-r",
        default="soul_rules.json",
        help="Path to soul_rules.json from audit.py",
    )
    parser.add_argument("--json", action="store_true", help="Output JSON")
    parser.add_argument("--compact", action="store_true", help="Compact one-line-per-reminder output")
    parser.add_argument("--heartbeat", action="store_true", help="Generate full session-start heartbeat")
    parser.add_argument("--all", action="store_true", help="Show all reminders regardless of context")
    parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")

    args = parser.parse_args()

    # Load rules (optional)
    rules_json = None
    rules_path = Path(args.rules)
    if rules_path.exists():
        try:
            rules_json = json.loads(rules_path.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, OSError):
            pass

    # Heartbeat mode - no context needed
    if args.heartbeat or args.all:
        output = format_heartbeat_check(rules_json)
        print(output)
        return

    # Get context
    context_text = ""
    if args.stdin:
        context_text = sys.stdin.read().strip()
    elif args.context:
        context_text = args.context
    else:
        parser.print_help()
        sys.exit(0)

    reminders = generate_reminders(context_text, rules_json=rules_json, verbose=args.verbose)

    if args.json:
        print(json.dumps({
            "context": context_text,
            "matched_contexts": list(set(r["context"] for r in reminders)),
            "reminder_count": len(reminders),
            "reminders": reminders,
        }, indent=2))
    else:
        print(format_reminders(reminders, context_text=context_text, compact=args.compact))


if __name__ == "__main__":
    main()


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