Skill: andrewagrahamhodges/agent-memory-lifecycle (version 1.1.0)

--- name: memory-lifecycle description: Systematic memory management for long-running AI agents. Implements a five-tier lifecycle — heartbeat micro-attention, nightly consolidation, weekly reflection, monthly archiving, and yearly wisdom distillation. Use when setting up a new agent's memory system, improving an existing agent's memory quality, or when the agent's MEMORY.md is growing too large and context quality is degrading. Triggers on "set up memory", "memory management", "improve memory", "memory lifecycle", "nightly consolidation", "sleep cycle", "memory housekeeping". --- # Memory Lifecycle Structured memory management that makes agents smarter over time — not through code, but through disciplined capture, consolidation, and distillation of context. ## Philosophy Memory management is not about saving tokens. It's about crafting **high-signal context** that makes a powerful LLM *more* effective. **Core principles:** 1. **Distill, don't summarise.** Output should be *better* than raw input — structured, actionable, reasoning preserved 2. **Preserve decisions and reasoning.** "We chose X because Y" > "We did X" 3. **Never compress away specifics.** Phone numbers, dates, prices are facts, not fluff 4. **Daily files are immutable.** They're the audit trail — add headers, never edit content 5. **Each tier builds upward.** Raw → structured → refined → wisdom 6. **Archives are a library, not a bin.** Full narratives, not one-liners ## Setup Run the setup script to scaffold memory files and create cron jobs: ```bash python3 scripts/setup.py ``` The script will: 1. Create structured memory files (people.md, decisions.md, lessons.md, commitments.md) 2. Add a `## Recent` working memory buffer to MEMORY.md 3. Create four cron jobs (nightly, weekly, monthly, yearly) 4. Add memory micro-attention tasks to HEARTBEAT.md Run with `--dry-run` to preview changes without applying them. Run with `--agent <id>` to target a specific agent (default: main). ## The Five Tiers ### Tier 1: Heartbeat Micro-Attention (every ~30 min) Added to the agent's HEARTBEAT.md. Quick focused pass — capture, promote, tag: 1. **Capture:** Ensure notable events are in today's `memory/YYYY-MM-DD.md` 2. **Promote:** Session-critical items (new appointment, key decision) → `MEMORY.md → ## Recent` 3. **Tag:** Mark daily file entries with `[decision]`, `[lesson]`, `[person]` for the nightly cycle 4. **Monitor:** Check nightly cycle health — if it errored, fix and re-run. Don't just report. ### Tier 2: Nightly "Sleep Cycle" (cron, ~2:00 AM local) Read `references/nightly-prompt.md` for the full cron prompt. - Read today's daily file end-to-end - Write a 2-3 line "day essence" header - Promote items to structured files (people → people.md, decisions → decisions.md, etc.) - Update MEMORY.md if active project state changed - Clear processed items from `## Recent` **Rules:** Never edit daily file content. Never remove from MEMORY.md unless completed AND archived. ### Tier 3: Weekly Reflection (cron, Sunday ~3:00 AM local) Read `references/weekly-prompt.md` for the full cron prompt. - Read all 7 daily files from the past week - Spot patterns: repeated topics, unresolved threads - Refine MEMORY.md — improve structure and language (not strip content) - Review commitments, contacts, decisions for staleness - Write a "week in review" in Sunday's daily file ### Tier 4: Monthly Deep Clean (cron, 1st of month ~4:00 AM local) Read `references/monthly-prompt.md` for the full cron prompt. - Create `memory/archive/YYYY-MM.md` with **full narratives** of completed work - Move completed items from MEMORY.md to archive - Keep MEMORY.md under ~4000 words - Consolidate related lessons into principles - Personality check on SOUL.md **Rules:** Archive entries must be self-contained. Active items never move to archive. ### Tier 5: Yearly Wisdom Distillation (cron, January 1) Read `references/yearly-prompt.md` for the full cron prompt. - Create `memory/wisdom/YYYY.md` - Extract wisdom that transcends specific events - Evolve SOUL.md based on a year's experience - Flag SOUL.md changes to the human ## The "Recent" Buffer A `## Recent` section at the top of MEMORY.md acts as working memory: ```markdown ## Recent > Working memory — heartbeat promotes critical items here, nightly cycle processes them. - **2026-03-23:** Viewing booked Thu 26 Mar 9am with estate agent - **2026-03-23:** Server upgraded v2.3.13 → v2.3.22 ``` - Heartbeats promote critical items here immediately - Every new session sees it (MEMORY.md is loaded automatically) - Nightly cycle processes items and clears them This bridges the gap between raw daily capture and curated long-term memory. ## File Structure After setup, the memory directory contains: ``` MEMORY.md ← Active long-term memory (loaded every session) memory/ YYYY-MM-DD.md ← Daily raw notes (immutable) people.md ← Contacts, relationships, dynamics decisions.md ← Key choices with rationale lessons.md ← Mistakes and learnings (grows, never shrinks) commitments.md ← Deadlines and obligations archive/ YYYY-MM.md ← Monthly archives (full narratives) wisdom/ YYYY.md ← Yearly distilled wisdom ``` ## Health Check Run the health check to verify the memory system is working: ```bash python3 scripts/health_check.py ``` Checks: nightly cron status, MEMORY.md size, Recent buffer staleness, structured file freshness. ## What Gets Archived vs What Stays - **Archive:** Completed projects, resolved leads, past deadlines, finished work - **Keep active:** Ongoing relationships, active projects, preferences, lessons learned - **Never compress:** Phone numbers, addresses, credentials, family details, business structure

