Skill: zgjq/smart-memory-zero-dep (version 1.1.2)

--- name: smart-memory description: > Enhanced memory system for agentic workflows. Automatic memory extraction from conversations, memory type classification (preference/project/technical/lesson), temporal decay/archival, session-scoped temporary cache, and HOT RAM working memory with WAL protocol. Use when managing MEMORY.md, extracting insights from conversations, organizing memory files, archiving stale memories, searching memories by type, tracking current task state, or when the user says "remember this", "what do you know about X", "clean up memories", "what are we working on". --- # Smart Memory Enhanced memory management for OpenClaw. Zero external dependencies. Inspired by Claude Code's memdir architecture. ## Requirements - **Runtime**: Python 3.10+ (standard library only), Bash 4.0+ (health/extract scripts only) - **OS**: Linux, macOS - **Environment variables** (all optional, with defaults): - `OPENCLAW_WORKSPACE` — Workspace root (default: `~/.openclaw/workspace`) - `OPENCLAW_SESSION_ID` — Session identifier for temp cache (default: `default`) ## Security ### Sensitive Data Protection All write commands (`session_state.py`, `session_cache.py`) automatically reject inputs matching: - API keys/tokens (OpenAI `sk-*`, GitHub `ghp_*`, ClawHub `clh_*`) - Passwords (`password=`, `passwd:`, etc.) - Private keys (`-----BEGIN PRIVATE KEY-----`) This is a **hard block** at the script level — the agent cannot bypass it. The regex patterns are conservative (high precision, may miss exotic formats); the agent should additionally avoid extracting any credential-like text even if not matched. ### Input Sanitization - Control characters stripped from all inputs - Session ID sanitized to alphanumeric/hyphen/underscore only (prevents path traversal) - Python-based scripts eliminate shell injection risks ### Data Isolation - All data stays local — no network calls, no cloud uploads - Session cache uses `/tmp/` with sanitized session ID filenames - No external dependencies or third-party packages ## Memory Layers | Layer | File | Purpose | Lifetime | |-------|------|---------|----------| | **HOT RAM** | `SESSION-STATE.md` | Current task, context, decisions | Session (survives compaction) | | **DAILY** | `memory/YYYY-MM-DD.md` | Raw daily notes with type tags | 90 days → archive | | **CURATED** | `MEMORY.md` | Promoted long-term facts | Permanent | | **ARCHIVE** | `memory/archive/YYYY-MM/` | Stale daily files | Forever (compressed) | | **CACHE** | `/tmp/openclaw-session-*.json` | Session temp data | Session end / reboot | ## Quick Reference | Action | Script | |--------|--------| | WAL shortcut (any command) | `scripts/wal task/decide/context/pending/done/blocker/get/snapshot/restore` | | Set current task | `scripts/wal task "description"` | | Log a decision | `scripts/wal decide "chose X over Y"` | | Add context | `scripts/wal context key value` | | Snapshot & restore | `scripts/wal snapshot` / `scripts/wal restore` | | Session cache | `python3 scripts/session_cache.py set/get/list/clear` | | Classify (summary) | `python3 scripts/classify_memory.py --summary` | | Decay (promote only) | `python3 scripts/memory_decay.py --promote-only` | | Health report | `bash scripts/memory_health.sh` | ## WAL Protocol (Write-Ahead Log) **Critical rule: Write BEFORE responding.** When the user provides information that should be remembered: 1. **Write to SESSION-STATE.md** (via `session_state.py`) 2. **Then** respond to the user This prevents context loss if compaction, crash, or restart happens between response and write. | User Action | WAL Write | |-------------|-----------| | States a preference | `session_state.py context "pref" "value"` | | Makes a decision | `session_state.py decide "chose X"` | | Gives a deadline | `session_state.py context "deadline" "date"` | | Corrects agent | `session_state.py decide "correction: X not Y"` | | Assigns task | `session_state.py task "description"` | | Mentions blocker | `session_state.py blocker "description"` | ## Memory Types All entries tagged with a type prefix: - `[PREF]` — User preferences, habits, style - `[PROJ]` — Project context, active work, goals - `[TECH]` — Technical details, configs, system knowledge - `[LESSON]` — Lessons learned, errors, corrections - `[PEOPLE]` — People, relationships, social context - `[TEMP]` — Session-scoped, auto-expires ## Core Workflows ### Session Start 1. Read `SESSION-STATE.md` for current task/context 2. Run `memory_search` for relevant prior context 3. Check `memory/YYYY-MM-DD.md` for today's activity ### During Conversation (WAL) 1. User provides actionable info → write to SESSION-STATE.md FIRST 2. Important facts → append to `memory/YYYY-MM-DD.md` with type tag 3. Use `session_cache.py` for transient session data ### Session End 1. Update `SESSION-STATE.md` with final state 2. Promote durable items from daily notes to `MEMORY.md` 3. Run `memory_health.sh` periodically to check hygiene ### Periodic Maintenance - Run `memory_decay.py` when MEMORY.md > 200 lines or 50+ daily files - Run `classify_memory.py` to tag orphaned entries - Archive daily files older than 90 days ## Agent Behavior ### Auto-Extract When - User shares preference, opinion, or personal fact - Project decision is made or changed - Error encountered and resolved (→ LESSON) - New people, tools, or workflows mentioned ### Extraction Modes - **Keyword mode** (default): `extract_memories.sh --auto "text"` — zero token cost, pure Python - **LLM mode** (opt-in): Use `references/extraction_prompt.md` template — costs tokens, better quality - Use keyword mode for most conversations; LLM mode only for long/complex sessions (20+ turns) ### Do NOT Extract - Passwords, tokens, API keys, credentials (scripts hard-block these) - Private conversations about third parties not relevant to work - Speculation or uncertain information ("user might prefer X") - Transient state ("user is currently looking at page X") - Information the user explicitly said not to remember ### Auto-Decay When - MEMORY.md exceeds 200 lines - memory/*.md totals > 50 files - On heartbeat if configured ## File Format ### MEMORY.md ```markdown ## [PREF] Preferences - Favorite color: 深蓝色 ## [PROJ] Active Projects - 黄金三章: /root/黄金三章/, golden3.killclaw.xyz ## [LESSON] Lessons Learned - Verify Telegram target before building notification workflows ``` ### Daily Notes ```markdown # 2026-03-31 ## [PROJ] 黄金三章 - Fixed scoring display to 10-point scale ``` ### SESSION-STATE.md ```markdown ## Current Task Building smart-memory skill ## Key Context - **platform**: ClawHub ## Recent Decisions - **2026-03-31**: Use zero-dependency approach ## Pending Actions - [ ] Publish to ClawHub ``` ## Scripts | Script | Language | Purpose | Security | |--------|----------|---------|----------| | `session_state.py` | Python | HOT RAM working memory (WAL protocol) | Sensitive data filter + sanitization | | `session_cache.py` | Python | Session-scoped temp key-value cache | Sensitive data filter + path-safe IDs | | `extract_memories.sh` | Bash | Memory extraction guide and daily file init | Read-only output | | `memory_health.sh` | Bash | Health report (stats, orphans, token estimate) | Read-only | | `memory_decay.py` | Python | Temporal decay and archival of stale files | Dry-run mode available | | `classify_memory.py` | Python | Keyword-based type classification | Dry-run mode available | ## References - `references/extraction_prompt.md` — LLM prompt for auto-extraction - `references/memory_schema.md` — Full schema and format spec - `references/decay_rules.md` — Decay/archival rule set

