Skill: fuuuuujichaos/better-memory (version 1.0.4)

--- name: better-memory description: One-shot smart installation and ongoing maintenance for a native OpenClaw memory stack with L1 daily logs (`memory/YYYY-MM-DD.md`), L2 sidecar summaries, and L3 weekly rollups in `MEMORY.md`. Use when installing memory capability, migrating legacy memory safely, capturing typed memory (`experience`/`value`/`standard`), running daily or weekly distillation, or generating monthly conflict/bloat review reports. metadata: {"openclaw":{"requires":{"bins":["python3"]},"config":{"stateDirs":[".openclaw-memory-os"],"example":"state_dir = \".openclaw-memory-os\""}}} --- # Better Memory V2 ## Smart Install (Recommended) Run once to inject memory capability additively and generate migration + schedule artifacts: `python3 skills/better-memory/scripts/smart_install.py --workspace .` Supported install customization: - `--daily-time HH:MM` (default `22:00`) - `--weekly-day MON..SUN` (default `SUN`) - `--weekly-time HH:MM` (default `20:00`) - `--monthly-day 1..31` (default `1`) - `--monthly-time HH:MM` (default `10:00`) - `--entry-threshold N` (default `8`) ## Memory Layers - L1 (native): `memory/YYYY-MM-DD.md` - L2 (sidecar): `.openclaw-memory-os/l2/{experience,value,standard}.md` - L3 (native): managed block in `MEMORY.md` Typed taxonomy: - `experience`: `think`, `say`, `do` - `value`: `good`, `bad` - `standard`: `right`, `wrong` ## Commands - Capture L1 typed memory: `python3 skills/better-memory/scripts/capture_memory.py --workspace . --kind <experience|value|standard> --axis <axis> --topic "<topic>" --statement "<statement>"` - Daily review (L1 -> L2): `python3 skills/better-memory/scripts/run_daily_review.py --workspace .` - Weekly rollup (L2 -> L3): `python3 skills/better-memory/scripts/run_weekly_rollup.py --workspace .` - Monthly advisory review (no auto cleanup): `python3 skills/better-memory/scripts/run_monthly_review.py --workspace .` - Apply approved monthly cleanup actions: `python3 skills/better-memory/scripts/apply_monthly_cleanup.py --workspace . --rerollup` - This command also writes an apply summary in `.openclaw-memory-os/reviews/apply-summary-*.md`. - Emit cron template: `python3 skills/better-memory/scripts/emit_cron_template.py --workspace .` - Legacy migration plan prepare/apply: - `python3 skills/better-memory/scripts/promote_legacy_memory.py --workspace . --prepare` - `python3 skills/better-memory/scripts/promote_legacy_memory.py --workspace . --apply` Compatibility command: - `python3 skills/better-memory/scripts/refine_memory.py --workspace .` - Runs daily review then weekly rollup in one command. ## Installation Policy - Preserve existing `AGENTS.md`, `MEMORY.md`, and daily logs. - Write only managed blocks in `AGENTS.md`, `MEMORY.md`, and optional `HEARTBEAT.md`. - Keep sidecar artifacts outside native `memory/` so `memory_search` behavior stays clean. - Generate migration review and editable migration plan; do not auto-import legacy lines unless enabled. ## Monthly Cleanup Policy - Monthly script only produces a report and suggested actions. - Monthly script also produces a disabled-by-default cleanup plan JSON. - Do not auto-delete or auto-rewrite memory on monthly review. - Resolve conflicts and bloat with user confirmation, enable selected plan actions, then apply. ## References Read `references/memory-schema.md` for schema and lifecycle details.

path: scripts/run_weekly_rollup.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
from pathlib import Path

from refine_memory import run_weekly_rollup