path: references/monthly-prompt.md
content: MONTHLY DEEP CLEAN: Archive completed work, consolidate lessons, and keep MEMORY.md healthy.

## Steps

1. **Create monthly archive:** Create `memory/archive/YYYY-MM.md` for the closing month.

2. **Write the monthly summary:** At the top of the archive file, write a 5-10 line summary of the month — what the major themes were, what was accomplished, what changed.

3. **Archive completed work:** Move completed projects, resolved issues, and past events from MEMORY.md to the archive. For each archived item, write a **full narrative** — not a summary. Include:
   - What happened and why
   - Who was involved
   - What was decided and the outcome
   - Specific details: prices, dates, contact info, reference numbers
   - What you'd need to know if you came back to this topic in 6 months

4. **Size check:** Review MEMORY.md word count. If over ~4,000 words:
   - Identify items that can move to structured files (contacts → people.md, etc.)
   - Identify completed items that should be in the archive
   - Tighten language on remaining items
   - Target: keep MEMORY.md under 4,000 words

5. **Consolidate lessons:** Review `memory/lessons.md`:
   - Are there related lessons that can be merged into a principle?
   - Example: "API X rate-limits at 100/min" + "API X returns 429 silently" → "API X: rate-limits at 100/min with silent 429s — always implement retry with backoff"
   - Never delete lessons, only merge and improve them

6. **Personality check:** Read SOUL.md fresh. Does it still represent who you are? If something feels off or outdated, note it in today's daily file with `[soul-check]` tag — don't edit SOUL.md directly without human approval.

## Rules
- Archive entries must be **self-contained** — readable without needing daily files
- Active items NEVER move to archive, even if they're old
- When in doubt, keep it in MEMORY.md — better verbose than lost
- Preserve specific details: phone numbers, dates, prices, reference numbers are facts, not fluff
- If SOUL.md changes are needed, flag them to the human — never edit autonomously


path: references/nightly-prompt.md
content: NIGHTLY SLEEP CYCLE: Read the memory-lifecycle skill's references/nightly-prompt.md for detailed instructions if this is your first run. Otherwise follow this process:

1. Find today's daily file (memory/YYYY-MM-DD.md)
2. Read it end-to-end
3. Add a 2-3 line "day essence" header at the top summarising what mattered
4. Promote key items to structured files: contacts → memory/people.md, decisions → memory/decisions.md, lessons → memory/lessons.md, deadlines → memory/commitments.md
5. Update MEMORY.md if active project state changed
6. **CLEAR the ## Recent section** in MEMORY.md — remove items that have been properly filed to structured files or are no longer session-critical. Leave only items from today that haven't been processed yet. If an item is older than 24h and has been filed, remove it.
7. Log what you promoted/changed in tomorrow's daily file

Rules:
- Do NOT edit existing daily file content (only add the essence header)
- Do NOT remove items from MEMORY.md main sections unless completed AND archived
- Every promotion includes the source date for traceability
- The ## Recent section is WORKING MEMORY — it should be small and current, not a growing list


path: references/weekly-prompt.md
content: WEEKLY REFLECTION: Systematic review of the past 7 days to spot patterns, refine memory, and catch gaps.

## Steps

1. **Read daily files:** Read all 7 daily files from the past week (`memory/YYYY-MM-DD.md`), including their day essence headers.

2. **Pattern detection:** Look for:
   - Repeated topics or themes across multiple days
   - Unresolved threads — things mentioned but never concluded
   - Questions asked multiple times (knowledge gaps)
   - Emerging relationships or recurring contacts