path: references/decay_rules.md
content: # Decay & Archival Rules

## Trigger Conditions

Run memory decay when:
- MEMORY.md exceeds 200 lines
- memory/*.md directory has more than 50 files
- User says "clean up memories", "archive old stuff", "memory hygiene"
- On heartbeat cycle (if configured)

## Daily File Archival

| File Age | Action |
|----------|--------|
| < 30 days | Keep in place |
| 30–89 days | Keep, but flag in health report |
| 90–179 days | Move to `memory/archive/YYYY-MM/` |
| 180–365 days | Move to `memory/archive/YYYY-MM/`, compress summary |
| > 365 days | Move to `memory/archive/YYYY-MM/` |

## Entry-Level Rules

### Permanent (never auto-archive)
- `[PREF]` — User preferences don't expire
- `[LESSON]` — Lessons are permanently valuable

### Time-Bound
- `[PROJ]` — Archive when project is explicitly marked complete
- `[TECH]` — Archive after 365 days if not referenced
- `[PEOPLE]` — Archive after 180 days if not referenced

### Session-Only
- `[TEMP]` — Never write to disk; lives only in session cache

## Promotion Rules

An entry in daily notes deserves promotion to MEMORY.md when:
1. Referenced in 2+ separate conversations
2. User explicitly says "remember this" or "don't forget"
3. It's a project fact (URL, config, architecture decision)
4. It's a strong user preference (confirmed, not assumed)

## Anti-Patterns (What NOT to Store)

- Transient state: "user is currently looking at file X"
- Obvious facts: "user uses a computer"
- Sensitive data: passwords, tokens, API keys (never in memory files)
- Speculation: "user might like X" — only store confirmed preferences
- Redundant info: check MEMORY.md before adding duplicates


path: references/extraction_prompt.md
content: # Memory Extraction Prompt Template

## Modes

### Mode 1: Keyword Extraction (default, zero token cost)
Use `extract_memories.sh --auto "text"` — pure Python keyword matching, no LLM calls.

### Mode 2: LLM Extraction (opt-in, costs tokens)
Use when conversation is long (>20 turns) or complex (multiple decisions/topics).
The agent calls this directly via its own LLM, not through a script.

## LLM Prompt Template

When the agent decides to use LLM extraction, use this prompt internally:

```
Review the following conversation and extract durable, long-term memories.
For each memory, assign ONE type tag and write it as a concise bullet point.

Types:
- [PREF] — User preference, habit, communication style
- [PROJ] — Project context, active work, goals, URLs
- [TECH] — Technical detail, config, system fact, tool usage
- [LESSON] — Lesson learned, error correction, workflow insight
- [PEOPLE] — Person, relationship, social context

Rules:
1. Only extract facts that will be useful in future conversations
2. Be specific: "User's favorite color is blue" not "User likes things"
3. Include context: "黄金三章 project uses SQLite for prompts" not "some project uses SQLite"
4. Skip transient info: don't record "user asked about weather"
5. Skip anything already in MEMORY.md
6. Skip passwords, tokens, API keys, or any credentials
7. Max 10 items per extraction to avoid bloat

Conversation:
---
{conversation_text}
---

Output format:
## [TYPE] Category
- Fact 1
- Fact 2
```

## When to Use Each Mode

| Scenario | Mode | Why |
|----------|------|-----|
| Short Q&A (<10 turns) | Keyword (`--auto`) | Not enough to extract |
| Medium conversation (10-20 turns) | Keyword (`--auto`) | Good enough, zero cost |
| Long conversation (20+ turns) | LLM | Better at nuanced extraction |
| Multi-topic session | LLM | Needs cross-topic reasoning |
| Simple preference mention | Keyword (`--auto`) | Trivial match |
| Complex decision with tradeoffs | LLM | Needs understanding context |


path: references/memory_schema.md
content: # Memory Schema Reference

## File Structure

```
~/.openclaw/workspace/
├── MEMORY.md                    # Curated long-term memory (promoted entries)
└── memory/
    ├── 2026-03-31.md            # Daily raw notes
    ├── 2026-03-30.md
    └── archive/
        └── 2026-01/
            ├── 2026-01-15.md    # Archived daily notes
            └── ...
```

## MEMORY.md Format

```markdown
# MEMORY.md

## [PREF] Preferences
- Item description

## [PROJ] Active Projects
- Project: description, URL, location

## [LESSON] Lessons Learned
- Lesson description

## [TECH] Technical Notes
- Technical fact

## [PEOPLE] People & Context
- Person: relevant context
```

## Daily Notes Format

```markdown
# YYYY-MM-DD

## [TYPE] Topic
- Entry description
```

## Type Tags

| Tag | Meaning | Retention | Archive Rule |
|-----|---------|-----------|--------------|
| `[PREF]` | User preference | Permanent | Never auto-archive |
| `[PROJ]` | Project context | Until project done | Archive when project marked complete |
| `[TECH]` | Technical detail | 1 year | Archive after 365 days |
| `[LESSON]` | Lesson learned | Permanent | Never auto-archive |
| `[PEOPLE]` | Person context | 6 months | Archive after 180 days |
| `[TEMP]` | Session only | Current session | Never persist |

## Session Cache Schema

Stored in `/tmp/openclaw-session-cache-{session_id}.json`:

```json
{
  "key1": "value1",
  "context:project-x": "current task is implementing feature Y",
  "count:api-calls": "5"
}
```

- Keys are arbitrary strings
- Values are strings (serialize complex objects as JSON strings)
- File auto-deleted when session ends or system reboots
- Not shared between sessions

## Decay Rules

1. Daily files older than 90 days → `memory/archive/YYYY-MM/`
2. `[PROJ]` entries for completed projects → archive immediately
3. `[TECH]` entries older than 365 days → archive
4. `[PEOPLE]` entries older than 180 days → archive
5. `[LESSON]` entries referenced 2+ times → promote to MEMORY.md
6. `[TEMP]` entries → never persist, session-only
7. `[PREF]` entries → permanent, never auto-archive


path: scripts/classify_memory.py
content: #!/usr/bin/env python3
"""
classify_memory.py — Analyze and tag memory entries with type prefixes.

Scans MEMORY.md and memory/*.md for untagged entries and suggests/applies
type classifications: [PREF], [PROJ], [TECH], [LESSON], [PEOPLE].
Uses keyword matching + character n-gram similarity for CJK/mixed text.
"""

import argparse
import os
import re
from pathlib import Path

WORKSPACE = Path(os.environ.get("OPENCLAW_WORKSPACE", os.path.expanduser("~/.openclaw/workspace")))

# Keywords that hint at each memory type
TYPE_KEYWORDS = {
    "PREF": [
        "prefer", "like", "dislike", "hate", "favorite", "favourite", "style",
        "习惯", "喜欢", "讨厌", "偏好", "风格", "颜色", "color", "always use",
        "never use", "default", "communication", "口味", "爱好", "爱用",
        "不用", "倾向", "方式", "comfortable", "uncomfortable",
    ],
    "PROJ": [
        "project", "repo", "repository", "deploy", "website", "app", "service",
        "项目", "网站", "仓库", "部署", "located at", "working on", "building",
        "http://", "https://", "github.com", "domain", "功能", "模块", "需求",
        "开发", "上线", "版本", "release", "milestone", "sprint",
    ],
    "TECH": [
        "config", "database", "sqlite", "api", "endpoint", "port", "server",
        "技术", "配置", "数据库", "端口", "服务器", "prompt", "template",
        "node", "python", "docker", "nginx", "ssh", "函数", "方法", "接口",
        "框架", "library", "package", "依赖", "npm", "pip", "环境",
    ],
    "LESSON": [
        "lesson", "learned", "mistake", "error", "bug", "don't", "never",
        "教训", "错误", "记住", "always verify", "make sure", "reminder",
        "before building", "workflow", "坑", "踩过", "注意", "别",
        "warning", "gotcha", "trap", "fix",
    ],
    "PEOPLE": [
        "user", "person", "name", "he", "she", "they", "team", "colleague",
        "用户", "人", "名字", "老板", "老大", "朋友", "同事", "客户",
        "partner", "contact", "contributor", "maintainer",
    ],
}

TYPE_TAG_PATTERN = re.compile(r"\[(PREF|PROJ|TECH|LESSON|PEOPLE|TEMP)\]", re.IGNORECASE)
SECTION_HEADER = re.compile(r"^##\s+(?:\[([A-Z]+)\]\s+)?(.+)$")


def char_ngrams(s: str, n: int = 2) -> set:
    """Generate character n-grams from a string."""
    s = s.lower()
    if len(s) < n:
        return {s} if s else set()
    return {s[i:i+n] for i in range(len(s) - n + 1)}


def ngram_similarity(text: str, keywords: list[str], n: int = 2) -> float:
    """Calculate max character n-gram overlap between text and any keyword."""
    text_ngrams = char_ngrams(text, n)
    if not text_ngrams:
        return 0.0

    max_score = 0.0
    for kw in keywords:
        kw_ngrams = char_ngrams(kw, n)
        if not kw_ngrams:
            continue
        overlap = len(text_ngrams & kw_ngrams)
        score = overlap / max(len(text_ngrams), len(kw_ngrams))
        max_score = max(max_score, score)

    return max_score


def classify_line(line: str) -> tuple[str | None, float]:
    """Classify a line by keyword matching + n-gram similarity. Returns (type, confidence)."""
    lower = line.lower()
    scores = {}

    for mem_type, keywords in TYPE_KEYWORDS.items():
        # Exact keyword match (weight: 1.0 each)
        exact_score = sum(1 for kw in keywords if kw in lower)

        # N-gram similarity for top keywords (weight: 2.0 max)
        ngram_score = ngram_similarity(lower, keywords[:8]) * 2

        total = exact_score + ngram_score
        if total > 0:
            scores[mem_type] = total

    if not scores:
        return None, 0.0

    best = max(scores, key=scores.get)
    total = sum(scores.values())
    confidence = scores[best] / total if total > 0 else 0.0
    return best, round(confidence, 2)


def analyze_file(filepath: Path) -> list[dict]:
    """Analyze a memory file and return untagged entries with suggestions."""
    results = []
    content = filepath.read_text(encoding="utf-8")
    current_section_type = None

    for i, line in enumerate(content.split("\n"), 1):
        # Track current section type from headers
        header_match = SECTION_HEADER.match(line)
        if header_match:
            tag = header_match.group(1)
            if tag:
                current_section_type = tag.upper()
            continue

        # Skip lines that already have type tags
        if TYPE_TAG_PATTERN.search(line):
            continue

        # Only classify non-empty list items or meaningful lines
        stripped = line.strip()
        if not stripped or stripped.startswith("#") or stripped.startswith("```"):
            continue

        if stripped.startswith("- ") or stripped.startswith("* "):
            suggested_type, confidence = classify_line(stripped)

            # If we're in a typed section, inherit section type if no better suggestion
            if current_section_type and not suggested_type:
                suggested_type = current_section_type
                confidence = 0.5

            if suggested_type:
                results.append({
                    "file": str(filepath),
                    "line": i,
                    "content": stripped[:100],
                    "suggested_type": suggested_type,
                    "confidence": confidence,
                    "section_type": current_section_type,
                })

    return results


def check_duplicates(filepath: Path, new_entry: str) -> bool:
    """Check if a similar entry already exists in MEMORY.md."""
    memory_file = WORKSPACE / "MEMORY.md"
    if not memory_file.exists() or filepath == memory_file:
        return False

    content = memory_file.read_text(encoding="utf-8").lower()
    new_lower = new_entry.lower().strip()

    # Check for exact substring match
    if new_lower in content:
        return True

    # Check n-gram similarity (>0.7 = likely duplicate)
    new_ngrams = char_ngrams(new_lower, 3)
    for line in content.split("\n"):
        line_ngrams = char_ngrams(line.lower().strip(), 3)
        if new_ngrams and line_ngrams:
            overlap = len(new_ngrams & line_ngrams)
            sim = overlap / max(len(new_ngrams), len(line_ngrams))
            if sim > 0.7:
                return True

    return False


def apply_tags(filepath: Path, suggestions: list[dict], dry_run: bool) -> int:
    """Apply type tags to a file. Returns count of modifications."""
    if not suggestions:
        return 0

    content = filepath.read_text(encoding="utf-8")
    lines = content.split("\n")
    applied = 0
    skipped_dupes = 0

    # Sort by line number descending to avoid offset issues
    suggestions.sort(key=lambda x: x["line"], reverse=True)

    for sugg in suggestions:
        line_idx = sugg["line"] - 1
        if line_idx >= len(lines):
            continue

        line = lines[line_idx]
        if TYPE_TAG_PATTERN.search(line):
            continue

        # Check for duplicates in MEMORY.md before applying
        if check_duplicates(filepath, sugg["content"]):
            skipped_dupes += 1
            continue

        tag = f"[{sugg['suggested_type']}]"
        # Insert tag after the list marker
        if line.strip().startswith("- "):
            new_line = line.replace("- ", f"- {tag} ", 1)
        elif line.strip().startswith("* "):
            new_line = line.replace("* ", f"* {tag} ", 1)
        else:
            new_line = f"{tag} {line}"

        if not dry_run:
            lines[line_idx] = new_line
        applied += 1

    if not dry_run and applied > 0:
        filepath.write_text("\n".join(lines), encoding="utf-8")

    if skipped_dupes > 0:
        print(f"  Skipped {skipped_dupes} duplicate entries (already in MEMORY.md)")

    return applied


def main():
    parser = argparse.ArgumentParser(description="Classify and tag memory entries")
    parser.add_argument("--file", type=Path, help="Specific file to analyze")
    parser.add_argument("--dry-run", action="store_true", help="Show suggestions without applying")
    parser.add_argument("--apply", action="store_true", help="Apply suggested tags")
    parser.add_argument("--min-confidence", type=float, default=0.3,
                        help="Minimum confidence threshold (default: 0.3)")
    parser.add_argument("--summary", action="store_true",
                        help="Show summary stats only, no per-line details")
    args = parser.parse_args()

    # Determine files to process
    if args.file:
        files = [args.file]
    else:
        memory_dir = WORKSPACE / "memory"
        files = [WORKSPACE / "MEMORY.md"]
        if memory_dir.exists():
            files.extend(f for f in memory_dir.glob("*.md") if f.name != "archive")

    all_suggestions = []
    for filepath in files:
        if not filepath.exists():
            continue
        suggestions = analyze_file(filepath)
        suggestions = [s for s in suggestions if s["confidence"] >= args.min_confidence]
        all_suggestions.extend(suggestions)

        if suggestions:
            if not args.summary:
                print(f"\n--- {filepath} ---")
                for s in suggestions:
                    print(f"  L{s['line']}: [{s['suggested_type']}] (conf: {s['confidence']}) {s['content']}")

            if args.apply:
                count = apply_tags(filepath, suggestions, dry_run=False)
                if not args.summary:
                    print(f"  Applied {count} tags.")
            elif args.dry_run and not args.summary:
                print(f"  Would apply {len(suggestions)} tags.")

    if not all_suggestions:
        print("No untagged entries found. Memory is well-organized!")
    else:
        # Summary stats
        type_counts = {}
        for s in all_suggestions:
            t = s["suggested_type"]
            type_counts[t] = type_counts.get(t, 0) + 1

        if args.summary or not args.apply:
            print(f"\nTotal: {len(all_suggestions)} entries need classification.")
            print("By type:", ", ".join(f"[{t}]: {c}" for t, c in sorted(type_counts.items())))
            if not args.apply and not args.dry_run:
                print("Run with --apply to apply tags, or --dry-run to preview.")
        elif args.apply:
            print(f"\nApplied tags to {len(all_suggestions)} entries across {len(files)} files.")


if __name__ == "__main__":
    main()


path: scripts/extract_memories.sh
content: #!/usr/bin/env bash
# extract_memories.sh — Extract memories from conversation.
#
# Usage:
#   extract_memories.sh                    — Output extraction guide only
#   extract_memories.sh --auto <text>      — Auto-extract from provided text
#   extract_memories.sh --from-stdin       — Read conversation text from stdin

set -euo pipefail

WORKSPACE="${OPENCLAW_WORKSPACE:-$HOME/.openclaw/workspace}"
TODAY=$(date -u '+%Y-%m-%d')
DAILY_FILE="$WORKSPACE/memory/${TODAY}.md"

# Ensure memory directory exists
mkdir -p "$WORKSPACE/memory"

# Create daily file if it doesn't exist
if [ ! -f "$DAILY_FILE" ]; then
    cat > "$DAILY_FILE" << EOF
# ${TODAY}

EOF
fi

case "${1:-guide}" in
    --auto)
        shift
        TEXT="$*"
        if [ -z "$TEXT" ]; then
            echo "Error: no text provided"
            exit 1
        fi
        # Use Python for safe extraction (no shell injection)
        python3 - "$WORKSPACE" "$DAILY_FILE" "$TEXT" << 'PYEOF'
import sys, re, os
from datetime import datetime, timezone

workspace = sys.argv[1]
daily_file = sys.argv[2]
text = sys.argv[3]

# Type detection keywords (subset for quick extraction)
TYPE_MAP = {
    "PREF": ["prefer", "like", "dislike", "favorite", "习惯", "喜欢", "讨厌", "偏好"],
    "PROJ": ["project", "deploy", "website", "项目", "网站", "部署", "github.com"],
    "TECH": ["config", "database", "api", "server", "配置", "数据库", "端口", "prompt"],
    "LESSON": ["lesson", "learned", "mistake", "error", "教训", "错误", "记住", "don't"],
    "PEOPLE": ["person", "name", "team", "用户", "人", "名字", "老板"],
}

SENSITIVE = re.compile(r"(?:password|api.?key|token|secret|bearer|clh_|sk-|ghp_|-----BEGIN)", re.IGNORECASE)

lines = text.split("\n")
extracted = []
current_type = None

for line in lines:
    line = line.strip()
    if not line or line.startswith("#") or line.startswith("```"):
        continue
    # Skip sensitive content
    if SENSITIVE.search(line):
        continue
    # Detect type
    lower = line.lower()
    for t, keywords in TYPE_MAP.items():
        if any(kw in lower for kw in keywords):
            current_type = t
            break
    # Extract meaningful statements (not questions, not short)
    if len(line) > 15 and not line.endswith("?"):
        tag = f"[{current_type}]" if current_type else "[TECH]"
        extracted.append(f"{tag} {line.lstrip('- *')}")

if extracted:
    ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
    with open(daily_file, "a", encoding="utf-8") as f:
        f.write(f"\n## Auto-extracted ({ts})\n")
        for item in extracted[:10]:  # Cap at 10
            f.write(f"- {item}\n")
    print(f"Extracted {min(len(extracted), 10)} memories → {daily_file}")
else:
    print("Nothing to extract.")
PYEOF
        ;;

    --from-stdin)
        TEXT=$(cat)
        exec "$0" --auto "$TEXT"
        ;;

    guide|*)
        echo "=== Memory Extraction Guide ==="
        echo "Today: $TODAY"
        echo "Daily file: $DAILY_FILE"
        echo ""
        echo "--- Usage ---"
        echo "  --auto <text>    Auto-extract from provided text"
        echo "  --from-stdin     Read conversation from stdin"
        echo "  (no args)        Show this guide"
        echo ""
        echo "--- Extraction Instructions ---"
        echo "1. Review the conversation for durable facts"
        echo "2. Classify each by type: [PREF] [PROJ] [TECH] [LESSON] [PEOPLE]"
        echo "3. Append to $DAILY_FILE with type tags"
        echo "4. Promote high-importance items to MEMORY.md"
        echo ""
        echo "--- Template ---"
        echo ""
        cat << 'TMPL'
## [TYPE] Category Name
- Description of the memory item
- Another item if applicable

Where [TYPE] is one of:
  [PREF]   — User preference, habit, style
  [PROJ]   — Project context, active work
  [TECH]   — Technical detail, config, system fact
  [LESSON] — Lesson learned, error correction
  [PEOPLE] — Person, relationship, social context
TMPL
        ;;
esac


path: scripts/memory_decay.py
content: #!/usr/bin/env python3
"""
memory_decay.py — Temporal decay and archival for memory files.

Moves stale entries from MEMORY.md and memory/*.md to memory/archive/.
Preserves high-value entries (LESSON, active PROJECT) based on rules.
"""

import argparse
import os
import re
import shutil
from datetime import datetime, timedelta
from pathlib import Path

WORKSPACE = Path(os.environ.get("OPENCLAW_WORKSPACE", os.path.expanduser("~/.openclaw/workspace")))
MEMORY_FILE = WORKSPACE / "MEMORY.md"
MEMORY_DIR = WORKSPACE / "memory"
ARCHIVE_DIR = MEMORY_DIR / "archive"

TYPE_PATTERNS = {
    "PREF": re.compile(r"\[PREF\]", re.IGNORECASE),
    "PROJ": re.compile(r"\[PROJ\]", re.IGNORECASE),
    "TECH": re.compile(r"\[TECH\]", re.IGNORECASE),
    "LESSON": re.compile(r"\[LESSON\]", re.IGNORECASE),
    "PEOPLE": re.compile(r"\[PEOPLE\]", re.IGNORECASE),
    "TEMP": re.compile(r"\[TEMP\]", re.IGNORECASE),
}

DATE_PATTERN = re.compile(r"(\d{4}-\d{2}-\d{2})")


def detect_type(line: str) -> str | None:
    """Detect memory type from a line."""
    for mem_type, pattern in TYPE_PATTERNS.items():
        if pattern.search(line):
            return mem_type
    return None


def get_file_age(filepath: Path) -> int:
    """Get file age in days."""
    mtime = datetime.fromtimestamp(filepath.stat().st_mtime)
    return (datetime.now() - mtime).days


def parse_daily_files(max_age_days: int, dry_run: bool) -> dict:
    """Find and categorize daily memory files by age."""
    results = {"archived": [], "kept": [], "skipped": []}

    if not MEMORY_DIR.exists():
        return results

    for f in MEMORY_DIR.glob("*.md"):
        if f.name == "archive":
            continue
        # Extract date from filename
        match = DATE_PATTERN.search(f.name)
        if not match:
            results["skipped"].append(str(f))
            continue

        file_date = datetime.strptime(match.group(1), "%Y-%m-%d")
        age = (datetime.now() - file_date).days

        if age > max_age_days:
            archive_subdir = ARCHIVE_DIR / file_date.strftime("%Y-%m")
            if not dry_run:
                archive_subdir.mkdir(parents=True, exist_ok=True)
                shutil.move(str(f), str(archive_subdir / f.name))
            results["archived"].append({
                "file": str(f),
                "age_days": age,
                "destination": str(archive_subdir / f.name) if not dry_run else "would archive"
            })
        else:
            results["kept"].append({"file": str(f), "age_days": age})

    return results


def decay_memory_md(max_age_days: int, dry_run: bool) -> dict:
    """Analyze MEMORY.md for stale sections and orphaned entries."""
    results = {"sections": 0, "orphaned_lines": 0, "suggestions": []}

    if not MEMORY_FILE.exists():
        return results

    content = MEMORY_FILE.read_text(encoding="utf-8")
    lines = content.split("\n")
    current_section = None
    section_lines = {}

    for i, line in enumerate(lines):
        if line.startswith("## "):
            current_section = line.strip()
            section_lines[current_section] = {"start": i, "lines": []}
        elif current_section and line.strip():
            section_lines.setdefault(current_section, {"start": i, "lines": []})
            if current_section in section_lines:
                section_lines[current_section]["lines"].append((i, line))

    results["sections"] = len(section_lines)

    # Find lines without type tags in typed sections
    typed_sections = {k: v for k, v in section_lines.items()
                      if any(f"[{t}]" in k for t in TYPE_PATTERNS)}

    for section, data in typed_sections.items():
        for line_no, line in data["lines"]:
            if line.strip().startswith("-") and not detect_type(line):
                results["orphaned_lines"] += 1
                results["suggestions"].append(
                    f"Line {line_no + 1}: '{line.strip()[:60]}...' — missing type tag"
                )

    return results


def promote_lessons(min_references: int = 2, dry_run: bool = False) -> dict:
    """Find LESSON entries in daily files referenced 2+ times, promote to MEMORY.md."""
    results = {"candidates": [], "promoted": []}

    if not MEMORY_DIR.exists():
        return results

    # Collect all LESSON entries from daily files
    lesson_entries = {}  # normalized_text -> [{"file", "line", "text"}]
    for f in MEMORY_DIR.glob("*.md"):
        if f.name == "archive":
            continue
        content = f.read_text(encoding="utf-8")
        for i, line in enumerate(content.split("\n"), 1):
            if detect_type(line) == "LESSON" and line.strip().startswith("-"):
                # Normalize for comparison
                clean = re.sub(r"\[LESSON\]\s*", "", line.strip()).strip()
                clean = re.sub(r"^-\s*", "", clean).strip()
                key = clean.lower()[:80]  # First 80 chars for matching
                if key not in lesson_entries:
                    lesson_entries[key] = []
                lesson_entries[key].append({"file": str(f), "line": i, "text": clean})

    # Find entries appearing in 2+ different files
    for key, occurrences in lesson_entries.items():
        unique_files = set(o["file"] for o in occurrences)
        if len(unique_files) >= min_references:
            results["candidates"].append({
                "text": occurrences[0]["text"],
                "references": len(unique_files),
                "files": list(unique_files),
            })

    # Check which are NOT already in MEMORY.md
    if MEMORY_FILE.exists():
        memory_content = MEMORY_FILE.read_text(encoding="utf-8").lower()
    else:
        memory_content = ""

    for candidate in results["candidates"]:
        # Simple substring check
        if candidate["text"].lower()[:60] not in memory_content:
            results["promoted"].append(candidate)
            if not dry_run:
                # Append to LESSON section in MEMORY.md
                if MEMORY_FILE.exists():
                    content = MEMORY_FILE.read_text(encoding="utf-8")
                    if "## [LESSON]" in content:
                        content = content.replace(
                            "## [LESSON]",
                            f"## [LESSON]\n- {candidate['text']}",
                            1
                        )
                    else:
                        content += f"\n\n## [LESSON] Lessons Learned\n- {candidate['text']}\n"
                    MEMORY_FILE.write_text(content, encoding="utf-8")

    return results


def run_decay(max_age_days: int, archive_dir: Path, dry_run: bool) -> None:
    """Main decay runner."""
    global ARCHIVE_DIR
    ARCHIVE_DIR = archive_dir

    print(f"=== Memory Decay {'(DRY RUN)' if dry_run else ''} ===")
    print(f"Max age: {max_age_days} days")
    print(f"Archive dir: {ARCHIVE_DIR}")
    print()

    # 1. Process daily files
    print("--- Daily Memory Files ---")
    daily_results = parse_daily_files(max_age_days, dry_run)

    if daily_results["archived"]:
        print(f"Archived {len(daily_results['archived'])} files:")
        for item in daily_results["archived"]:
            print(f"  {item['file']} (age: {item['age_days']}d) → {item['destination']}")
    else:
        print("No files to archive.")

    print(f"Kept {len(daily_results['kept'])} recent files.")
    if daily_results["skipped"]:
        print(f"Skipped {len(daily_results['skipped'])} non-date files.")
    print()

    # 2. Analyze MEMORY.md
    print("--- MEMORY.md Analysis ---")
    md_results = decay_memory_md(max_age_days, dry_run)
    print(f"Sections: {md_results['sections']}")
    print(f"Orphaned lines (missing type tag): {md_results['orphaned_lines']}")
    if md_results["suggestions"]:
        print("Suggestions:")
        for s in md_results["suggestions"][:10]:
            print(f"  - {s}")
        if len(md_results["suggestions"]) > 10:
            print(f"  ... and {len(md_results['suggestions']) - 10} more")

    print()
    # 3. Promote LESSON entries
    print("--- LESSON Promotion ---")
    lesson_results = promote_lessons(dry_run=dry_run)
    if lesson_results["candidates"]:
        print(f"Found {len(lesson_results['candidates'])} LESSON candidates (referenced 2+ files):")
        for c in lesson_results["candidates"][:5]:
            print(f"  [{c['references']} refs] {c['text'][:60]}")
        if lesson_results["promoted"]:
            action = "Promoted" if not dry_run else "Would promote"
            print(f"{action} {len(lesson_results['promoted'])} to MEMORY.md")
        else:
            print("All candidates already in MEMORY.md.")
    else:
        print("No LESSON entries with 2+ references found.")
    print()

    print("=== Decay Complete ===")


def main():
    parser = argparse.ArgumentParser(description="Memory decay and archival tool")
    parser.add_argument("--max-age-days", type=int, default=90,
                        help="Archive daily files older than N days (default: 90)")
    parser.add_argument("--archive-dir", type=Path, default=ARCHIVE_DIR,
                        help="Archive directory (default: memory/archive)")
    parser.add_argument("--dry-run", action="store_true",
                        help="Show what would be done without making changes")
    parser.add_argument("--promote-only", action="store_true",
                        help="Only run LESSON promotion, skip archival")
    args = parser.parse_args()

    if args.promote_only:
        print(f"=== LESSON Promotion {'(DRY RUN)' if args.dry_run else ''} ===")
        lesson_results = promote_lessons(dry_run=args.dry_run)
        if lesson_results["candidates"]:
            print(f"Found {len(lesson_results['candidates'])} LESSON candidates (referenced 2+ files):")
            for c in lesson_results["candidates"][:10]:
                print(f"  [{c['references']} refs] {c['text'][:70]}")
            if lesson_results["promoted"]:
                action = "Promoted" if not args.dry_run else "Would promote"
                print(f"{action} {len(lesson_results['promoted'])} to MEMORY.md")
            else:
                print("All candidates already in MEMORY.md.")
        else:
            print("No LESSON entries with 2+ references found.")
        return

    run_decay(args.max_age_days, args.archive_dir, args.dry_run)


if __name__ == "__main__":
    main()


path: scripts/memory_health.sh
content: #!/usr/bin/env bash
# memory_health.sh — Generate a memory system health report.
# Shows stats, orphans, staleness, and token cost estimate.

set -euo pipefail

WORKSPACE="${OPENCLAW_WORKSPACE:-$HOME/.openclaw/workspace}"
MEMORY_FILE="$WORKSPACE/MEMORY.md"
MEMORY_DIR="$WORKSPACE/memory"

echo "=== Memory Health Report ==="
echo "Generated: $(date -u '+%Y-%m-%d %H:%M UTC')"
echo ""

# 1. MEMORY.md stats
echo "--- MEMORY.md ---"
if [ -f "$MEMORY_FILE" ]; then
    LINES=$(wc -l < "$MEMORY_FILE")
    CHARS=$(wc -c < "$MEMORY_FILE")
    # Rough token estimate: 1 token ≈ 4 chars for English, ≈ 2 chars for CJK
    TOKENS_EST=$((CHARS / 3))
    SECTIONS=$(grep -c '^## ' "$MEMORY_FILE" || echo 0)

    echo "  Lines: $LINES"
    echo "  Characters: $CHARS"
    echo "  Estimated tokens: ~$TOKENS_EST"
    echo "  Sections: $SECTIONS"

    # Count by type
    echo "  Entries by type:"
    for TYPE in PREF PROJ TECH LESSON PEOPLE TEMP; do
        COUNT=$(grep -ci "\[$TYPE\]" "$MEMORY_FILE" 2>/dev/null | tail -1 || echo 0)
        COUNT=$(echo "$COUNT" | tr -d '[:space:]')
        if [ -n "$COUNT" ] && [ "$COUNT" -gt 0 ] 2>/dev/null; then
            echo "    [$TYPE]: $COUNT"
        fi
    done

    # Untagged entries
    UNTAGGED=$(grep -c '^  - ' "$MEMORY_FILE" 2>/dev/null | tail -1 || echo 0)
    UNTAGGED=$(echo "$UNTAGGED" | tr -d '[:space:]')
    TAGGED=$(grep -c '\[PREF\]\|\[PROJ\]\|\[TECH\]\|\[LESSON\]\|\[PEOPLE\]' "$MEMORY_FILE" 2>/dev/null | tail -1 || echo 0)
    TAGGED=$(echo "$TAGGED" | tr -d '[:space:]')
    ORPHANS=$((UNTAGGED - TAGGED))
    if [ "$ORPHANS" -lt 0 ]; then ORPHANS=0; fi
    echo "  Untagged entries: ~$ORPHANS"

    # Health warnings
    if [ "$LINES" -gt 200 ]; then
        echo "  ⚠️  MEMORY.md exceeds 200 lines — consider decay/archival"
    fi
    if [ "$TOKENS_EST" -gt 5000 ]; then
        echo "  ⚠️  Estimated token cost is high (~$TOKENS_EST) — run memory_decay.py"
    fi
else
    echo "  MEMORY.md not found!"
fi
echo ""

# 2. Daily memory files
echo "--- Daily Notes (memory/*.md) ---"
if [ -d "$MEMORY_DIR" ]; then
    FILE_COUNT=$(find "$MEMORY_DIR" -maxdepth 1 -name "*.md" -type f | wc -l)
    TOTAL_SIZE=$(find "$MEMORY_DIR" -maxdepth 1 -name "*.md" -type f -exec cat {} + 2>/dev/null | wc -c)
    echo "  Files: $FILE_COUNT"
    echo "  Total size: $((TOTAL_SIZE / 1024))KB"

    # Oldest file
    OLDEST=$(find "$MEMORY_DIR" -maxdepth 1 -name "*.md" -type f -printf '%T+ %p\n' 2>/dev/null | sort | head -1)
    if [ -n "$OLDEST" ]; then
        OLDEST_DATE=$(echo "$OLDEST" | cut -d' ' -f1 | cut -d'T' -f1)
        echo "  Oldest file: $OLDEST_DATE"
    fi

    # Archive status
    ARCHIVE_DIR="$MEMORY_DIR/archive"
    if [ -d "$ARCHIVE_DIR" ]; then
        ARCHIVE_COUNT=$(find "$ARCHIVE_DIR" -name "*.md" -type f | wc -l)
        echo "  Archived files: $ARCHIVE_COUNT"
    else
        echo "  Archive: not initialized"
    fi
else
    echo "  memory/ directory not found!"
fi
echo ""

# 3. Session cache
echo "--- Session Cache ---"
CACHE_COUNT=$(ls /tmp/openclaw-session-cache-*.json 2>/dev/null | wc -l | tr -d '[:space:]' || echo 0)
CACHE_COUNT=${CACHE_COUNT:-0}
if [ "$CACHE_COUNT" -gt 0 ]; then
    echo "  Active caches: $CACHE_COUNT"
    for cache in /tmp/openclaw-session-cache-*.json; do
        KEYS=$(python3 -c "import json; print(len(json.load(open('$cache'))))" 2>/dev/null || echo 0)
        echo "    $(basename "$cache"): $KEYS entries"
    done
else
    echo "  No active session caches."
fi
echo ""

# 4. Actionable suggestions
echo "--- Suggestions ---"
python3 "$(dirname "$0")/memory_decay.py" --promote-only --dry-run 2>/dev/null | grep -E "Found|promote" | head -3 || echo "  No lessons to promote."

# Check if classify would find anything
UNTAGGED=$(python3 "$(dirname "$0")/classify_memory.py" --summary 2>/dev/null | grep "Total:" | head -1 || echo "")
if [ -n "$UNTAGGED" ]; then
    echo "  $UNTAGGED — run classify_memory.py --apply to fix"
fi

# Check archive candidates
ARCHIVE_CANDIDATES=$(python3 "$(dirname "$0")/memory_decay.py" --dry-run 2>/dev/null | grep "Archived" | head -1 || echo "")
if [ -n "$ARCHIVE_CANDIDATES" ]; then
    echo "  $ARCHIVE_CANDIDATES — run memory_decay.py to archive"
fi

echo ""
echo "=== End Report ==="


path: scripts/session_cache.py
content: #!/usr/bin/env python3
"""
session_cache.py — Session-scoped temporary memory cache.
Safe alternative to shell-based session_cache.sh (no shell injection).
Usage:
  session_cache.py set <key> <value>
  session_cache.py get <key>
  session_cache.py list
  session_cache.py remove <key>
  session_cache.py clear
"""

import json
import os
import re
import sys
from pathlib import Path

SESSION_ID = os.environ.get("OPENCLAW_SESSION_ID", "default")
SAFE_ID = re.sub(r"[^a-zA-Z0-9_-]", "", SESSION_ID) or "default"
CACHE_FILE = Path(f"/tmp/openclaw-session-cache-{SAFE_ID}.json")

# Sensitive patterns — refuse to cache
SENSITIVE_PATTERNS = [
    re.compile(r"(?:password|passwd|pwd)\s*[:=]\s*\S+", re.IGNORECASE),
    re.compile(r"(?:api[_-]?key|token|secret|bearer)\s*[:=]\s*\S+", re.IGNORECASE),
    re.compile(r"clh_[A-Za-z0-9]{30,}", re.IGNORECASE),
    re.compile(r"sk-[A-Za-z0-9]{20,}", re.IGNORECASE),
    re.compile(r"ghp_[A-Za-z0-9]{30,}", re.IGNORECASE),
]


def check_sensitive(text: str) -> str | None:
    for pattern in SENSITIVE_PATTERNS:
        if pattern.search(text):
            return f"Refused: matches sensitive pattern"
    return None


def load_cache() -> dict:
    if CACHE_FILE.exists():
        try:
            return json.loads(CACHE_FILE.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, OSError):
            return {}
    return {}


def save_cache(data: dict):
    CACHE_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")


def cmd_set(key: str, value: str):
    err = check_sensitive(value)
    if err:
        print(err)
        return
    data = load_cache()
    data[key] = value
    save_cache(data)
    print(f"Set: {key} = {value}")


def cmd_get(key: str):
    data = load_cache()
    print(data.get(key, ""))


def cmd_list():
    data = load_cache()
    if not data:
        print("(empty)")
    else:
        for k, v in data.items():
            print(f"{k}: {v}")


def cmd_remove(key: str):
    data = load_cache()
    data.pop(key, None)
    save_cache(data)
    print(f"Removed: {key}")


def cmd_clear():
    save_cache({})
    print("Cache cleared.")


def main():
    if len(sys.argv) < 2:
        print("Usage: session_cache.py {set|get|list|remove|clear} [key] [value]")
        sys.exit(1)

    cmd = sys.argv[1]
    args = sys.argv[2:]

    if cmd == "set" and len(args) >= 2:
        cmd_set(args[0], " ".join(args[1:]))
    elif cmd == "get" and len(args) >= 1:
        cmd_get(args[0])
    elif cmd == "list":
        cmd_list()
    elif cmd == "remove" and len(args) >= 1:
        cmd_remove(args[0])
    elif cmd == "clear":
        cmd_clear()
    else:
        print("Usage: session_cache.py {set|get|list|remove|clear} [key] [value]")
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/session_state.py
content: #!/usr/bin/env python3
"""
session_state.py — HOT RAM: Session-Scoped Working Memory (SESSION-STATE.md)
Replaces shell-based session_state.sh to eliminate shell injection risks.
Implements WAL (Write-Ahead Log) protocol for durability.
"""

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

WORKSPACE = Path(os.environ.get("OPENCLAW_WORKSPACE", os.path.expanduser("~/.openclaw/workspace")))
STATE_FILE = WORKSPACE / "SESSION-STATE.md"
TIMESTAMP = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")

# Patterns that indicate sensitive data — refuse to write
SENSITIVE_PATTERNS = [
    re.compile(r"(?:password|passwd|pwd)\s*[:=]\s*\S+", re.IGNORECASE),
    re.compile(r"(?:api[_-]?key|token|secret|bearer)\s*[:=]\s*\S+", re.IGNORECASE),
    re.compile(r"-----BEGIN (?:RSA |EC )?PRIVATE KEY-----", re.IGNORECASE),
    re.compile(r"clh_[A-Za-z0-9]{30,}", re.IGNORECASE),  # ClawHub tokens
    re.compile(r"sk-[A-Za-z0-9]{20,}", re.IGNORECASE),   # OpenAI keys
    re.compile(r"ghp_[A-Za-z0-9]{30,}", re.IGNORECASE),  # GitHub PAT
]


def sanitize(text: str) -> str:
    """Remove control characters and normalize whitespace."""
    # Strip control chars except newline/tab
    text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", text)
    return text.strip()


def check_sensitive(text: str) -> str | None:
    """Check if text contains sensitive data. Returns reason or None."""
    for pattern in SENSITIVE_PATTERNS:
        if pattern.search(text):
            return f"Refused: input matches sensitive pattern ({pattern.pattern[:40]}...)"
    return None


def ensure_state():
    """Create SESSION-STATE.md if it doesn't exist."""
    if not STATE_FILE.exists():
        content = f"""# SESSION-STATE.md — Active Working Memory (HOT RAM)

This file is the agent's "RAM" — survives compaction, restarts, distractions.
Write BEFORE responding (WAL protocol).

## Current Task
[None]

## Key Context
[None yet]

## Recent Decisions
[None yet]

## Pending Actions
- [ ] None

## Blockers
[None]

---
*Initialized: {TIMESTAMP}*
"""
        STATE_FILE.write_text(content, encoding="utf-8")


def read_state() -> str:
    ensure_state()
    return STATE_FILE.read_text(encoding="utf-8")


def write_state(content: str):
    STATE_FILE.write_text(content, encoding="utf-8")


def cmd_init():
    ensure_state()
    # Force re-init
    content = STATE_FILE.read_text(encoding="utf-8")
    content = re.sub(r"\*Initialized:.*?\*", f"*Initialized: {TIMESTAMP}*", content)
    content = re.sub(r"\*Updated:.*?\*", f"*Updated: {TIMESTAMP}*", content)
    # Reset sections
    content = re.sub(r"(## Current Task\n).*?(\n## )", r"\1[None]\n\n*Updated: " + TIMESTAMP + r"*\n\2", content, flags=re.DOTALL)
    write_state(content)
    print(f"Initialized: {STATE_FILE}")


def cmd_get():
    print(read_state())


def cmd_task(task: str):
    err = check_sensitive(task)
    if err:
        print(err)
        return
    task = sanitize(task)
    ensure_state()
    content = read_state()
    content = re.sub(
        r"(## Current Task\n).*?(\n## )",
        f"\\1{task}\n\n*Updated: {TIMESTAMP}*\\n\\2",
        content,
        flags=re.DOTALL,
    )
    write_state(content)
    print(f"Task set: {task}")


def cmd_context(key: str, value: str):
    err = check_sensitive(value)
    if err:
        print(err)
        return
    key = sanitize(key)
    value = sanitize(value)
    ensure_state()
    content = read_state()
    entry = f"- **{key}**: {value} *({TIMESTAMP})*"
    if "[None yet]" in content:
        content = content.replace("[None yet]", entry, 1)
    else:
        content = content.replace("## Recent Decisions", f"{entry}\n\n## Recent Decisions", 1)
    write_state(content)
    print(f"Context: {key} = {value}")


def cmd_decide(decision: str):
    err = check_sensitive(decision)
    if err:
        print(err)
        return
    decision = sanitize(decision)
    ensure_state()
    content = read_state()
    entry = f"- **{TIMESTAMP}**: {decision}"
    if "## Recent Decisions" in content:
        parts = content.split("## Recent Decisions", 1)
        after = parts[1]
        if "[None yet]" in after:
            after = after.replace("[None yet]", entry, 1)
        else:
            after = "\n" + entry + after
        content = parts[0] + "## Recent Decisions" + after
    write_state(content)
    print(f"Decided: {decision}")


def cmd_pending(action: str):
    err = check_sensitive(action)
    if err:
        print(err)
        return
    action = sanitize(action)
    ensure_state()
    content = read_state()
    if "- [ ] None" in content:
        content = content.replace("- [ ] None", f"- [ ] {action}", 1)
    else:
        content = content.replace("## Blockers", f"- [ ] {action}\n\n## Blockers", 1)
    write_state(content)
    print(f"Pending: {action}")


def cmd_done(action: str):
    action = sanitize(action)
    ensure_state()
    content = read_state()
    # Escape for regex
    escaped = re.escape(action)
    new_content = re.sub(r"- \[ \] " + escaped, f"- [x] {action} ✓", content)
    if new_content == content:
        print(f"Action not found: {action}")
    else:
        write_state(new_content)
        print(f"Done: {action}")


def cmd_blocker(blocker: str):
    err = check_sensitive(blocker)
    if err:
        print(err)
        return
    blocker = sanitize(blocker)
    ensure_state()
    content = read_state()
    entry = f"- **{TIMESTAMP}**: {blocker}"
    if "## Blockers" in content:
        parts = content.split("## Blockers", 1)
        after = parts[1]
        if "[None]" in after:
            after = after.replace("[None]", entry, 1)
        else:
            after = "\n" + entry + after
        content = parts[0] + "## Blockers" + after
    write_state(content)
    print(f"Blocker: {blocker}")


def cmd_clear():
    if STATE_FILE.exists():
        STATE_FILE.unlink()
    cmd_init()
    print("State cleared.")


def cmd_snapshot():
    """Save current SESSION-STATE.md as a timestamped snapshot."""
    ensure_state()
    snapshot_dir = WORKSPACE / "memory" / "session-snapshots"
    snapshot_dir.mkdir(parents=True, exist_ok=True)
    ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
    snapshot_file = snapshot_dir / f"session-{ts}.md"
    content = read_state()
    snapshot_file.write_text(content, encoding="utf-8")
    # Keep only last 20 snapshots
    snapshots = sorted(snapshot_dir.glob("session-*.md"))
    while len(snapshots) > 20:
        snapshots.pop(0).unlink()
    print(f"Snapshot saved: {snapshot_file}")


def cmd_restore(latest: bool = False, file: str | None = None):
    """Restore SESSION-STATE.md from a snapshot."""
    snapshot_dir = WORKSPACE / "memory" / "session-snapshots"
    if not snapshot_dir.exists():
        print("No snapshots found.")
        return

    if file:
        snapshot_file = Path(file)
        if not snapshot_file.exists():
            snapshot_file = snapshot_dir / file
    else:
        snapshots = sorted(snapshot_dir.glob("session-*.md"))
        if not snapshots:
            print("No snapshots found.")
            return
        snapshot_file = snapshots[-1]

    if not snapshot_file.exists():
        print(f"Snapshot not found: {snapshot_file}")
        return

    # Save current state before overwriting
    if STATE_FILE.exists():
        cmd_snapshot()

    content = snapshot_file.read_text(encoding="utf-8")
    write_state(content)
    print(f"Restored from: {snapshot_file}")


def cmd_snapshots():
    """List available snapshots."""
    snapshot_dir = WORKSPACE / "memory" / "session-snapshots"
    if not snapshot_dir.exists():
        print("No snapshots found.")
        return
    snapshots = sorted(snapshot_dir.glob("session-*.md"))
    if not snapshots:
        print("No snapshots found.")
        return
    for s in snapshots:
        size = s.stat().st_size
        mtime = datetime.fromtimestamp(s.stat().st_mtime, tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
        print(f"  {s.name}  ({size}B, {mtime} UTC)")
    print(f"Total: {len(snapshots)} snapshots")


def main():
    if len(sys.argv) < 2:
        print("Usage: session_state.py {init|get|task|context|decide|pending|done|blocker|clear} [args]")
        sys.exit(1)

    cmd = sys.argv[1]
    args = sys.argv[2:]

    commands = {
        "init": lambda: cmd_init(),
        "get": lambda: cmd_get(),
        "task": lambda: cmd_task(" ".join(args)),
        "context": lambda: cmd_context(args[0], " ".join(args[1:])) if len(args) >= 2 else print("Usage: context <key> <value>"),
        "decide": lambda: cmd_decide(" ".join(args)),
        "pending": lambda: cmd_pending(" ".join(args)),
        "done": lambda: cmd_done(" ".join(args)),
        "blocker": lambda: cmd_blocker(" ".join(args)),
        "clear": lambda: cmd_clear(),
        "snapshot": lambda: cmd_snapshot(),
        "restore": lambda: cmd_restore(file=" ".join(args) if args else None),
        "snapshots": lambda: cmd_snapshots(),
    }

    if cmd in commands:
        commands[cmd]()
    else:
        print(f"Unknown command: {cmd}")
        print("Available: init, get, task, context, decide, pending, done, blocker, clear, snapshot, restore, snapshots")
        sys.exit(1)


if __name__ == "__main__":
    main()


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