def main() -> int:
    parser = argparse.ArgumentParser(description="Run weekly L2 -> L3 memory rollup.")
    parser.add_argument("--workspace", default=".", help="Workspace path (default: current directory)")
    args = parser.parse_args()

    workspace = Path(args.workspace).expanduser().resolve()
    result = run_weekly_rollup(workspace)

    print(f"Workspace: {workspace}")
    print(f"MEMORY file: {result['memory_file']}")
    print(
        "Weekly topics: "
        f"experience={result['topics_experience']}, value={result['topics_value']}, standard={result['topics_standard']}"
    )
    print(f"Conflicts: {result['conflicts']}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/memory_os_common.py
content: from __future__ import annotations

import json
import re
from pathlib import Path

MANAGED_AGENT_BLOCK_START = "<!-- OPENCLAW_MEMORY_OS_START -->"
MANAGED_AGENT_BLOCK_END = "<!-- OPENCLAW_MEMORY_OS_END -->"
MANAGED_MEMORY_BLOCK_START = "<!-- OPENCLAW_MEMORY_OS_L3_START -->"
MANAGED_MEMORY_BLOCK_END = "<!-- OPENCLAW_MEMORY_OS_L3_END -->"
MANAGED_HEARTBEAT_BLOCK_START = "<!-- OPENCLAW_MEMORY_OS_HEARTBEAT_START -->"
MANAGED_HEARTBEAT_BLOCK_END = "<!-- OPENCLAW_MEMORY_OS_HEARTBEAT_END -->"

DEFAULT_SETTINGS = {
    "stale_days": 45,
    "max_l3_per_kind": 12,
    "min_evidence_for_l3": 1,
    "migration_days": 14,
    "daily_entries_soft_limit": 40,
    "daily_review_time": "22:00",
    "weekly_rollup_day": "SUN",
    "weekly_rollup_time": "20:00",
    "monthly_review_day": 1,
    "monthly_review_time": "10:00",
    "l1_entry_threshold": 8,
}

DEFAULT_STATE = {
    "last_daily_review_at": None,
    "last_weekly_rollup_at": None,
    "last_monthly_review_at": None,
    "last_processed_l1_entry_id": None,
}


def sidecar_dir(workspace: Path) -> Path:
    return workspace / ".openclaw-memory-os"


def config_path(workspace: Path) -> Path:
    return sidecar_dir(workspace) / "config.json"


def state_path(workspace: Path) -> Path:
    return sidecar_dir(workspace) / "state.json"


def load_settings(workspace: Path) -> dict:
    settings = dict(DEFAULT_SETTINGS)
    path = config_path(workspace)
    if not path.exists():
        return settings
    loaded = json.loads(path.read_text(encoding="utf-8"))
    settings.update({key: value for key, value in loaded.items() if key in DEFAULT_SETTINGS})
    return settings


def save_settings(workspace: Path, overrides: dict) -> Path:
    path = config_path(workspace)
    path.parent.mkdir(parents=True, exist_ok=True)
    settings = dict(DEFAULT_SETTINGS)
    settings.update({key: value for key, value in overrides.items() if key in DEFAULT_SETTINGS})
    path.write_text(json.dumps(settings, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
    return path


def load_state(workspace: Path) -> dict:
    state = dict(DEFAULT_STATE)
    path = state_path(workspace)
    if not path.exists():
        return state
    loaded = json.loads(path.read_text(encoding="utf-8"))
    state.update({key: value for key, value in loaded.items() if key in DEFAULT_STATE})
    return state


def save_state(workspace: Path, overrides: dict) -> Path:
    path = state_path(workspace)
    path.parent.mkdir(parents=True, exist_ok=True)
    state = dict(DEFAULT_STATE)
    current = load_state(workspace)
    state.update(current)
    state.update({key: value for key, value in overrides.items() if key in DEFAULT_STATE})
    path.write_text(json.dumps(state, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
    return path


def upsert_marked_block(original: str, start: str, end: str, block: str) -> str:
    pattern = re.compile(re.escape(start) + r".*?" + re.escape(end), re.DOTALL)
    replacement = block.strip()
    if pattern.search(original):
        return pattern.sub(replacement, original) + ("\n" if not original.endswith("\n") else "")
    if original.strip():
        return original.rstrip() + "\n\n" + replacement + "\n"
    return replacement + "\n"


def remove_marked_block(original: str, start: str, end: str) -> str:
    pattern = re.compile(r"\n?" + re.escape(start) + r".*?" + re.escape(end) + r"\n?", re.DOTALL)
    updated = pattern.sub("\n", original)
    collapsed = re.sub(r"\n{3,}", "\n\n", updated).strip()
    return (collapsed + "\n") if collapsed else ""


path: scripts/emit_cron_template.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
from pathlib import Path

from memory_os_common import load_settings, sidecar_dir

WEEKDAY_TO_CRON = {
    "SUN": "0",
    "MON": "1",
    "TUE": "2",
    "WED": "3",
    "THU": "4",
    "FRI": "5",
    "SAT": "6",
}


def hhmm_to_pair(raw: str) -> tuple[int, int]:
    hour, minute = raw.split(":")
    return int(hour), int(minute)


def emit_cron_template(workspace: Path) -> Path:
    settings = load_settings(workspace)

    daily_hour, daily_minute = hhmm_to_pair(settings["daily_review_time"])
    weekly_hour, weekly_minute = hhmm_to_pair(settings["weekly_rollup_time"])
    monthly_hour, monthly_minute = hhmm_to_pair(settings["monthly_review_time"])
    weekly_day = WEEKDAY_TO_CRON[settings["weekly_rollup_day"]]

    cron_lines = [
        "# Better Memory V2 cron template",
        "# Review timezone before enabling these lines.",
        f"# workspace={workspace}",
        "",
        "# 1) Catch-up run: every 2 hours, only execute when new L1 entries >= threshold",
        f"{daily_minute} */2 * * * cd {workspace} && python3 skills/better-memory/scripts/run_daily_review.py --workspace . --threshold-only",
        "",
        "# 2) Daily L1 -> L2 review (always run)",
        f"{daily_minute} {daily_hour} * * * cd {workspace} && python3 skills/better-memory/scripts/run_daily_review.py --workspace .",
        "",
        "# 3) Weekly L2 -> L3 rollup",
        f"{weekly_minute} {weekly_hour} * * {weekly_day} cd {workspace} && python3 skills/better-memory/scripts/run_weekly_rollup.py --workspace .",
        "",
        "# 4) Monthly advisory review report (no auto cleanup)",
        f"{monthly_minute} {monthly_hour} {settings['monthly_review_day']} * * cd {workspace} && python3 skills/better-memory/scripts/run_monthly_review.py --workspace .",
        "",
    ]

    output_path = sidecar_dir(workspace) / "cron-template.txt"
    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_text("\n".join(cron_lines), encoding="utf-8")
    return output_path


def main() -> int:
    parser = argparse.ArgumentParser(description="Emit cron template for Better Memory V2.")
    parser.add_argument("--workspace", default=".", help="Workspace path (default: current directory)")
    args = parser.parse_args()

    workspace = Path(args.workspace).expanduser().resolve()
    output_path = emit_cron_template(workspace)

    print(f"Workspace: {workspace}")
    print(f"Cron template: {output_path}")
    print("Generated tasks: catch-up daily, daily review, weekly rollup, monthly review")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/run_monthly_review.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
from pathlib import Path

from refine_memory import run_monthly_review


def main() -> int:
    parser = argparse.ArgumentParser(description="Generate monthly conflict/bloat review report.")
    parser.add_argument("--workspace", default=".", help="Workspace path (default: current directory)")
    args = parser.parse_args()

    workspace = Path(args.workspace).expanduser().resolve()
    result = run_monthly_review(workspace)

    print(f"Workspace: {workspace}")
    print(f"Report file: {result['report_file']}")
    print(f"Plan file: {result['plan_file']}")
    print(f"Plan actions: {result['plan_actions']}")
    print(f"Conflicts: {result['conflicts']}")
    print(f"Bloat topics: {result['bloat_topics']}")
    print(f"Redundant groups: {result['redundant_groups']}")
    print(f"Stale or superseded candidates: {result['stale_or_superseded']}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/capture_memory.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
from datetime import date
import re
from pathlib import Path
from textwrap import dedent

from memory_os_common import load_settings

AXIS_BY_KIND = {
    "experience": {"think", "say", "do"},
    "value": {"good", "bad"},
    "standard": {"right", "wrong"},
}

KIND_ALIASES = {
    "preference": "value",
}


def ensure_daily_file(path: Path, iso_date: str) -> None:
    if path.exists():
        text = path.read_text(encoding="utf-8")
        if "## L1 Entries" in text:
            return
        path.write_text(text.rstrip() + "\n\n## L1 Entries\n", encoding="utf-8")
        return

    template = dedent(
        f"""
        # {iso_date} Memory Log (L1)

        ## Format
        - [YYYY-MM-DD|M-YYYYMMDD-###|kind|axis|status|confidence|topic] statement

        ## L1 Entries
        """
    ).strip() + "\n"
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(template, encoding="utf-8")


def next_entry_id(path: Path, iso_date: str) -> str:
    ymd = iso_date.replace("-", "")
    prefix = f"M-{ymd}-"
    if not path.exists():
        return prefix + "001"

    max_num = 0
    pattern = re.compile(re.escape(prefix) + r"(\d{3})")
    for line in path.read_text(encoding="utf-8").splitlines():
        match = pattern.search(line)
        if not match:
            continue
        max_num = max(max_num, int(match.group(1)))
    return prefix + f"{max_num + 1:03d}"


def sanitize(text: str) -> str:
    return " ".join(text.replace("|", "/").replace("]", ")").split())


def normalize_kind(raw_kind: str) -> str:
    kind = raw_kind.strip().lower()
    return KIND_ALIASES.get(kind, kind)


def main() -> int:
    parser = argparse.ArgumentParser(description="Capture a typed L1 memory entry.")
    parser.add_argument("--workspace", default=".", help="Workspace path (default: current directory)")
    parser.add_argument("--kind", required=True, help="experience | value | standard (legacy preference is accepted)")
    parser.add_argument("--axis", required=True)
    parser.add_argument("--topic", required=True, help="Short topic label")
    parser.add_argument("--statement", required=True, help="Memory statement")
    parser.add_argument("--status", default="active", choices=["active", "candidate", "stale", "conflicted", "superseded"])
    parser.add_argument("--confidence", default="medium", choices=["low", "medium", "high"])
    args = parser.parse_args()

    kind = normalize_kind(args.kind)
    if kind not in AXIS_BY_KIND:
        raise SystemExit(f"Invalid kind '{args.kind}'. Allowed: {', '.join(sorted(AXIS_BY_KIND.keys()))}")

    axis = args.axis.strip().lower()
    allowed = AXIS_BY_KIND[kind]
    if axis not in allowed:
        raise SystemExit(f"Invalid axis '{axis}' for kind '{kind}'. Allowed: {', '.join(sorted(allowed))}")

    workspace = Path(args.workspace).expanduser().resolve()
    settings = load_settings(workspace)
    today = date.today().isoformat()
    daily_path = workspace / "memory" / f"{today}.md"
    ensure_daily_file(daily_path, today)

    entry_id = next_entry_id(daily_path, today)
    topic = sanitize(args.topic.strip().lower())
    statement = sanitize(args.statement.strip())

    line = f"- [{today}|{entry_id}|{kind}|{axis}|{args.status}|{args.confidence}|{topic}] {statement}"
    with daily_path.open("a", encoding="utf-8") as handle:
        if daily_path.read_text(encoding="utf-8").rstrip().endswith("## L1 Entries"):
            handle.write("\n")
        handle.write(line + "\n")

    print(f"Captured: {entry_id}")
    print(f"File: {daily_path}")
    print(line)
    entry_count = int(entry_id.rsplit("-", 1)[-1])
    if entry_count > settings["daily_entries_soft_limit"]:
        print(
            f"Warning: daily entry count {entry_count} exceeds soft limit "
            f"{settings['daily_entries_soft_limit']}. Consider running run_daily_review.py."
        )
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/refine_memory.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
from collections import Counter, defaultdict
from dataclasses import dataclass
from datetime import date, datetime, timedelta
import json
import re
from pathlib import Path
from textwrap import dedent

from memory_os_common import (
    MANAGED_MEMORY_BLOCK_END,
    MANAGED_MEMORY_BLOCK_START,
    load_settings,
    load_state,
    save_state,
    sidecar_dir,
    upsert_marked_block,
)

ENTRY_RE = re.compile(
    r"^- \[(?P<date>\d{4}-\d{2}-\d{2})\|(?P<id>M-\d{8}-\d{3})\|"
    r"(?P<kind>experience|value|standard|preference)\|(?P<axis>[a-z]+)\|"
    r"(?P<status>active|candidate|stale|conflicted|superseded)\|"
    r"(?P<confidence>low|medium|high)\|(?P<topic>[^\]]+)\] (?P<statement>.+)$"
)
ENTRY_ID_RE = re.compile(r"^M-(?P<ymd>\d{8})-(?P<seq>\d{3})$")

KIND_ALIAS = {"preference": "value"}
KIND_ORDER = ("experience", "value", "standard")


@dataclass
class Entry:
    entry_date: date
    entry_id: str
    kind: str
    axis: str
    status: str
    confidence: str
    topic: str
    statement: str
    path: Path
    line_index: int

    def to_line(self) -> str:
        return (
            f"- [{self.entry_date.isoformat()}|{self.entry_id}|{self.kind}|"
            f"{self.axis}|{self.status}|{self.confidence}|{self.topic}] {self.statement}"
        )


def normalize_kind(kind: str) -> str:
    return KIND_ALIAS.get(kind, kind)


def parse_entry_id(entry_id: str) -> tuple[int, int]:
    match = ENTRY_ID_RE.match(entry_id)
    if not match:
        return (0, 0)
    return (int(match.group("ymd")), int(match.group("seq")))


def parse_entries(workspace: Path) -> tuple[list[Entry], dict[Path, list[str]]]:
    entries: list[Entry] = []
    file_lines: dict[Path, list[str]] = {}
    memory_dir = workspace / "memory"
    if not memory_dir.exists():
        return entries, file_lines

    for path in sorted(memory_dir.glob("20??-??-??.md")):
        lines = path.read_text(encoding="utf-8").splitlines()
        file_lines[path] = lines
        for idx, line in enumerate(lines):
            match = ENTRY_RE.match(line)
            if not match:
                continue
            payload = match.groupdict()
            entries.append(
                Entry(
                    entry_date=date.fromisoformat(payload["date"]),
                    entry_id=payload["id"],
                    kind=normalize_kind(payload["kind"]),
                    axis=payload["axis"],
                    status=payload["status"],
                    confidence=payload["confidence"],
                    topic=payload["topic"].strip().lower(),
                    statement=payload["statement"].strip(),
                    path=path,
                    line_index=idx,
                )
            )
    entries.sort(key=lambda item: (item.entry_date, parse_entry_id(item.entry_id)))
    return entries, file_lines


def apply_stale_status(entries: list[Entry], stale_days: int, file_lines: dict[Path, list[str]]) -> int:
    cutoff = date.today() - timedelta(days=stale_days)
    touched = 0
    for entry in entries:
        if entry.status != "active":
            continue
        if entry.entry_date >= cutoff:
            continue
        entry.status = "stale"
        file_lines[entry.path][entry.line_index] = entry.to_line()
        touched += 1
    return touched


def write_modified_files(file_lines: dict[Path, list[str]]) -> None:
    for path, lines in file_lines.items():
        path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")


def detect_contradictions(entries: list[Entry]) -> dict[tuple[str, str], list[Entry]]:
    grouped: dict[tuple[str, str], list[Entry]] = defaultdict(list)
    for entry in entries:
        if entry.status not in {"active", "candidate"}:
            continue
        grouped[(entry.kind, entry.topic)].append(entry)

    conflicts: dict[tuple[str, str], list[Entry]] = {}
    for key, group in grouped.items():
        kind, _topic = key
        axes = {item.axis for item in group}
        if kind == "value" and {"good", "bad"} <= axes:
            conflicts[key] = group
        if kind == "standard" and {"right", "wrong"} <= axes:
            conflicts[key] = group
    return conflicts


def cluster_entries(entries: list[Entry]) -> dict[str, dict[str, list[Entry]]]:
    clusters: dict[str, dict[str, list[Entry]]] = defaultdict(lambda: defaultdict(list))
    seen: set[tuple[str, str, str]] = set()
    for entry in entries:
        if entry.status not in {"active", "candidate"}:
            continue
        statement_key = " ".join(entry.statement.lower().split())
        dedupe_key = (entry.kind, entry.topic, statement_key)
        if dedupe_key in seen:
            continue
        seen.add(dedupe_key)
        clusters[entry.kind][entry.topic].append(entry)
    for by_topic in clusters.values():
        for topic in by_topic:
            by_topic[topic].sort(key=lambda item: (item.entry_date, parse_entry_id(item.entry_id)))
    return clusters


def format_axis_mix(group: list[Entry]) -> str:
    counts = Counter(item.axis for item in group)
    return ", ".join(f"{axis}({count})" for axis, count in sorted(counts.items()))


def build_l2_snapshot(
    clusters: dict[str, dict[str, list[Entry]]],
    conflicts: dict[tuple[str, str], list[Entry]],
    stale_entries: list[Entry],
) -> dict:
    by_kind: dict[str, dict] = {}
    for kind in KIND_ORDER:
        by_kind[kind] = {}
        for topic, group in sorted(clusters.get(kind, {}).items()):
            canonical = group[-1]
            by_kind[kind][topic] = {
                "evidence_count": len(group),
                "last_seen": canonical.entry_date.isoformat(),
                "axis_mix": format_axis_mix(group),
                "canonical_statement": canonical.statement,
                "supporting_ids": [item.entry_id for item in group[-8:]],
                "axes": sorted({item.axis for item in group}),
                "has_conflict": (kind, topic) in conflicts,
            }

    conflict_items = []
    for (kind, topic), group in sorted(conflicts.items()):
        conflict_items.append(
            {
                "kind": kind,
                "topic": topic,
                "axes": sorted({item.axis for item in group}),
                "ids": [item.entry_id for item in group],
            }
        )

    stale_items = [
        {
            "entry_id": entry.entry_id,
            "kind": entry.kind,
            "topic": entry.topic,
            "entry_date": entry.entry_date.isoformat(),
            "statement": entry.statement,
        }
        for entry in stale_entries[:100]
    ]

    return {
        "generated_at": datetime.now().isoformat(timespec="seconds"),
        "by_kind": by_kind,
        "conflicts": conflict_items,
        "stale_candidates": stale_items,
    }


def render_l2(kind: str, snapshot: dict) -> str:
    title = kind.capitalize()
    lines = [
        f"# {title} Memory (L2)",
        "",
        f"Updated: {date.today().isoformat()}",
        "",
    ]
    topics = snapshot.get("by_kind", {}).get(kind, {})
    if not topics:
        lines.extend(["- (no active entries)", ""])
        return "\n".join(lines)

    for topic in sorted(topics):
        row = topics[topic]
        lines.extend(
            [
                f"## {topic}",
                f"- Evidence count: {row['evidence_count']}",
                f"- Last seen: {row['last_seen']}",
                f"- Axis mix: {row['axis_mix']}",
                f"- Canonical: {row['canonical_statement']}",
                f"- Supporting IDs: {', '.join(row['supporting_ids']) if row['supporting_ids'] else '(none)'}",
                f"- Conflict: {'yes' if row['has_conflict'] else 'no'}",
                "",
            ]
        )
    return "\n".join(lines)


def write_l2_files(workspace: Path, snapshot: dict) -> list[Path]:
    l2_dir = sidecar_dir(workspace) / "l2"
    l2_dir.mkdir(parents=True, exist_ok=True)
    changed: list[Path] = []
    for kind in KIND_ORDER:
        path = l2_dir / f"{kind}.md"
        content = render_l2(kind, snapshot).rstrip() + "\n"
        path.write_text(content, encoding="utf-8")
        changed.append(path)

    summary_path = l2_dir / "summary.json"
    summary_path.write_text(json.dumps(snapshot, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
    changed.append(summary_path)
    return changed


def read_l2_snapshot(workspace: Path) -> dict | None:
    path = sidecar_dir(workspace) / "l2" / "summary.json"
    if not path.exists():
        return None
    return json.loads(path.read_text(encoding="utf-8"))


def render_l3_block_from_snapshot(snapshot: dict, settings: dict) -> str:
    max_l3_per_kind = settings["max_l3_per_kind"]
    min_evidence_for_l3 = settings["min_evidence_for_l3"]

    def section(kind: str, heading: str) -> list[str]:
        lines = [f"## {heading}"]
        topics = snapshot.get("by_kind", {}).get(kind, {})
        if not topics:
            lines.append("- (empty)")
            lines.append("")
            return lines

        ranked = sorted(
            topics.items(),
            key=lambda item: (item[1]["evidence_count"], item[1]["last_seen"]),
            reverse=True,
        )

        emitted = 0
        for topic, row in ranked:
            if row["evidence_count"] < min_evidence_for_l3:
                continue
            lines.append(
                f"- {topic}: {row['canonical_statement']} "
                f"(axes={','.join(row['axes'])}, evidence={row['evidence_count']}, last={row['last_seen']})"
            )
            emitted += 1
            if emitted >= max_l3_per_kind:
                break
        if emitted == 0:
            lines.append("- (empty)")
        lines.append("")
        return lines

    output = [MANAGED_MEMORY_BLOCK_START]
    output.extend(section("experience", "L3 Experience"))
    output.extend(section("value", "L3 Values"))
    output.extend(section("standard", "L3 Standards"))

    output.append("## Contradictions To Resolve")
    conflicts = snapshot.get("conflicts", [])
    if conflicts:
        for item in conflicts:
            output.append(
                f"- {item['kind']}/{item['topic']}: axes={','.join(item['axes'])}; ids={','.join(item['ids'])}"
            )
    else:
        output.append("- (none)")
    output.append("")

    output.append("## Stale Candidates")
    stale_candidates = snapshot.get("stale_candidates", [])
    if stale_candidates:
        for item in stale_candidates[:20]:
            output.append(
                f"- {item['entry_id']} | {item['kind']}/{item['topic']} | "
                f"{item['entry_date']} | {item['statement']}"
            )
    else:
        output.append("- (none)")
    output.append("")

    output.append("## Last Distillation")
    output.append(f"- Generated at {datetime.now().isoformat(timespec='seconds')}")
    output.append(MANAGED_MEMORY_BLOCK_END)
    output.append("")
    return "\n".join(output)


def write_memory_md(workspace: Path, l3_block: str) -> Path:
    memory_path = workspace / "MEMORY.md"
    if memory_path.exists():
        original = memory_path.read_text(encoding="utf-8")
    else:
        original = dedent(
            """
            # MEMORY.md (L3 Long-Term Memory)

            This file stores distilled long-term memory only.
            """
        ).strip() + "\n"
    original = original.replace("## L3 Preferences", "## L3 Values")
    updated = upsert_marked_block(original, MANAGED_MEMORY_BLOCK_START, MANAGED_MEMORY_BLOCK_END, l3_block)
    memory_path.write_text(updated, encoding="utf-8")
    return memory_path


def write_review_file(workspace: Path, conflicts: dict[tuple[str, str], list[Entry]], stale_entries: list[Entry], stale_days: int) -> Path:
    reviews_dir = sidecar_dir(workspace) / "reviews"
    reviews_dir.mkdir(parents=True, exist_ok=True)
    review_path = reviews_dir / f"{date.today().isoformat()}.md"

    lines = [
        f"# Memory Review {date.today().isoformat()}",
        "",
        f"- stale_days_threshold: {stale_days}",
        f"- contradictions: {len(conflicts)}",
        f"- stale_candidates: {len(stale_entries)}",
        "",
        "## Contradictions",
    ]
    if conflicts:
        for (kind, topic), group in sorted(conflicts.items()):
            ids = ", ".join(item.entry_id for item in group)
            axes = ", ".join(sorted({item.axis for item in group}))
            lines.append(f"- {kind}/{topic}: axes={axes}; ids={ids}")
    else:
        lines.append("- (none)")

    lines.extend(["", "## Stale Candidates"])
    if stale_entries:
        for entry in stale_entries[:50]:
            lines.append(
                f"- {entry.entry_id} | {entry.kind}/{entry.topic} | "
                f"{entry.entry_date.isoformat()} | {entry.statement}"
            )
    else:
        lines.append("- (none)")
    lines.append("")

    review_path.write_text("\n".join(lines), encoding="utf-8")
    return review_path


def last_cursor_key_from_state(state: dict) -> tuple[int, int]:
    cursor = state.get("last_processed_l1_entry_id")
    if not cursor:
        return (0, 0)
    return parse_entry_id(cursor)


def run_daily_review(
    workspace: Path,
    *,
    stale_days: int | None = None,
    entry_threshold: int | None = None,
    threshold_only: bool = False,
    apply_stale: bool = False,
) -> dict:
    settings = load_settings(workspace)
    state = load_state(workspace)
    stale_days_value = stale_days if stale_days is not None else settings["stale_days"]
    threshold_value = entry_threshold if entry_threshold is not None else settings["l1_entry_threshold"]

    entries, file_lines = parse_entries(workspace)
    cursor_key = last_cursor_key_from_state(state)
    new_entries = [entry for entry in entries if parse_entry_id(entry.entry_id) > cursor_key]

    if threshold_only and len(new_entries) < threshold_value:
        return {
            "workspace": str(workspace),
            "executed": False,
            "reason": "threshold_not_met",
            "new_entries": len(new_entries),
            "entry_threshold": threshold_value,
        }

    changed_stale = 0
    if apply_stale and entries:
        changed_stale = apply_stale_status(entries, stale_days_value, file_lines)
        if changed_stale:
            write_modified_files(file_lines)

    stale_cutoff = date.today() - timedelta(days=stale_days_value)
    stale_entries = [entry for entry in entries if entry.status == "active" and entry.entry_date < stale_cutoff]
    conflicts = detect_contradictions(entries)
    clusters = cluster_entries(entries)
    snapshot = build_l2_snapshot(clusters, conflicts, stale_entries)
    l2_paths = write_l2_files(workspace, snapshot)
    review_path = write_review_file(workspace, conflicts, stale_entries, stale_days_value)

    latest_id = entries[-1].entry_id if entries else state.get("last_processed_l1_entry_id")
    save_state(
        workspace,
        {
            "last_daily_review_at": datetime.now().isoformat(timespec="seconds"),
            "last_processed_l1_entry_id": latest_id,
        },
    )

    return {
        "workspace": str(workspace),
        "executed": True,
        "new_entries": len(new_entries),
        "entry_threshold": threshold_value,
        "threshold_triggered": len(new_entries) >= threshold_value,
        "stale_statuses_changed": changed_stale,
        "l2_files": [str(path) for path in l2_paths],
        "review_file": str(review_path),
    }


def run_weekly_rollup(workspace: Path) -> dict:
    settings = load_settings(workspace)
    snapshot = read_l2_snapshot(workspace)
    if snapshot is None:
        run_daily_review(workspace, apply_stale=False, threshold_only=False)
        snapshot = read_l2_snapshot(workspace)
    if snapshot is None:
        raise SystemExit("Unable to build L2 snapshot for weekly rollup.")

    l3_block = render_l3_block_from_snapshot(snapshot, settings)
    memory_path = write_memory_md(workspace, l3_block)
    save_state(workspace, {"last_weekly_rollup_at": datetime.now().isoformat(timespec="seconds")})

    return {
        "workspace": str(workspace),
        "memory_file": str(memory_path),
        "topics_experience": len(snapshot.get("by_kind", {}).get("experience", {})),
        "topics_value": len(snapshot.get("by_kind", {}).get("value", {})),
        "topics_standard": len(snapshot.get("by_kind", {}).get("standard", {})),
        "conflicts": len(snapshot.get("conflicts", [])),
    }


def run_monthly_review(workspace: Path) -> dict:
    settings = load_settings(workspace)
    entries, _file_lines = parse_entries(workspace)
    conflicts = detect_contradictions(entries)

    by_topic: dict[tuple[str, str], list[Entry]] = defaultdict(list)
    by_statement: dict[tuple[str, str, str], list[Entry]] = defaultdict(list)
    for entry in entries:
        by_topic[(entry.kind, entry.topic)].append(entry)
        statement_key = " ".join(entry.statement.lower().split())
        by_statement[(entry.kind, entry.topic, statement_key)].append(entry)

    bloat_topics = [
        (kind, topic, len(group))
        for (kind, topic), group in by_topic.items()
        if len(group) > settings["max_l3_per_kind"]
    ]
    redundant_groups = [
        (kind, topic, statement, len(group))
        for (kind, topic, statement), group in by_statement.items()
        if len(group) > 1
    ]
    stale_cutoff = date.today() - timedelta(days=settings["stale_days"])
    stale_or_superseded = [
        entry
        for entry in entries
        if entry.status in {"stale", "superseded"} or (entry.status == "active" and entry.entry_date < stale_cutoff)
    ]

    reviews_dir = sidecar_dir(workspace) / "reviews"
    reviews_dir.mkdir(parents=True, exist_ok=True)
    month_slug = date.today().strftime("%Y-%m")
    report_path = reviews_dir / f"monthly-{month_slug}.md"
    plan_path = reviews_dir / f"monthly-{month_slug}-plan.json"

    id_to_entry = {entry.entry_id: entry for entry in entries}
    plan_actions: list[dict] = []

    for (kind, topic), group in sorted(conflicts.items()):
        target_ids = [entry.entry_id for entry in group if entry.status in {"active", "candidate"}]
        if not target_ids:
            continue
        plan_actions.append(
            {
                "id": f"conflict-{kind}-{topic}",
                "enabled": False,
                "type": "mark_conflicted",
                "reason": f"conflict axes in {kind}/{topic}",
                "kind": kind,
                "topic": topic,
                "target_entry_ids": target_ids,
            }
        )

    for kind, topic, statement, _count in sorted(redundant_groups, key=lambda item: item[3], reverse=True):
        dup_group = [
            entry
            for entry in entries
            if entry.kind == kind
            and entry.topic == topic
            and " ".join(entry.statement.lower().split()) == statement
        ]
        if len(dup_group) < 2:
            continue
        dup_group.sort(key=lambda item: (item.entry_date, parse_entry_id(item.entry_id)))
        keep = dup_group[-1]
        target_ids = [entry.entry_id for entry in dup_group[:-1] if entry.status in {"active", "candidate"}]
        if not target_ids:
            continue
        plan_actions.append(
            {
                "id": f"dedupe-{kind}-{topic}-{keep.entry_id}",
                "enabled": False,
                "type": "mark_superseded",
                "reason": f"redundant statements under {kind}/{topic}",
                "kind": kind,
                "topic": topic,
                "keep_entry_id": keep.entry_id,
                "target_entry_ids": target_ids,
            }
        )

    for entry in entries:
        if entry.entry_id not in id_to_entry:
            continue
        if entry.status != "active":
            continue
        if entry.entry_date >= stale_cutoff:
            continue
        plan_actions.append(
            {
                "id": f"stale-{entry.entry_id}",
                "enabled": False,
                "type": "mark_stale",
                "reason": f"older than stale_days={settings['stale_days']}",
                "kind": entry.kind,
                "topic": entry.topic,
                "target_entry_ids": [entry.entry_id],
            }
        )

    lines = [
        f"# Monthly Memory Review {date.today().strftime('%Y-%m')}",
        "",
        "This report is advisory only. No automatic cleanup has been applied.",
        "",
        f"- generated_at: {datetime.now().isoformat(timespec='seconds')}",
        f"- total_entries: {len(entries)}",
        f"- conflicts: {len(conflicts)}",
        f"- bloat_topics: {len(bloat_topics)}",
        f"- redundant_statement_groups: {len(redundant_groups)}",
        f"- stale_or_superseded_candidates: {len(stale_or_superseded)}",
        f"- cleanup_plan_actions: {len(plan_actions)}",
        "",
        "## Conflict Groups",
    ]
    if conflicts:
        for (kind, topic), group in sorted(conflicts.items()):
            ids = ", ".join(item.entry_id for item in group)
            axes = ", ".join(sorted({item.axis for item in group}))
            lines.append(f"- {kind}/{topic}: axes={axes}; ids={ids}")
    else:
        lines.append("- (none)")

    lines.extend(["", "## Bloat Topic Candidates"])
    if bloat_topics:
        for kind, topic, count in sorted(bloat_topics, key=lambda item: item[2], reverse=True)[:30]:
            lines.append(f"- {kind}/{topic}: evidence_count={count} (suggest topic-level merge)")
    else:
        lines.append("- (none)")

    lines.extend(["", "## Redundant Statement Groups"])
    if redundant_groups:
        for kind, topic, statement, count in sorted(redundant_groups, key=lambda item: item[3], reverse=True)[:30]:
            lines.append(f"- {kind}/{topic}: repeated={count} :: {statement}")
    else:
        lines.append("- (none)")

    lines.extend(["", "## Stale or Superseded Candidates"])
    if stale_or_superseded:
        for entry in stale_or_superseded[:60]:
            lines.append(
                f"- {entry.entry_id} | {entry.kind}/{entry.topic} | {entry.status} | "
                f"{entry.entry_date.isoformat()} | {entry.statement}"
            )
    else:
        lines.append("- (none)")

    lines.extend(
        [
            "",
            "## Suggested Cleanup Conversation",
            "1. Confirm whether each conflict should resolve to one axis or remain contextual.",
            "2. Merge or supersede repeated statements under the same topic.",
            "3. Archive stale entries that are no longer useful.",
            "4. Re-run weekly rollup after manual decisions.",
            "",
            "## Cleanup Plan",
            f"- Plan file: `{plan_path}`",
            "- All actions are disabled by default.",
            "- Enable selected actions in plan JSON, then apply with:",
            "  `python3 skills/better-memory/scripts/apply_monthly_cleanup.py --workspace . --rerollup`",
            "",
        ]
    )

    report_path.write_text("\n".join(lines), encoding="utf-8")
    plan_payload = {
        "generated_at": datetime.now().isoformat(timespec="seconds"),
        "month": month_slug,
        "workspace": str(workspace),
        "report_file": str(report_path),
        "actions": plan_actions,
    }
    plan_path.write_text(json.dumps(plan_payload, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
    save_state(workspace, {"last_monthly_review_at": datetime.now().isoformat(timespec="seconds")})

    return {
        "workspace": str(workspace),
        "report_file": str(report_path),
        "plan_file": str(plan_path),
        "conflicts": len(conflicts),
        "bloat_topics": len(bloat_topics),
        "redundant_groups": len(redundant_groups),
        "stale_or_superseded": len(stale_or_superseded),
        "plan_actions": len(plan_actions),
    }


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Compatibility wrapper: run daily review and weekly rollup (L1 -> L2 -> L3)."
    )
    parser.add_argument("--workspace", default=".", help="Workspace path (default: current directory)")
    parser.add_argument("--stale-days", type=int, help="Days after which active memory becomes stale candidate")
    parser.add_argument("--apply-stale", action="store_true", help="Update old active entries to stale status in L1 files")
    parser.add_argument("--entry-threshold", type=int, help="Threshold for new L1 entries")
    parser.add_argument("--threshold-only", action="store_true", help="Skip daily review when new entries are below threshold")
    args = parser.parse_args()

    workspace = Path(args.workspace).expanduser().resolve()
    daily = run_daily_review(
        workspace,
        stale_days=args.stale_days,
        entry_threshold=args.entry_threshold,
        threshold_only=args.threshold_only,
        apply_stale=args.apply_stale,
    )
    if not daily["executed"]:
        print(f"Workspace: {workspace}")
        print("Daily review skipped.")
        print(f"Reason: {daily['reason']}")
        print(f"New entries: {daily['new_entries']}")
        print(f"Entry threshold: {daily['entry_threshold']}")
        return 0

    weekly = run_weekly_rollup(workspace)
    print(f"Workspace: {workspace}")
    print(f"Daily review executed: {daily['executed']}")
    print(f"New entries: {daily['new_entries']}")
    print(f"Threshold triggered: {daily['threshold_triggered']}")
    print(f"Stale statuses changed: {daily['stale_statuses_changed']}")
    print(f"Daily review file: {daily['review_file']}")
    print(f"Weekly rollup memory file: {weekly['memory_file']}")
    print(
        "Weekly topics: "
        f"experience={weekly['topics_experience']}, value={weekly['topics_value']}, standard={weekly['topics_standard']}"
    )
    print(f"Conflicts: {weekly['conflicts']}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/apply_monthly_cleanup.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
from datetime import datetime
import json
from pathlib import Path

from refine_memory import parse_entries, run_daily_review, run_weekly_rollup, write_modified_files


def latest_plan_path(workspace: Path) -> Path | None:
    reviews_dir = workspace / ".openclaw-memory-os" / "reviews"
    if not reviews_dir.exists():
        return None
    plans = sorted(reviews_dir.glob("monthly-*-plan.json"))
    return plans[-1] if plans else None


def apply_actions(workspace: Path, plan: dict, dry_run: bool) -> tuple[int, int, int, list[dict], list[str]]:
    entries, file_lines = parse_entries(workspace)
    entry_by_id = {entry.entry_id: entry for entry in entries}
    enabled_actions = [action for action in plan.get("actions", []) if action.get("enabled")]
    enabled_action_ids = [str(action.get("id", "(no-id)")) for action in enabled_actions]
    changes: list[dict] = []

    touched = 0
    skipped = 0
    applied = 0
    for action in enabled_actions:
        action_id = str(action.get("id", "(no-id)"))
        action_type = action.get("type")
        target_ids = action.get("target_entry_ids", [])
        local_changes = 0

        for entry_id in target_ids:
            entry = entry_by_id.get(entry_id)
            if entry is None:
                skipped += 1
                continue

            desired_status = None
            if action_type == "mark_conflicted":
                if entry.status in {"active", "candidate"}:
                    desired_status = "conflicted"
            elif action_type == "mark_superseded":
                if entry.status in {"active", "candidate"}:
                    desired_status = "superseded"
            elif action_type == "mark_stale":
                if entry.status == "active":
                    desired_status = "stale"
            else:
                skipped += 1
                continue

            if desired_status is None or entry.status == desired_status:
                skipped += 1
                continue

            old_status = entry.status
            entry.status = desired_status
            file_lines[entry.path][entry.line_index] = entry.to_line()
            changes.append(
                {
                    "action_id": action_id,
                    "action_type": action_type,
                    "entry_id": entry.entry_id,
                    "kind": entry.kind,
                    "topic": entry.topic,
                    "old_status": old_status,
                    "new_status": desired_status,
                    "file": str(entry.path),
                    "statement": entry.statement,
                }
            )
            local_changes += 1
            touched += 1

        if local_changes > 0:
            applied += 1

    if touched > 0 and not dry_run:
        write_modified_files(file_lines)

    return touched, applied, skipped, changes, enabled_action_ids


def write_change_summary(
    workspace: Path,
    *,
    plan_path: Path,
    dry_run: bool,
    applied: int,
    touched: int,
    skipped: int,
    enabled_action_ids: list[str],
    changes: list[dict],
    rerollup_msg: str,
    summary_limit: int,
) -> Path:
    reviews_dir = workspace / ".openclaw-memory-os" / "reviews"
    reviews_dir.mkdir(parents=True, exist_ok=True)
    ts = datetime.now().strftime("%Y%m%d-%H%M%S")
    summary_path = reviews_dir / f"apply-summary-{ts}.md"

    action_counts: dict[str, int] = {}
    for item in changes:
        action_id = item["action_id"]
        action_counts[action_id] = action_counts.get(action_id, 0) + 1

    lines = [
        f"# Monthly Cleanup Apply Summary {datetime.now().isoformat(timespec='seconds')}",
        "",
        f"- workspace: {workspace}",
        f"- plan: {plan_path}",
        f"- dry_run: {dry_run}",
        f"- enabled_actions: {len(enabled_action_ids)}",
        f"- actions_applied: {applied}",
        f"- entries_updated: {touched}",
        f"- skipped_targets: {skipped}",
        f"- rerollup: {rerollup_msg}",
        "",
        "## Enabled Actions",
    ]
    if enabled_action_ids:
        for action_id in enabled_action_ids:
            lines.append(f"- {action_id}")
    else:
        lines.append("- (none)")

    lines.extend(["", "## Action Impact"])
    if action_counts:
        for action_id in sorted(action_counts):
            lines.append(f"- {action_id}: {action_counts[action_id]} entries")
    else:
        lines.append("- (none)")

    lines.extend(["", f"## Entry Changes (showing up to {summary_limit})"])
    if changes:
        for item in changes[:summary_limit]:
            lines.append(
                f"- {item['entry_id']} | {item['kind']}/{item['topic']} | "
                f"{item['old_status']} -> {item['new_status']} | {item['action_id']}"
            )
        extra = len(changes) - min(len(changes), summary_limit)
        if extra > 0:
            lines.append(f"- ... {extra} more changes omitted")
    else:
        lines.append("- (none)")

    lines.append("")
    summary_path.write_text("\n".join(lines), encoding="utf-8")
    return summary_path


def main() -> int:
    parser = argparse.ArgumentParser(description="Apply enabled actions from monthly cleanup plan.")
    parser.add_argument("--workspace", default=".", help="Workspace path (default: current directory)")
    parser.add_argument("--plan", help="Path to monthly plan JSON (default: latest monthly-*-plan.json)")
    parser.add_argument("--dry-run", action="store_true", help="Do not write files; only report pending changes")
    parser.add_argument("--rerollup", action="store_true", help="Run weekly rollup after successful apply")
    parser.add_argument("--summary-limit", type=int, default=20, help="Maximum number of entry changes in summary detail")
    args = parser.parse_args()

    workspace = Path(args.workspace).expanduser().resolve()
    plan_path = Path(args.plan).expanduser().resolve() if args.plan else latest_plan_path(workspace)
    if plan_path is None or not plan_path.exists():
        raise SystemExit("Monthly cleanup plan not found. Run run_monthly_review.py first.")

    plan = json.loads(plan_path.read_text(encoding="utf-8"))
    touched, applied, skipped, changes, enabled_action_ids = apply_actions(workspace, plan, dry_run=args.dry_run)

    if args.rerollup and not args.dry_run and touched > 0:
        run_daily_review(workspace, threshold_only=False, apply_stale=False)
        rollup = run_weekly_rollup(workspace)
        rerollup_msg = f"Daily review + weekly rollup updated: {rollup['memory_file']}"
    else:
        rerollup_msg = "Weekly rollup not run."

    summary_path = write_change_summary(
        workspace,
        plan_path=plan_path,
        dry_run=args.dry_run,
        applied=applied,
        touched=touched,
        skipped=skipped,
        enabled_action_ids=enabled_action_ids,
        changes=changes,
        rerollup_msg=rerollup_msg,
        summary_limit=max(1, args.summary_limit),
    )

    print(f"Workspace: {workspace}")
    print(f"Plan: {plan_path}")
    print(f"Dry run: {args.dry_run}")
    print(f"Enabled actions applied: {applied}")
    print(f"Entries updated: {touched}")
    print(f"Skipped targets: {skipped}")
    print(rerollup_msg)
    print(f"Summary file: {summary_path}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/smart_install.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
from pathlib import Path

from bootstrap_memory import bootstrap
from emit_cron_template import emit_cron_template
from memory_os_common import DEFAULT_SETTINGS
from promote_legacy_memory import latest_migration_review, prepare_plan


def main() -> int:
    parser = argparse.ArgumentParser(
        description="One-shot smart install for Better Memory V2 (bootstrap + migration plan + cron template)."
    )
    parser.add_argument("--workspace", default=".", help="Workspace path (default: current directory)")
    parser.add_argument("--stale-days", type=int, default=DEFAULT_SETTINGS["stale_days"])
    parser.add_argument("--max-l3-per-kind", type=int, default=DEFAULT_SETTINGS["max_l3_per_kind"])
    parser.add_argument("--min-evidence-for-l3", type=int, default=DEFAULT_SETTINGS["min_evidence_for_l3"])
    parser.add_argument("--migration-days", type=int, default=DEFAULT_SETTINGS["migration_days"])
    parser.add_argument("--daily-entries-soft-limit", type=int, default=DEFAULT_SETTINGS["daily_entries_soft_limit"])
    parser.add_argument("--daily-time", default=DEFAULT_SETTINGS["daily_review_time"])
    parser.add_argument("--weekly-day", default=DEFAULT_SETTINGS["weekly_rollup_day"])
    parser.add_argument("--weekly-time", default=DEFAULT_SETTINGS["weekly_rollup_time"])
    parser.add_argument("--monthly-day", type=int, default=DEFAULT_SETTINGS["monthly_review_day"])
    parser.add_argument("--monthly-time", default=DEFAULT_SETTINGS["monthly_review_time"])
    parser.add_argument("--entry-threshold", type=int, default=DEFAULT_SETTINGS["l1_entry_threshold"])
    parser.add_argument("--force-migration-plan", action="store_true", help="Overwrite existing migration-plan.json")
    args = parser.parse_args()

    workspace = Path(args.workspace).expanduser().resolve()
    settings = {
        "stale_days": args.stale_days,
        "max_l3_per_kind": args.max_l3_per_kind,
        "min_evidence_for_l3": args.min_evidence_for_l3,
        "migration_days": args.migration_days,
        "daily_entries_soft_limit": args.daily_entries_soft_limit,
        "daily_review_time": args.daily_time.strip(),
        "weekly_rollup_day": args.weekly_day.strip().upper(),
        "weekly_rollup_time": args.weekly_time.strip(),
        "monthly_review_day": args.monthly_day,
        "monthly_review_time": args.monthly_time.strip(),
        "l1_entry_threshold": args.entry_threshold,
    }

    changed = bootstrap(workspace, settings)
    cron_path = emit_cron_template(workspace)

    migration_plan_path = None
    migration_plan_note = None
    review_path = latest_migration_review(workspace)
    if review_path is not None and review_path.exists():
        try:
            migration_plan_path = prepare_plan(workspace, review_path, force=args.force_migration_plan)
        except SystemExit as exc:
            migration_plan_note = str(exc)

    print(f"Workspace: {workspace}")
    print("Smart install completed.")
    print("Changed/created paths:")
    for path in changed:
        print(f"- {path}")
    print(f"Cron template: {cron_path}")
    if migration_plan_path is not None:
        print(f"Migration plan: {migration_plan_path}")
    elif migration_plan_note:
        print(f"Migration plan note: {migration_plan_note}")
    else:
        print("Migration plan: skipped (no migration review found)")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/run_daily_review.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
from pathlib import Path

from refine_memory import run_daily_review


def main() -> int:
    parser = argparse.ArgumentParser(description="Run daily L1 -> L2 memory review.")
    parser.add_argument("--workspace", default=".", help="Workspace path (default: current directory)")
    parser.add_argument("--stale-days", type=int, help="Override stale_days")
    parser.add_argument("--entry-threshold", type=int, help="Override l1_entry_threshold")
    parser.add_argument("--threshold-only", action="store_true", help="Skip if new L1 entries are below threshold")
    parser.add_argument("--apply-stale", action="store_true", help="Mark old active entries as stale in L1")
    args = parser.parse_args()

    workspace = Path(args.workspace).expanduser().resolve()
    result = run_daily_review(
        workspace,
        stale_days=args.stale_days,
        entry_threshold=args.entry_threshold,
        threshold_only=args.threshold_only,
        apply_stale=args.apply_stale,
    )

    print(f"Workspace: {workspace}")
    if not result["executed"]:
        print("Daily review skipped.")
        print(f"Reason: {result['reason']}")
        print(f"New entries: {result['new_entries']}")
        print(f"Entry threshold: {result['entry_threshold']}")
        return 0

    print(f"Daily review executed: {result['executed']}")
    print(f"New entries: {result['new_entries']}")
    print(f"Threshold triggered: {result['threshold_triggered']}")
    print(f"Stale statuses changed: {result['stale_statuses_changed']}")
    print("L2 files:")
    for path in result["l2_files"]:
        print(f"- {path}")
    print(f"Review file: {result['review_file']}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/install_heartbeat_memory_os.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
from pathlib import Path
from textwrap import dedent

from memory_os_common import (
    MANAGED_HEARTBEAT_BLOCK_END,
    MANAGED_HEARTBEAT_BLOCK_START,
    load_settings,
    upsert_marked_block,
)


def render_block(settings: dict) -> str:
    return dedent(
        f"""
        {MANAGED_HEARTBEAT_BLOCK_START}
        ## Better Memory V2

        If there are 3 or more new structured L1 entries since the last daily review, or daily review has not run in 24h:
        1. Run `python3 skills/better-memory/scripts/refine_memory.py --workspace .`
        2. If needed, run `python3 skills/better-memory/scripts/run_weekly_rollup.py --workspace .`.
        3. Check `.openclaw-memory-os/reviews/` for contradiction or stale candidates.
        4. If there is nothing actionable, reply `HEARTBEAT_OK`.

        Monthly hygiene:
        - At least once per month run:
          `python3 skills/better-memory/scripts/run_monthly_review.py --workspace .`
        - Review the generated report with the user before any cleanup edits.
        - If approved, enable actions in monthly plan JSON, then run:
          `python3 skills/better-memory/scripts/apply_monthly_cleanup.py --workspace . --rerollup`

        During heartbeat review, inspect recent raw daily notes for durable signals:
        - experience (how to think/say/do)
        - value (what is good/bad)
        - standard (what is right/wrong)
        If a signal is not represented yet, capture first, then run daily review.

        Respect current settings:
        - `daily_review_time={settings["daily_review_time"]}`
        - `weekly_rollup_day={settings["weekly_rollup_day"]}`
        - `weekly_rollup_time={settings["weekly_rollup_time"]}`
        - `monthly_review_day={settings["monthly_review_day"]}`
        - `monthly_review_time={settings["monthly_review_time"]}`
        - `l1_entry_threshold={settings["l1_entry_threshold"]}`
        - `stale_days={settings["stale_days"]}`

        This heartbeat block is advisory. Do not auto-clean memory on monthly review.
        Do not rewrite non-managed sections of `HEARTBEAT.md`.
        {MANAGED_HEARTBEAT_BLOCK_END}
        """
    ).strip()

def main() -> int:
    parser = argparse.ArgumentParser(description="Install an additive Memory OS block into HEARTBEAT.md.")
    parser.add_argument("--workspace", default=".", help="Workspace path (default: current directory)")
    args = parser.parse_args()

    workspace = Path(args.workspace).expanduser().resolve()
    settings = load_settings(workspace)
    heartbeat_path = workspace / "HEARTBEAT.md"
    original = heartbeat_path.read_text(encoding="utf-8") if heartbeat_path.exists() else "# HEARTBEAT.md\n\n"
    updated = upsert_marked_block(
        original,
        MANAGED_HEARTBEAT_BLOCK_START,
        MANAGED_HEARTBEAT_BLOCK_END,
        render_block(settings),
    )
    heartbeat_path.write_text(updated, encoding="utf-8")

    print(f"Workspace: {workspace}")
    print(f"Updated heartbeat integration: {heartbeat_path}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/uninstall_memory_os.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
import shutil
from pathlib import Path

from memory_os_common import (
    MANAGED_AGENT_BLOCK_END,
    MANAGED_AGENT_BLOCK_START,
    MANAGED_HEARTBEAT_BLOCK_END,
    MANAGED_HEARTBEAT_BLOCK_START,
    MANAGED_MEMORY_BLOCK_END,
    MANAGED_MEMORY_BLOCK_START,
    remove_marked_block,
    sidecar_dir,
)


def strip_block(path: Path, start: str, end: str) -> bool:
    if not path.exists():
        return False
    original = path.read_text(encoding="utf-8")
    updated = remove_marked_block(original, start, end)
    if updated == original:
        return False
    if updated:
        path.write_text(updated, encoding="utf-8")
    else:
        path.unlink()
    return True


def main() -> int:
    parser = argparse.ArgumentParser(description="Uninstall Better Memory additive wiring.")
    parser.add_argument("--workspace", default=".", help="Workspace path (default: current directory)")
    parser.add_argument("--keep-sidecar", action="store_true", help="Keep .openclaw-memory-os artifacts")
    args = parser.parse_args()

    workspace = Path(args.workspace).expanduser().resolve()
    changed: list[str] = []

    if strip_block(workspace / "AGENTS.md", MANAGED_AGENT_BLOCK_START, MANAGED_AGENT_BLOCK_END):
        changed.append(str(workspace / "AGENTS.md"))
    if strip_block(workspace / "MEMORY.md", MANAGED_MEMORY_BLOCK_START, MANAGED_MEMORY_BLOCK_END):
        changed.append(str(workspace / "MEMORY.md"))
    if strip_block(workspace / "HEARTBEAT.md", MANAGED_HEARTBEAT_BLOCK_START, MANAGED_HEARTBEAT_BLOCK_END):
        changed.append(str(workspace / "HEARTBEAT.md"))

    sidecar = sidecar_dir(workspace)
    if sidecar.exists() and not args.keep_sidecar:
        shutil.rmtree(sidecar)
        changed.append(str(sidecar))

    print(f"Workspace: {workspace}")
    if changed:
        print("Removed Memory OS managed integration from:")
        for path in changed:
            print(f"- {path}")
    else:
        print("No managed integration found.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/promote_legacy_memory.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import re
from datetime import date, datetime
from pathlib import Path

from capture_memory import AXIS_BY_KIND, ensure_daily_file, next_entry_id, sanitize
from memory_os_common import sidecar_dir

PLAN_FILENAME = "migration-plan.json"
CANDIDATE_RE = re.compile(r"^- (?P<source>[^:]+ :: )(?P<statement>.+)$")
KIND_ALIAS = {"preference": "value"}


def latest_migration_review(workspace: Path) -> Path | None:
    review_dir = sidecar_dir(workspace) / "reviews"
    reviews = sorted(review_dir.glob("migration-*.md"))
    return reviews[-1] if reviews else None


def plan_path(workspace: Path) -> Path:
    return sidecar_dir(workspace) / PLAN_FILENAME


def extract_candidates(review_path: Path) -> list[dict]:
    candidates: list[dict] = []
    for raw in review_path.read_text(encoding="utf-8").splitlines():
        match = CANDIDATE_RE.match(raw.strip())
        if not match:
            continue
        source = match.group("source")[:-4]
        statement = match.group("statement").strip()
        statement = statement.lstrip("- ").strip()
        candidates.append(
            {
                "source": source,
                "statement": statement,
                "enabled": False,
                "kind": "",
                "axis": "",
                "topic": "",
                "status": "candidate",
                "confidence": "medium",
                "imported_at": None,
                "entry_id": None,
            }
        )
    return candidates


def prepare_plan(workspace: Path, review_path: Path, force: bool) -> Path:
    target = plan_path(workspace)
    if target.exists() and not force:
        raise SystemExit(f"Plan already exists: {target}. Use --force to overwrite.")

    plan = {
        "generated_at": datetime.now().isoformat(timespec="seconds"),
        "review_path": str(review_path),
        "items": extract_candidates(review_path),
    }
    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_text(json.dumps(plan, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
    return target


def validate_item(item: dict, index: int) -> None:
    if not item.get("enabled"):
        return
    kind = KIND_ALIAS.get(item.get("kind", ""), item.get("kind", ""))
    axis = item.get("axis", "")
    if kind not in AXIS_BY_KIND:
        raise SystemExit(f"Plan item {index} has invalid kind: {kind!r}")
    if axis not in AXIS_BY_KIND[kind]:
        raise SystemExit(f"Plan item {index} has invalid axis {axis!r} for kind {kind!r}")
    if not item.get("topic", "").strip():
        raise SystemExit(f"Plan item {index} is enabled but missing topic")
    if not item.get("statement", "").strip():
        raise SystemExit(f"Plan item {index} is enabled but missing statement")


def append_promotions(workspace: Path, plan: dict, dry_run: bool) -> tuple[int, Path]:
    today = date.today().isoformat()
    daily_path = workspace / "memory" / f"{today}.md"
    ensure_daily_file(daily_path, today)
    promoted = 0

    for index, item in enumerate(plan.get("items", []), start=1):
        validate_item(item, index)
        if not item.get("enabled") or item.get("imported_at"):
            continue

        kind = KIND_ALIAS.get(item["kind"], item["kind"])
        entry_id = next_entry_id(daily_path, today)
        topic = sanitize(item["topic"].strip().lower())
        statement = sanitize(item["statement"].strip())
        line = (
            f"- [{today}|{entry_id}|{kind}|{item['axis']}|"
            f"{item.get('status', 'candidate')}|{item.get('confidence', 'medium')}|{topic}] {statement}"
        )
        if not dry_run:
            with daily_path.open("a", encoding="utf-8") as handle:
                if daily_path.read_text(encoding="utf-8").rstrip().endswith("## L1 Entries"):
                    handle.write("\n")
                handle.write(line + "\n")
            item["kind"] = kind
            item["imported_at"] = datetime.now().isoformat(timespec="seconds")
            item["entry_id"] = entry_id
        promoted += 1
    return promoted, daily_path


def main() -> int:
    parser = argparse.ArgumentParser(description="Prepare or apply a legacy-memory promotion plan.")
    parser.add_argument("--workspace", default=".", help="Workspace path (default: current directory)")
    parser.add_argument("--prepare", action="store_true", help="Generate a migration plan JSON from the latest migration review")
    parser.add_argument("--apply", action="store_true", help="Apply enabled items from the migration plan into today's L1 file")
    parser.add_argument("--review", help="Optional path to a migration review file")
    parser.add_argument("--force", action="store_true", help="Overwrite an existing plan when used with --prepare")
    parser.add_argument("--dry-run", action="store_true", help="Validate and count promotions without writing")
    args = parser.parse_args()

    if args.prepare == args.apply:
        raise SystemExit("Use exactly one of --prepare or --apply.")

    workspace = Path(args.workspace).expanduser().resolve()
    if args.prepare:
        review_path = Path(args.review).expanduser().resolve() if args.review else latest_migration_review(workspace)
        if review_path is None or not review_path.exists():
            raise SystemExit("No migration review found.")
        target = prepare_plan(workspace, review_path, args.force)
        print(f"Prepared migration plan: {target}")
        return 0

    target = plan_path(workspace)
    if not target.exists():
        raise SystemExit(f"Migration plan not found: {target}. Run with --prepare first.")
    plan = json.loads(target.read_text(encoding="utf-8"))
    promoted, daily_path = append_promotions(workspace, plan, args.dry_run)
    if not args.dry_run:
        target.write_text(json.dumps(plan, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
    print(f"Promotions {'validated' if args.dry_run else 'written'}: {promoted}")
    print(f"Daily file: {daily_path}")
    print(f"Plan: {target}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/bootstrap_memory.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
from datetime import date, datetime, timedelta
from pathlib import Path
import re
from textwrap import dedent

from memory_os_common import (
    DEFAULT_SETTINGS,
    MANAGED_AGENT_BLOCK_END,
    MANAGED_AGENT_BLOCK_START,
    MANAGED_MEMORY_BLOCK_END,
    MANAGED_MEMORY_BLOCK_START,
    save_settings,
    save_state,
    sidecar_dir,
    upsert_marked_block,
)

WEEKDAYS = {"MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"}


def _is_valid_time(value: str) -> bool:
    match = re.fullmatch(r"(\d{2}):(\d{2})", value.strip())
    if not match:
        return False
    hour = int(match.group(1))
    minute = int(match.group(2))
    return 0 <= hour <= 23 and 0 <= minute <= 59


def _validate_settings(settings: dict) -> None:
    if not _is_valid_time(settings["daily_review_time"]):
        raise SystemExit("Invalid --daily-review-time. Expected HH:MM in 24h format.")
    if settings["weekly_rollup_day"] not in WEEKDAYS:
        raise SystemExit(f"Invalid --weekly-rollup-day. Use one of: {', '.join(sorted(WEEKDAYS))}")
    if not _is_valid_time(settings["weekly_rollup_time"]):
        raise SystemExit("Invalid --weekly-rollup-time. Expected HH:MM in 24h format.")
    if not 1 <= int(settings["monthly_review_day"]) <= 31:
        raise SystemExit("Invalid --monthly-review-day. Expected 1-31.")
    if not _is_valid_time(settings["monthly_review_time"]):
        raise SystemExit("Invalid --monthly-review-time. Expected HH:MM in 24h format.")
    if int(settings["l1_entry_threshold"]) <= 0:
        raise SystemExit("Invalid --l1-entry-threshold. Must be > 0.")


def render_agent_block(settings: dict) -> str:
    return dedent(
        f"""
        {MANAGED_AGENT_BLOCK_START}
        ## Better Memory V2

        This block is additive. Keep all workspace rules outside this block unchanged.

        Native memory source of truth:
        - `memory/YYYY-MM-DD.md` for raw daily memory (L1)
        - `MEMORY.md` for high-level weekly rollup memory (L3)

        Sidecar layer:
        - `.openclaw-memory-os/l2/*.md` for daily structured summaries (L2)
        - `.openclaw-memory-os/reviews/*.md` for monthly review artifacts
        - Sidecar files stay outside `memory/` to keep native `memory_search` clean.

        Typed memory taxonomy:
        - `experience`: `think`, `say`, `do`
        - `value`: `good`, `bad`
        - `standard`: `right`, `wrong`

        Capture command:
        `python3 skills/better-memory/scripts/capture_memory.py --workspace . --kind <experience|value|standard> --axis <axis> --topic "<topic>" --statement "<statement>"`

        Scheduled pipeline commands:
        - Daily L1->L2:
          `python3 skills/better-memory/scripts/run_daily_review.py --workspace .`
        - Weekly L2->L3:
          `python3 skills/better-memory/scripts/run_weekly_rollup.py --workspace .`
        - Monthly review report:
          `python3 skills/better-memory/scripts/run_monthly_review.py --workspace .`
        - Apply confirmed monthly cleanup actions:
          `python3 skills/better-memory/scripts/apply_monthly_cleanup.py --workspace . --rerollup`

        Settings:
        - `daily_review_time={settings["daily_review_time"]}`
        - `weekly_rollup_day={settings["weekly_rollup_day"]}`
        - `weekly_rollup_time={settings["weekly_rollup_time"]}`
        - `monthly_review_day={settings["monthly_review_day"]}`
        - `monthly_review_time={settings["monthly_review_time"]}`
        - `l1_entry_threshold={settings["l1_entry_threshold"]}`
        - `stale_days={settings["stale_days"]}`
        - `max_l3_per_kind={settings["max_l3_per_kind"]}`
        - `min_evidence_for_l3={settings["min_evidence_for_l3"]}`
        - `migration_days={settings["migration_days"]}`
        - `daily_entries_soft_limit={settings["daily_entries_soft_limit"]}`

        Install migration policy:
        - Preserve existing `AGENTS.md`, `MEMORY.md`, and daily notes.
        - Generate migration review and editable migration plan.
        - Do not auto-import legacy lines unless explicitly enabled in plan.
        {MANAGED_AGENT_BLOCK_END}
        """
    ).strip()


def render_memory_block() -> str:
    return dedent(
        f"""
        {MANAGED_MEMORY_BLOCK_START}
        ## L3 Experience
        - (empty)

        ## L3 Values
        - (empty)

        ## L3 Standards
        - (empty)

        ## Contradictions To Resolve
        - (none)

        ## Stale Candidates
        - (none)

        ## Last Distillation
        - (not run yet)
        {MANAGED_MEMORY_BLOCK_END}
        """
    ).strip()


def ensure_daily_file(path: Path, iso_date: str) -> bool:
    template = dedent(
        f"""
        # {iso_date} Memory Log (L1)

        ## Format
        Use one line per memory item:
        - [YYYY-MM-DD|M-YYYYMMDD-###|kind|axis|status|confidence|topic] statement

        ## L1 Entries
        """
    ).strip() + "\n"

    if not path.exists():
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(template, encoding="utf-8")
        return True

    original = path.read_text(encoding="utf-8")
    if "## L1 Entries" in original:
        return False

    path.write_text(original.rstrip() + "\n\n## L1 Entries\n", encoding="utf-8")
    return True


def ensure_memory_md(memory_path: Path) -> bool:
    if memory_path.exists():
        original = memory_path.read_text(encoding="utf-8")
    else:
        original = "# MEMORY.md\n\n"

    original = original.replace("## L3 Preferences", "## L3 Values")
    updated = upsert_marked_block(
        original,
        MANAGED_MEMORY_BLOCK_START,
        MANAGED_MEMORY_BLOCK_END,
        render_memory_block(),
    )
    if updated == original:
        return False
    memory_path.write_text(updated, encoding="utf-8")
    return True


def ensure_agents_md(agents_path: Path, settings: dict) -> bool:
    original = agents_path.read_text(encoding="utf-8") if agents_path.exists() else "# AGENTS.md\n\n"
    updated = upsert_marked_block(
        original,
        MANAGED_AGENT_BLOCK_START,
        MANAGED_AGENT_BLOCK_END,
        render_agent_block(settings),
    )
    if updated == original:
        return False
    agents_path.write_text(updated, encoding="utf-8")
    return True


def migrate_preference_to_value(workspace: Path) -> list[str]:
    changed_files: list[str] = []

    for path in sorted((workspace / "memory").glob("20??-??-??.md")):
        original = path.read_text(encoding="utf-8")
        updated = re.sub(r"\|preference\|", "|value|", original)
        if updated != original:
            path.write_text(updated, encoding="utf-8")
            changed_files.append(str(path))

    memory_md = workspace / "MEMORY.md"
    if memory_md.exists():
        original = memory_md.read_text(encoding="utf-8")
        updated = original.replace("L3 Preferences", "L3 Values")
        if updated != original:
            memory_md.write_text(updated, encoding="utf-8")
            changed_files.append(str(memory_md))

    l2_dir = sidecar_dir(workspace) / "l2"
    pref_path = l2_dir / "preference.md"
    value_path = l2_dir / "value.md"
    if pref_path.exists():
        if value_path.exists():
            merged = value_path.read_text(encoding="utf-8").rstrip() + "\n\n" + pref_path.read_text(encoding="utf-8")
            value_path.write_text(merged.rstrip() + "\n", encoding="utf-8")
            pref_path.unlink()
            changed_files.extend([str(value_path), str(pref_path)])
        else:
            pref_path.rename(value_path)
            changed_files.extend([str(pref_path), str(value_path)])

    return changed_files


def collect_legacy_lines(workspace: Path, migration_days: int) -> list[str]:
    lines: list[str] = []
    memory_path = workspace / "MEMORY.md"
    if memory_path.exists():
        in_managed_block = False
        for raw in memory_path.read_text(encoding="utf-8").splitlines():
            stripped = raw.strip()
            if stripped == MANAGED_MEMORY_BLOCK_START:
                in_managed_block = True
                continue
            if stripped == MANAGED_MEMORY_BLOCK_END:
                in_managed_block = False
                continue
            if in_managed_block:
                continue
            if not stripped or stripped.startswith("<!-- OPENCLAW_MEMORY_OS_"):
                continue
            if stripped.startswith("#"):
                continue
            if len(stripped) >= 12:
                lines.append(f"MEMORY.md :: {stripped}")

    memory_dir = workspace / "memory"
    if not memory_dir.exists():
        return lines

    cutoff = date.today() - timedelta(days=migration_days)
    for path in sorted(memory_dir.glob("20??-??-??.md")):
        try:
            log_date = date.fromisoformat(path.stem)
        except ValueError:
            continue
        if log_date < cutoff:
            continue
        for raw in path.read_text(encoding="utf-8").splitlines():
            stripped = raw.strip()
            if not stripped or stripped.startswith("#"):
                continue
            if stripped.startswith("- [") or stripped.startswith("Use one line per memory item"):
                continue
            if len(stripped) >= 12:
                lines.append(f"{path.name} :: {stripped}")
    return lines


def write_migration_review(workspace: Path, migration_days: int) -> Path:
    review_dir = sidecar_dir(workspace) / "reviews"
    review_dir.mkdir(parents=True, exist_ok=True)
    path = review_dir / f"migration-{date.today().isoformat()}.md"
    legacy_lines = collect_legacy_lines(workspace, migration_days)

    body = [
        f"# Legacy Memory Migration Review {date.today().isoformat()}",
        "",
        "This file is outside native memory on purpose.",
        "Promote selected items into typed L1 entries instead of copying everything blindly.",
        "",
        f"- scanned_window_days: {migration_days}",
        f"- legacy_candidates: {len(legacy_lines)}",
        "",
        "## Candidates",
    ]
    if legacy_lines:
        for line in legacy_lines:
            body.append(f"- {line}")
    else:
        body.append("- (none)")
    body.append("")

    path.write_text("\n".join(body), encoding="utf-8")
    return path


def bootstrap(workspace: Path, settings: dict) -> list[str]:
    _validate_settings(settings)
    changed: list[str] = []
    sidecar = sidecar_dir(workspace)
    sidecar.mkdir(parents=True, exist_ok=True)
    (sidecar / "l2").mkdir(parents=True, exist_ok=True)
    (sidecar / "reviews").mkdir(parents=True, exist_ok=True)

    review_path = write_migration_review(workspace, settings["migration_days"])
    changed.append(str(review_path))

    config = save_settings(workspace, settings)
    changed.append(str(config))
    state = save_state(workspace, {})
    changed.append(str(state))

    memory_path = workspace / "MEMORY.md"
    if ensure_memory_md(memory_path):
        changed.append(str(memory_path))

    today = date.today().isoformat()
    today_path = workspace / "memory" / f"{today}.md"
    if ensure_daily_file(today_path, today):
        changed.append(str(today_path))

    agents_path = workspace / "AGENTS.md"
    if ensure_agents_md(agents_path, settings):
        changed.append(str(agents_path))

    migration_changes = migrate_preference_to_value(workspace)
    changed.extend(migration_changes)

    marker_path = sidecar / "installed.txt"
    marker_path.write_text(
        f"installed_at={datetime.now().isoformat(timespec='seconds')}\n",
        encoding="utf-8",
    )
    changed.append(str(marker_path))
    return sorted(set(changed))


def main() -> int:
    parser = argparse.ArgumentParser(description="Install or update Better Memory additively.")
    parser.add_argument("--workspace", default=".", help="Workspace path (default: current directory)")
    parser.add_argument("--stale-days", type=int, default=DEFAULT_SETTINGS["stale_days"])
    parser.add_argument("--max-l3-per-kind", type=int, default=DEFAULT_SETTINGS["max_l3_per_kind"])
    parser.add_argument("--min-evidence-for-l3", type=int, default=DEFAULT_SETTINGS["min_evidence_for_l3"])
    parser.add_argument("--migration-days", type=int, default=DEFAULT_SETTINGS["migration_days"])
    parser.add_argument("--daily-entries-soft-limit", type=int, default=DEFAULT_SETTINGS["daily_entries_soft_limit"])
    parser.add_argument("--daily-review-time", default=DEFAULT_SETTINGS["daily_review_time"])
    parser.add_argument("--weekly-rollup-day", default=DEFAULT_SETTINGS["weekly_rollup_day"])
    parser.add_argument("--weekly-rollup-time", default=DEFAULT_SETTINGS["weekly_rollup_time"])
    parser.add_argument("--monthly-review-day", type=int, default=DEFAULT_SETTINGS["monthly_review_day"])
    parser.add_argument("--monthly-review-time", default=DEFAULT_SETTINGS["monthly_review_time"])
    parser.add_argument("--l1-entry-threshold", type=int, default=DEFAULT_SETTINGS["l1_entry_threshold"])
    args = parser.parse_args()

    workspace = Path(args.workspace).expanduser().resolve()
    settings = {
        "stale_days": args.stale_days,
        "max_l3_per_kind": args.max_l3_per_kind,
        "min_evidence_for_l3": args.min_evidence_for_l3,
        "migration_days": args.migration_days,
        "daily_entries_soft_limit": args.daily_entries_soft_limit,
        "daily_review_time": args.daily_review_time.strip(),
        "weekly_rollup_day": args.weekly_rollup_day.strip().upper(),
        "weekly_rollup_time": args.weekly_rollup_time.strip(),
        "monthly_review_day": args.monthly_review_day,
        "monthly_review_time": args.monthly_review_time.strip(),
        "l1_entry_threshold": args.l1_entry_threshold,
    }

    changed = bootstrap(workspace, settings)
    print(f"Workspace: {workspace}")
    print("Installed/updated additively.")
    for path in changed:
        print(f"- {path}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: agents/openai.yaml
content: interface:
  display_name: "Better Memory V2"
  short_description: "Smart install for native L1/L2/L3 memory fusion."
  default_prompt: "Use $better-memory to smart-install native memory fusion, then run daily, weekly, and monthly memory maintenance."


path: references/memory-schema.md
content: # Better Memory V2 Schema (Native-Fusion)

## Objective

Keep OpenClaw memory native while adding structured distillation:
- L1 raw logs in `memory/YYYY-MM-DD.md`
- L2 structured summaries in `.openclaw-memory-os/l2/*.md`
- L3 weekly abstractions in managed block of `MEMORY.md`

## Typing Model

- `experience`
  - `think`, `say`, `do`
- `value`
  - `good`, `bad`
- `standard`
  - `right`, `wrong`

Legacy `preference` is migrated to `value`.

## L1 Line Format

`- [YYYY-MM-DD|M-YYYYMMDD-###|kind|axis|status|confidence|topic] statement`

Rules:
- `status`: `active`, `candidate`, `stale`, `conflicted`, `superseded`
- `confidence`: `low`, `medium`, `high`
- `topic`: short stable cluster key

## Config Schema

Stored in `.openclaw-memory-os/config.json`:
- `stale_days`
- `max_l3_per_kind`
- `min_evidence_for_l3`
- `migration_days`
- `daily_entries_soft_limit`
- `daily_review_time` (`HH:MM`)
- `weekly_rollup_day` (`MON..SUN`)
- `weekly_rollup_time` (`HH:MM`)
- `monthly_review_day` (`1..31`)
- `monthly_review_time` (`HH:MM`)
- `l1_entry_threshold` (integer)

Runtime cursor state in `.openclaw-memory-os/state.json`:
- `last_daily_review_at`
- `last_weekly_rollup_at`
- `last_monthly_review_at`
- `last_processed_l1_entry_id`

## Distillation Lifecycle

1. Daily (`run_daily_review.py`)
- Parse L1 incrementally using cursor.
- Deduplicate by normalized `(kind, topic, statement)`.
- Build/update L2 markdown + `l2/summary.json`.
- Optional stale marking when requested.

2. Weekly (`run_weekly_rollup.py`)
- Read `l2/summary.json`.
- Emit stable high-signal sections into `MEMORY.md` managed block:
  - `L3 Experience`
  - `L3 Values`
  - `L3 Standards`
- Include contradiction and stale summaries.

3. Monthly (`run_monthly_review.py`)
- Generate advisory report only:
  - conflict groups
  - bloat topics
  - redundant statements
  - stale/superseded candidates
- Also generate `monthly-YYYY-MM-plan.json` with disabled actions.
- No automatic cleanup edits until actions are explicitly enabled and applied with:
  `apply_monthly_cleanup.py --workspace . [--rerollup]`.
- Cleanup apply writes execution summary:
  - `apply-summary-YYYYMMDD-HHMMSS.md`
  - includes overview, per-action impact, and sampled entry-level changes.

## Contradiction Heuristic

- `value`: same topic contains both `good` and `bad`
- `standard`: same topic contains both `right` and `wrong`

## Migration Policy

- Smart install creates migration review and editable migration plan.
- Plan application imports only user-enabled items as `candidate`.
- Historical native memory is preserved; no destructive rewrite.

## Scheduling

`emit_cron_template.py` writes `.openclaw-memory-os/cron-template.txt` with:
- catch-up daily check (`--threshold-only`)
- daily L1 -> L2 run
- weekly L2 -> L3 rollup
- monthly advisory review


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