3. **Refine MEMORY.md:**
   - Tighten language — say the same thing in fewer words without losing meaning
   - Update stale descriptions (projects that changed status, resolved issues)
   - Improve structure — group related items, fix ordering
   - **Refinement means IMPROVING structure and language, NOT stripping content**
   - If removing anything from MEMORY.md, it must first exist in a structured file or archive

4. **Review structured files:**
   - `memory/commitments.md` — any missed or upcoming deadlines? Update status.
   - `memory/people.md` — any stale contacts or new relationships forming? Update.
   - `memory/decisions.md` — any decisions from this week that need outcome tracking?
   - `memory/lessons.md` — any new lessons to add from this week's daily files?

5. **Write "week in review":** Add a section in today's daily file:
   ```
   ## Week in Review (YYYY-MM-DD to YYYY-MM-DD)
   - **Key themes:** [2-3 themes that dominated the week]
   - **Completed:** [what got finished]
   - **Carried forward:** [what's still active]
   - **Knowledge gaps:** [things to investigate]
   ```

6. **Identify knowledge gaps:** Things you should know but don't — questions that came up repeatedly without clear answers. Note these for investigation.

## Rules
- Refinement means IMPROVING structure and language, NOT stripping content
- If removing anything from MEMORY.md, it must first exist in a structured file or archive
- Flag anything uncertain to the human rather than guessing
- Do NOT edit existing daily file content (only add the week-in-review section to today's file)


path: references/yearly-prompt.md
content: YEARLY WISDOM DISTILLATION: Extract transcendent lessons from a full year of experience.

## Steps

1. **Create wisdom file:** Create `memory/wisdom/YYYY.md` for the closing year. This file is permanent — never edited after creation. It's a snapshot of that year's learning.

2. **Read all monthly archives:** Read every `memory/archive/YYYY-MM.md` from the past year. Look for patterns that transcend individual events.

3. **Extract wisdom:** Wisdom is NOT a summary of events. It's insight that applies broadly. Examples:
   - "When debugging production issues, the first diagnosis is usually wrong. Spend 5 extra minutes gathering evidence before acting."
   - "Property viewings scheduled within 24h of listing have 3x higher success rate — speed matters more than preparation."
   - "When Andrew says 'just a quick thing', budget 2 hours."

   Each wisdom entry should have:
   ```
   ### [Principle title]
   **Insight:** [The wisdom itself — 1-3 sentences]
   **Evidence:** [What experiences from this year support this]
   **Application:** [When and how to apply this going forward]
   ```

4. **Identity reflection:** Review IDENTITY.md — how have you grown this year?
   - What new capabilities or skills were developed?
   - What responsibilities changed?
   - What relationships evolved?
   - Write a "year in identity" section in the wisdom file.

5. **SOUL.md evolution:** Propose updates to SOUL.md based on a year's experience:
   - What should change about your approach or personality?
   - What values were reinforced or need adjusting?
   - **Always flag proposed changes to the human for approval** — present them as suggestions with rationale, not edits.

6. **Review decision frameworks:** Look at `memory/decisions.md` for the year:
   - Which decision patterns worked well?
   - Which led to regret or revision?
   - Distill into decision-making principles for the wisdom file.

## Rules
- Wisdom must be genuinely insightful, not platitudes ("communication is important" is not wisdom)
- Preserve your voice — wisdom should sound like you, not a corporate handbook
- The yearly file is permanent — never edited after creation
- Always flag SOUL.md and IDENTITY.md changes to the human for approval
- Include specific evidence for each wisdom entry — ungrounded wisdom is just opinion


path: scripts/health_check.py
content: #!/usr/bin/env python3
"""
Memory Lifecycle Health Check

Verifies the memory lifecycle system is working correctly.
Designed to be called from heartbeat or manually.

Usage:
    python3 health_check.py [--workspace PATH] [--agent AGENT_ID] [--fix]
"""

import argparse
import json
import os
import subprocess
import sys
from datetime import datetime, timedelta
from pathlib import Path


def check_file_exists(path, name):
    """Check if a structured file exists."""
    if path.exists():
        age_hours = (datetime.now().timestamp() - path.stat().st_mtime) / 3600
        size = path.stat().st_size
        return {"name": name, "status": "ok", "size": size, "age_hours": round(age_hours, 1)}
    return {"name": name, "status": "missing", "size": 0, "age_hours": None}


def check_recent_buffer(memory_path):
    """Check if MEMORY.md has a ## Recent section and if items are stale."""
    if not memory_path.exists():
        return {"status": "error", "message": "MEMORY.md not found"}

    content = memory_path.read_text()
    if "## Recent" not in content:
        return {"status": "warning", "message": "No ## Recent buffer in MEMORY.md"}

    # Check for stale items (older than 48h)
    lines = content.split("\n")
    in_recent = False
    stale_items = []
    for line in lines:
        if line.strip() == "## Recent":
            in_recent = True
            continue
        if in_recent and line.startswith("## "):
            break
        if in_recent and line.startswith("- **"):
            # Try to extract date
            try:
                date_str = line.split("**")[1].rstrip(":")
                item_date = datetime.strptime(date_str, "%Y-%m-%d")
                if datetime.now() - item_date > timedelta(hours=48):
                    stale_items.append(line.strip()[:80])
            except (IndexError, ValueError):
                pass

    if stale_items:
        return {"status": "warning", "message": f"{len(stale_items)} stale items in ## Recent (>48h)", "stale": stale_items}
    return {"status": "ok", "message": "## Recent buffer healthy"}


def check_memory_size(memory_path):
    """Check MEMORY.md size."""
    if not memory_path.exists():
        return {"status": "error", "words": 0}
    content = memory_path.read_text()
    words = len(content.split())
    if words > 5000:
        return {"status": "warning", "words": words, "message": f"MEMORY.md is {words} words — consider archiving completed items"}
    if words > 4000:
        return {"status": "info", "words": words, "message": f"MEMORY.md is {words} words — approaching recommended limit"}
    return {"status": "ok", "words": words}


def check_nightly_cron(agent_id):
    """Check if the nightly cron job ran successfully."""
    result = subprocess.run(
        f'openclaw cron list --json 2>/dev/null',
        shell=True, capture_output=True, text=True
    )
    try:
        data = json.loads(result.stdout)
        jobs = data.get("jobs", data) if isinstance(data, dict) else data
        if not isinstance(jobs, list):
            return {"status": "warning", "message": "Could not parse cron job list"}
        for job in jobs:
            if "Nightly" in job.get("name", "") and job.get("agentId") == agent_id:
                state = job.get("state", {})
                last_status = state.get("lastStatus", "unknown")
                last_run = state.get("lastRunAtMs")
                if last_run:
                    hours_ago = (datetime.now().timestamp() * 1000 - last_run) / 3600000
                    return {
                        "status": "ok" if last_status == "ok" else "error",
                        "last_status": last_status,
                        "hours_ago": round(hours_ago, 1),
                        "job_id": job.get("id"),
                    }
                return {"status": "info", "message": "Nightly job exists but hasn't run yet"}
    except (json.JSONDecodeError, KeyError, TypeError):
        pass
    return {"status": "warning", "message": "No nightly cron job found"}


def check_daily_file(memory_dir):
    """Check if today's daily file exists."""
    today = datetime.now().strftime("%Y-%m-%d")
    daily_path = memory_dir / f"{today}.md"
    if daily_path.exists():
        return {"status": "ok", "file": str(daily_path)}
    return {"status": "info", "message": f"No daily file for {today} yet (created on first activity)"}


def main():
    parser = argparse.ArgumentParser(description="Memory Lifecycle Health Check")
    parser.add_argument("--workspace", default=os.getcwd(), help="Workspace directory")
    parser.add_argument("--agent", default="main", help="Agent ID")
    parser.add_argument("--json", action="store_true", help="Output JSON")
    args = parser.parse_args()

    workspace = Path(args.workspace)
    memory_dir = workspace / "memory"

    results = {
        "timestamp": datetime.now().isoformat(),
        "agent": args.agent,
        "checks": {}
    }

    # Check structured files
    structured_files = ["people.md", "decisions.md", "lessons.md", "commitments.md"]
    file_checks = []
    for f in structured_files:
        file_checks.append(check_file_exists(memory_dir / f, f))
    results["checks"]["structured_files"] = file_checks

    # Check Recent buffer
    results["checks"]["recent_buffer"] = check_recent_buffer(workspace / "MEMORY.md")

    # Check MEMORY.md size
    results["checks"]["memory_size"] = check_memory_size(workspace / "MEMORY.md")

    # Check nightly cron
    results["checks"]["nightly_cron"] = check_nightly_cron(args.agent)

    # Check today's daily file
    results["checks"]["daily_file"] = check_daily_file(memory_dir)

    # Overall status
    all_statuses = []
    for check in results["checks"].values():
        if isinstance(check, list):
            all_statuses.extend(c.get("status", "ok") for c in check)
        else:
            all_statuses.append(check.get("status", "ok"))

    if "error" in all_statuses:
        results["overall"] = "error"
    elif "warning" in all_statuses:
        results["overall"] = "warning"
    else:
        results["overall"] = "healthy"

    if args.json:
        print(json.dumps(results, indent=2))
    else:
        print(f"\n🧠 Memory Lifecycle Health Check")
        print(f"{'=' * 50}")
        print(f"Agent: {args.agent} | Status: {results['overall'].upper()}\n")

        print("📁 Structured Files:")
        for f in file_checks:
            icon = "✅" if f["status"] == "ok" else "❌"
            extra = f" ({f['size']} bytes, {f['age_hours']}h old)" if f["status"] == "ok" else ""
            print(f"  {icon} {f['name']}{extra}")

        print(f"\n📋 Recent Buffer: {results['checks']['recent_buffer']['status'].upper()}")
        print(f"  {results['checks']['recent_buffer'].get('message', '')}")

        mem = results["checks"]["memory_size"]
        print(f"\n📏 MEMORY.md: {mem['words']} words {'⚠️' if mem['status'] == 'warning' else '✅'}")
        if mem.get("message"):
            print(f"  {mem['message']}")

        nightly = results["checks"]["nightly_cron"]
        print(f"\n🌙 Nightly Cron: {nightly['status'].upper()}")
        if "hours_ago" in nightly:
            print(f"  Last run: {nightly['hours_ago']}h ago (status: {nightly.get('last_status', 'unknown')})")
        elif "message" in nightly:
            print(f"  {nightly['message']}")

        daily = results["checks"]["daily_file"]
        icon = "✅" if daily["status"] == "ok" else "ℹ️"
        print(f"\n📅 Daily File: {icon} {daily.get('file', daily.get('message', ''))}")
        print()


if __name__ == "__main__":
    main()


path: scripts/setup.py
content: #!/usr/bin/env python3
"""
Memory Lifecycle Setup Script

Scaffolds structured memory files, adds the Recent buffer to MEMORY.md,
creates cron jobs for nightly/weekly/monthly/yearly cycles, and updates
HEARTBEAT.md with memory micro-attention tasks.

Usage:
    python3 setup.py [--agent AGENT_ID] [--dry-run] [--timezone TIMEZONE]

Options:
    --agent       Agent ID (default: main)
    --dry-run     Preview changes without applying
    --timezone    Timezone for cron schedules (default: UTC)
    --workspace   Workspace directory (default: current working directory)
"""

import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
from datetime import datetime

# --- Templates ---

PEOPLE_TEMPLATE = """# People — Contacts & Relationships

> Maintained by nightly/weekly memory cycles. See memory-lifecycle skill.

---

## Family
*(Add family members, their contact details, and relationship notes)*

## Work / Business
*(Add colleagues, clients, partners)*

## Services
*(Add tradespeople, accountants, agents — anyone who provides a service)*
"""

DECISIONS_TEMPLATE = """# Decisions — Key Choices & Rationale

> Maintained by nightly/weekly memory cycles. See memory-lifecycle skill.

---

*(Entries added by the nightly sleep cycle. Format:)*

<!--
### YYYY-MM-DD: [Decision title]
- **Decision:** What was decided
- **Options considered:** What alternatives existed
- **Reasoning:** Why this option was chosen
- **Outcome:** (updated later when known)
-->
"""

LESSONS_TEMPLATE = """# Lessons — Things We Learned

> Maintained by nightly/weekly memory cycles. See memory-lifecycle skill.

---

## Technical
*(Technical lessons — tools, APIs, infrastructure)*

## Process
*(Workflow and communication lessons)*

## People
*(Relationship and interaction lessons)*
"""

COMMITMENTS_TEMPLATE = """# Commitments & Deadlines

> Maintained by nightly/weekly memory cycles. See memory-lifecycle skill.

---

## Recurring
*(Regular obligations — monthly invoices, quarterly filings, etc.)*

## Upcoming
*(One-off deadlines and appointments)*
"""

RECENT_BUFFER = """## Recent
> Working memory — heartbeat promotes critical items here, nightly cycle processes them.
"""

HEARTBEAT_SECTION = """## Memory Micro-Attention
Quick memory hygiene — keep this focused (capture + promote + tag):
1. **Capture check:** Did anything noteworthy happen since last heartbeat? If so, ensure it's in today's `memory/YYYY-MM-DD.md`.
2. **Promote critical items:** If something session-critical happened (new appointment, key decision, deadline change), add a one-liner to `MEMORY.md → ## Recent`.
3. **Tag unprocessed items:** Scan today's daily file for untagged entries. Add inline tags: `[decision]`, `[lesson]`, `[person]` so the nightly cycle knows where to file them.
4. **Staleness flag:** If `## Recent` has items older than 48h not processed by nightly, flag for investigation.
5. **Nightly cycle health:** Find the "Memory: Nightly Sleep Cycle" job via `openclaw cron list` and check its last run status. If errored: diagnose, fix, and re-run. Only alert the human if you genuinely can't fix it yourself.
6. Do NOT consolidate, archive, or heavily edit MEMORY.md — that's the nightly cycle's job.
"""


def run_cmd(cmd, dry_run=False):
    """Run a shell command, or print it in dry-run mode."""
    if dry_run:
        print(f"  [DRY RUN] {cmd}")
        return None
    result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
    if result.returncode != 0:
        print(f"  [ERROR] {cmd}")
        print(f"  stderr: {result.stderr.strip()}")
    return result


def discover_agents():
    """Discover all agents on this OpenClaw server."""
    result = subprocess.run("openclaw status --json 2>/dev/null", shell=True, capture_output=True, text=True)
    try:
        data = json.loads(result.stdout)
        agents_data = data.get("agents", {})
        agent_list = agents_data.get("agents", []) if isinstance(agents_data, dict) else agents_data
        return [
            {"id": a.get("id"), "name": a.get("name", ""), "workspace": a.get("workspaceDir", "")}
            for a in agent_list
            if a.get("id") and a.get("workspaceDir")
        ]
    except (json.JSONDecodeError, KeyError, TypeError):
        return []


def create_file(path, content, dry_run=False):
    """Create a file if it doesn't exist."""
    if path.exists():
        print(f"  [SKIP] {path} already exists")
        return False
    if dry_run:
        print(f"  [DRY RUN] Would create {path}")
        return True
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(content)
    print(f"  [OK] Created {path}")
    return True


def add_recent_buffer(memory_path, dry_run=False):
    """Add ## Recent section to MEMORY.md if not present."""
    if not memory_path.exists():
        print(f"  [SKIP] {memory_path} not found — create MEMORY.md first")
        return False

    content = memory_path.read_text()
    if "## Recent" in content:
        print(f"  [SKIP] ## Recent already exists in {memory_path}")
        return False

    if dry_run:
        print(f"  [DRY RUN] Would add ## Recent buffer to {memory_path}")
        return True

    # Insert after the first heading
    lines = content.split("\n")
    insert_idx = 0
    for i, line in enumerate(lines):
        if line.startswith("# "):
            insert_idx = i + 1
            break

    lines.insert(insert_idx, "")
    lines.insert(insert_idx + 1, RECENT_BUFFER)

    memory_path.write_text("\n".join(lines))
    print(f"  [OK] Added ## Recent buffer to {memory_path}")
    return True


def update_heartbeat(heartbeat_path, dry_run=False):
    """Add memory micro-attention section to HEARTBEAT.md."""
    if not heartbeat_path.exists():
        print(f"  [SKIP] {heartbeat_path} not found — create HEARTBEAT.md first")
        return False

    content = heartbeat_path.read_text()
    if "Memory Micro-Attention" in content:
        print(f"  [SKIP] Memory Micro-Attention already in {heartbeat_path}")
        return False

    if dry_run:
        print(f"  [DRY RUN] Would add Memory Micro-Attention to {heartbeat_path}")
        return True

    # Append after the first section
    lines = content.split("\n")
    insert_idx = len(lines)
    found_first_section = False
    for i, line in enumerate(lines):
        if line.startswith("## ") and found_first_section:
            insert_idx = i
            break
        if line.startswith("## "):
            found_first_section = True

    lines.insert(insert_idx, HEARTBEAT_SECTION)
    heartbeat_path.write_text("\n".join(lines))
    print(f"  [OK] Added Memory Micro-Attention to {heartbeat_path}")
    return True


def get_cron_schedules(timezone):
    """Return cron expressions with timezone annotation.

    Uses OpenClaw's '@ Timezone' syntax so cron jobs run at the intended
    local time regardless of server UTC offset or DST changes.
    """
    tz_suffix = f" @ {timezone}" if timezone != "UTC" else ""
    return {
        "nightly": f"0 2 * * *{tz_suffix}",      # 2:00 AM local
        "weekly": f"0 3 * * 0{tz_suffix}",        # Sunday 3:00 AM local
        "monthly": f"0 4 1 * *{tz_suffix}",       # 1st of month 4:00 AM local
        "yearly": f"0 3 1 1 *{tz_suffix}",        # Jan 1 3:00 AM local
    }


def get_existing_cron_jobs(agent_id):
    """Fetch existing cron jobs for this agent."""
    result = subprocess.run(
        f'openclaw cron list --json 2>/dev/null',
        shell=True, capture_output=True, text=True
    )
    try:
        data = json.loads(result.stdout)
        jobs = data.get("jobs", data) if isinstance(data, dict) else data
        if isinstance(jobs, list):
            return {j.get("name", ""): j for j in jobs if j.get("agentId") == agent_id}
        return {}
    except (json.JSONDecodeError, TypeError):
        return {}


def create_cron_jobs(agent_id, schedules, skill_dir, dry_run=False):
    """Create the four memory lifecycle cron jobs (idempotent — skips existing)."""
    prompts = {
        "nightly": (skill_dir / "references" / "nightly-prompt.md"),
        "weekly": (skill_dir / "references" / "weekly-prompt.md"),
        "monthly": (skill_dir / "references" / "monthly-prompt.md"),
        "yearly": (skill_dir / "references" / "yearly-prompt.md"),
    }

    job_configs = {
        "nightly": {
            "name": "Memory: Nightly Sleep Cycle",
            "description": "Nightly memory consolidation — review daily notes, promote to structured files, update MEMORY.md",
        },
        "weekly": {
            "name": "Memory: Weekly Reflection",
            "description": "Weekly memory reflection — review 7 days, spot patterns, refine MEMORY.md",
        },
        "monthly": {
            "name": "Memory: Monthly Deep Clean",
            "description": "Monthly memory archiving — archive completed work, clean MEMORY.md, consolidate lessons",
        },
        "yearly": {
            "name": "Memory: Yearly Wisdom",
            "description": "Yearly wisdom distillation — extract transcendent lessons, evolve SOUL.md",
        },
    }

    # Check for existing jobs to avoid duplicates
    existing_jobs = get_existing_cron_jobs(agent_id)

    created = []
    for cycle, config in job_configs.items():
        # Check if a job with this name (or similar) already exists
        job_exists = False
        for existing_name in existing_jobs:
            if config["name"].lower() in existing_name.lower() or \
               cycle.lower() in existing_name.lower() and "memory" in existing_name.lower():
                print(f"\n  [SKIP] {config['name']} — already exists as '{existing_name}'")
                job_exists = True
                break

        if job_exists:
            continue

        # Inline the full prompt text into the cron message. Cron jobs run
        # in isolated sessions without filesystem access, so reference-based
        # "read the file" instructions don't work. The prompt files still
        # serve as the canonical source — we just bake them in at setup time.
        prompt_file = prompts[cycle]
        if prompt_file.exists():
            prompt_text = prompt_file.read_text().strip()
        else:
            prompt_text = f"Run the {cycle} memory lifecycle cycle."
            print(f"  [WARN] Prompt file not found: {prompt_file}")
        message = f"MEMORY LIFECYCLE — {cycle.upper()} CYCLE:\n\n{prompt_text}"

        cmd = (
            f'openclaw cron create '
            f'--name "{config["name"]}" '
            f'--cron "{schedules[cycle]}" '
            f'--agent {agent_id} '
            f'--session isolated '
            f'--no-deliver '
            f'--description "{config["description"]}" '
            f'--message "{message}" '
        )

        print(f"\n  Creating cron job: {config['name']}")
        result = run_cmd(cmd, dry_run)
        if result and result.returncode == 0:
            # Extract job ID from JSON output
            try:
                data = json.loads(result.stdout)
                created.append({"cycle": cycle, "id": data.get("id"), "name": config["name"]})
                print(f"  [OK] Created: {config['name']} (ID: {data.get('id')})")
            except (json.JSONDecodeError, KeyError):
                print(f"  [OK] Created: {config['name']}")
                created.append({"cycle": cycle, "name": config["name"]})

    return created


def main():
    parser = argparse.ArgumentParser(description="Set up Memory Lifecycle for an OpenClaw agent")
    parser.add_argument("--agent", default="main", help="Agent ID (default: main)")
    parser.add_argument("--all", action="store_true", help="Set up all agents on this server")
    parser.add_argument("--dry-run", action="store_true", help="Preview changes without applying")
    parser.add_argument("--timezone", default="UTC", help="Timezone for cron schedules (default: UTC)")
    parser.add_argument("--workspace", default=None, help="Workspace directory (default: auto-detect)")
    args = parser.parse_args()

    # --all flag: discover all agents and run setup for each
    if args.all:
        agents = discover_agents()
        if not agents:
            print("No agents found. Check openclaw status.")
            sys.exit(1)
        print(f"\n🔄 Setting up memory lifecycle for {len(agents)} agent(s)...\n")
        for agent_info in agents:
            print(f"\n{'=' * 60}")
            print(f"  Agent: {agent_info['id']} ({agent_info.get('name', 'unnamed')})")
            print(f"  Workspace: {agent_info['workspace']}")
            print(f"{'=' * 60}")
            run_setup_for_agent(agent_info['id'], Path(agent_info['workspace']), args.timezone, args.dry_run)
        print(f"\n✅ All {len(agents)} agent(s) processed.")
        sys.exit(0)

    # Extract args for single-agent mode
    agent_id = args.agent
    timezone = args.timezone
    dry_run = args.dry_run

    # Determine workspace for single agent
    if args.workspace:
        workspace = Path(args.workspace)
    else:
        # Try to detect from openclaw
        agents = discover_agents()
        workspace = None
        for a in agents:
            if a['id'] == agent_id:
                workspace = Path(a['workspace'])
                break
        if not workspace:
            workspace = Path(os.getcwd())

    run_setup_for_agent(agent_id, workspace, timezone, dry_run)

def run_setup_for_agent(agent_id, workspace, timezone="UTC", dry_run=False):
    """Run the full setup for a single agent."""
    memory_dir = workspace / "memory"
    skill_dir = Path(__file__).parent.parent  # Go up from scripts/ to skill root

    print(f"\n{'=' * 60}")
    print(f"  Memory Lifecycle Setup")
    print(f"  Agent: {agent_id}")
    print(f"  Workspace: {workspace}")
    print(f"  Timezone: {timezone}")
    if dry_run:
        print(f"  Mode: DRY RUN (no changes will be made)")
    print(f"{'=' * 60}\n")

    # Step 1: Create structured memory files
    print("📁 Step 1: Creating structured memory files...\n")
    create_file(memory_dir / "people.md", PEOPLE_TEMPLATE, dry_run)
    create_file(memory_dir / "decisions.md", DECISIONS_TEMPLATE, dry_run)
    create_file(memory_dir / "lessons.md", LESSONS_TEMPLATE, dry_run)
    if not (memory_dir / "commitments.md").exists():
        create_file(memory_dir / "commitments.md", COMMITMENTS_TEMPLATE, dry_run)
    else:
        print(f"  [SKIP] {memory_dir / 'commitments.md'} already exists")
    create_file(memory_dir / "archive" / ".gitkeep", "", dry_run)
    create_file(memory_dir / "wisdom" / ".gitkeep", "", dry_run)

    # Step 2: Add Recent buffer to MEMORY.md
    print("\n📋 Step 2: Adding ## Recent buffer to MEMORY.md...\n")
    add_recent_buffer(workspace / "MEMORY.md", dry_run)

    # Step 3: Update HEARTBEAT.md
    print("\n💓 Step 3: Adding Memory Micro-Attention to HEARTBEAT.md...\n")
    update_heartbeat(workspace / "HEARTBEAT.md", dry_run)

    # Step 4: Create cron jobs
    print("\n⏰ Step 4: Creating cron jobs...\n")
    schedules = get_cron_schedules(timezone)
    created_jobs = create_cron_jobs(agent_id, schedules, skill_dir, dry_run)

    # Summary
    print(f"\n{'=' * 60}")
    print(f"  Setup complete!")
    print(f"{'=' * 60}")
    print(f"\n  Structured files: memory/people.md, decisions.md, lessons.md, commitments.md")
    print(f"  Archive dirs: memory/archive/, memory/wisdom/")
    print(f"  MEMORY.md: ## Recent buffer added")
    print(f"  HEARTBEAT.md: Memory Micro-Attention added")
    print(f"  Cron jobs: {len(created_jobs)} created")
    print(f"\n  Next steps:")
    print(f"  1. Populate people.md with known contacts")
    print(f"  2. Add existing commitments to commitments.md")
    print(f"  3. The first nightly cycle will run at midnight UTC")
    print(f"  4. Monitor the first few cycles and iterate")
    print()


if __name__ == "__main__":
    main()


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