Skill: teman2050/dream-memory-manager (version 0.2.1)

--- name: dream description: >- 主动维护 MEMORY.md 的记忆蒸馏技能。触发场景： (1) 每日 03:30 定时蒸馏（空闲时执行，忙则顺延至 06:00）； (2) 用户说 "dream" / "复盘" / "整理记忆" / "你记得我什么"； (3) 用户要求收录内容到 Obsidian 索引。 version: 0.2.1 metadata: openclaw: emoji: "🌙" homepage: https://github.com/teman2050/dream-skill requires: bins: - jq - wc config: - DREAM_VAULT_PATH install: - kind: brew formula: jq bins: [jq] --- # Dream — 主动记忆蒸馏 v5 ## 核心定位 **OpenClaw 原生已经解决了捕获和搜索问题：** - 捕获：compaction flush 自动把重要内容写入 `memory/YYYY-MM-DD.md` - 搜索：`openclaw memory search` 提供向量 + BM25 混合搜索 **Dream 填补的空白：** - 原生不会主动维护 MEMORY.md（只靠 AI 偶尔手动写，内容只增不减） - 原生对 MEMORY.md 的 20,000 字符上限没有任何主动处理：超限后直接静默截断， AI 不会收到警告，只是安静地丢失后半段上下文，用户很难察觉 Dream 在 18,000 字符时主动触发压缩，将过期内容移入 ledger， 确保 MEMORY.md 始终在有效范围内、内容始终是最新最相关的 - 原生没有永久档案（ledger） - 原生没有 Re-emergence 检测机制 Dream 的职责是**蒸馏者**，不是捕获者，也不造重复的搜索轮子。 --- ## 文件分工 ``` OpenClaw 原生（Dream 只读） └── memory/YYYY-MM-DD.md ← compaction flush 自动写入，Dream 读取作为原材料 Dream 主动维护 ├── MEMORY.md ← 每日蒸馏更新，每次对话全量注入 context，精简为王 │ 硬上限 18,000 字符（原生截断线 20,000，留 2,000 余量） │ {DREAM_VAULT_PATH}/ ├── ledger.md ← 永久档案，只追加，永不删除 ├── ledger-index.json ← 结构化索引，供 ledger 检索用 ├── meta/ │ ├── removed-entries.json ← 曾从 MEMORY.md 移除的条目摘要（Re-emergence 用） │ ├── last-review.txt ← 上次蒸馏完成时间 │ └── dream-state.txt ← active | hibernating | pending └── obsidian-index/ ├── _index.md ← 内容主索引（按日期倒序） └── topics/<topic>.md ← 按主题分类 ``` **不存在的文件（刻意省略）：** - ~~cache.md~~ → 由 `memory/YYYY-MM-DD.md` 原生承担 - ~~自建搜索索引~~ → 由 `openclaw memory search` 原生承担 --- ## MEMORY.md 结构规范 Dream 维护的 MEMORY.md 分三个区块，总字符数强制控制在 15,000 以内： ```markdown ## 当前状态 <!-- 正在进行的项目、未完成的决策、近期重要变化 --> <!-- 变化最快，每次蒸馏都检查是否需要更新 --> ## 稳定认知 <!-- 技术栈、工作环境、决策风格、核心偏好、反复验证的价值判断 --> <!-- 变化慢，只在有新证据时更新 --> ## 关系与背景 <!-- 重要的人、正在进行的合作、关键外部上下文 --> <!-- 按需更新 --> ## Dream <!-- Dream 技能自动维护，勿手动编辑 --> <!-- ledger 最近 5 条入档摘要，让 AI 知道最近什么内容被永久保存了 --> ``` **不写入 MEMORY.md 的内容：** - 可以实时观察到的信息（用 Mac、喜欢深色主题） - 流水账细节（这些在 memory/YYYY-MM-DD.md 里，可随时 search） - 已完成/已过期的状态（移入 ledger 后从 MEMORY.md 删除） --- ## 实时捕获（对话中，不等复盘） 对话过程中发现以下内容时，**直接写入 MEMORY.md 对应区块**（不等 03:30）， 同时记录到当日 `memory/YYYY-MM-DD.md`： **立即写入 MEMORY.md 的情况：** - 用户明确纠正了 AI 的判断（"不对，我其实..."）→ 更新【稳定认知】 - 用户宣布重要决策结果（"我决定了..."）→ 更新【当前状态】 - 用户描述正在进行的新项目 → 更新【当前状态】 **只写 memory/YYYY-MM-DD.md，等待 03:30 蒸馏：** - 情绪事件、偏好讨论、观点表达 - 单次出现、重要性尚不确定的信息 写入前检查 MEMORY.md 字符数（`dream-tools.sh --check-size`）， 超过 16,000 字符时先压缩最旧的【当前状态】条目，再写入。 --- ## 操作指令 ### `dream review` — 每日蒸馏（核心） **全程自动，静默执行（03:30 触发时不推送任何消息）。** 执行步骤： **Step 1 — 前置检查** ``` dream-tools.sh --check-idle → busy? 写 pending，15分钟后重试，上限 06:00 dream-tools.sh --check-size → 读取 MEMORY.md 当前字符数 ``` **Step 2 — 读取原材料** 读取自上次蒸馏以来新增的 `memory/YYYY-MM-DD.md` 文件（增量，不重复处理）。 若无新文件，跳过蒸馏，只更新 `last-review.txt`。 **Step 3 — AI 蒸馏判断** 对每条日记内容，按以下规则判断操作： | 判断 | 条件 | 操作 | |------|------|------| | 更新 MEMORY.md | 比现有条目有新进展或修正 | 替换对应行 | | 新增到 MEMORY.md | 跨 2 个以上日期出现，或单次但明显重要 | 追加到对应区块 | | 仅入 ledger | 重要但已完成/过期，不需要常驻上下文 | 从 MEMORY.md 删除并入档 | | 忽略 | 口水话、单次低价值、已有高度相似条目 | 丢弃 | **Re-emergence 检查**（每次蒸馏必做）： 对照 `meta/removed-entries.json`，若日记中出现与曾被移除条目语义相似（>70%）的内容， 该条目重新写入 MEMORY.md 并标注 `[re-emerged]`，优先级提升，下次蒸馏不轻易移除。 同时在 ledger 追加一条 re-emergence 事件记录。 **Step 4 — 字符上限保护（写入前执行）** ``` 目标写入后大小 > 15,000 字符？ → 压缩【当前状态】中最旧的已完成条目：移入 ledger，从 MEMORY.md 删除 → 压缩【稳定认知】中重复度最高的条目：合并相似条目 → 直到预估写入后 ≤ 18,000 字符 ``` **Step 5 — 原子写入 MEMORY.md** ``` dream-tools.sh --atomic-write MEMORY.md <tmpfile> # 先写 .tmp，验证格式和字符数（≤ 18,000）后 mv 替换 ``` **Step 6 — 入档操作** 对本次蒸馏中判断"仅入 ledger"的条目： ``` dream-tools.sh --ledger-append <id> <category> <content> # 追加区块到 ledger.md，更新 ledger-index.json ``` ledger 条目格式： ```markdown --- ID: a3f8c201 入档时间: 2026-02-27 03:31 类别: [决策] 内容: 决定用 Obsidian 替代 Notion，原因是数据本地化和 Git 同步 来源: memory/2026-02-27.md，首次出现 --- ``` **Step 7 — 更新 meta** - 追加今日到 `active-days.json`（去重） - 更新 `last-review.txt` - 更新 `## Dream` 区块：ledger 最近 5 条一句话摘要（覆盖） **Step 8 — 复盘日志（按月归档）** ``` # 追加到 meta/review-YYYY-MM.md ### YYYY-MM-DD 03:30 更新: N条 | 新增: N条 | 入档: N条 | Re-emergence: N条 | 忽略: N条 MEMORY.md 字符数: N → N ``` 手动触发时额外输出简报： ``` 🌙 蒸馏完成 MEMORY.md: N 字符（上限 15,000） 本次：更新 N 条 | 新增 N 条 | 入档 N 条 永久档案共 N 条记录 ``` --- ### 搜索：全部委托原生，不自建 ```bash # 日常搜索（memory/ 目录 + MEMORY.md，向量+BM25混合） openclaw memory search "<关键词>" # 深度检索（含永久档案） # Step 1: 先用原生搜索 memory 部分 openclaw memory search "<关键词>" # Step 2: 再检索 ledger dream-tools.sh --ledger-search "<关键词>" # Step 3: 合并输出，标注来源 ``` `dream search <关键词>` 命令封装以上两步，输出时明确区分来源： ``` 🔍 "Obsidian" 检索结果： ── OpenClaw Memory ── · memory/2026-02-27.md: 决定用 Obsidian 替代 Notion... · MEMORY.md [稳定认知]: 主力笔记工具 Obsidian... ── 永久档案 ── · [a3f8c201] 2026-02-27 [决策] 决定用 Obsidian 替代 Notion（入档得分 22） ── Obsidian 内容索引 ── · OpenClaw + Obsidian 集成笔记 (2026-02-15) ``` --- ### `dream index <内容>` — 收录到 Obsidian 索引 1. URL/标题哈希去重，已存在跳过 2. 提取元数据：标题、来源、日期、主题标签（≤5个）、一句话摘要 3. Append 到 `obsidian-index/_index.md` 和对应 `topics/<topic>.md` 4. 若内容明显反映用户偏好，同时写入当日 `memory/YYYY-MM-DD.md` 一条记录，等待下次蒸馏 --- ### `dream status` — 状态总览（只读 meta，低 IO） ``` 🌙 Dream 状态 — YYYY-MM-DD HH:MM MEMORY.md: N 字符 / 18,000 上限（N%） 永久档案: N 条记录 上次蒸馏: YYYY-MM-DD HH:MM（N 小时前） 系统状态: active / hibernating（沉睡 N 天）/ pending（顺延中，已等 N 分钟） Obsidian 索引: N 条 待蒸馏日记: N 个文件（自上次蒸馏后新增） ``` --- ### `dream forget <描述>` — 从 memory 中清除 在 `memory/YYYY-MM-DD.md` 和 MEMORY.md 中语义搜索匹配条目并清除。 无需确认，直接执行。 **Re-emergence 机制：** 清除时将条目摘要写入 `meta/removed-entries.json`，记录清除时间和内容哈希。 后续对话中若该内容再次出现，自动触发 re-emergence，重新写入 MEMORY.md 并提升优先级。 被遗忘后又出现的内容，比从未被遗忘的内容更值得保留。 ledger 中的记录不受 `dream forget` 影响，永久保留。 执行时告知：「已从记忆中清除，永久档案不受影响。」 --- ### `dream init` — 冷启动引导 1. 创建 `{DREAM_VAULT_PATH}` 目录结构 2. 若 MEMORY.md 为空或不存在，询问 5 个种子问题： 工具偏好、工作环境、近期最重要的项目、核心价值判断、最重要的人际关系 3. 将回答直接写入 MEMORY.md 对应区块（不等蒸馏） 4. 写入 `dream-state.txt` = `active` --- ### `dream wakeup` — 休眠唤醒 连续 7 个日历天无活跃后首次对话自动触发： 1. 输出 ledger 最近 3 条 + MEMORY.md 当前快照摘要 2. 恢复 active 状态，不删除任何内容 --- ## 辅助脚本 `dream-tools.sh` ```bash dream-tools.sh --check-idle # 查询 openclaw agent status，返回 idle / busy dream-tools.sh --check-size # 返回 MEMORY.md 当前字符数（用 wc -c） dream-tools.sh --hash "<内容>" # 返回 8 位 MD5 短哈希，用于 ID 生成和去重 dream-tools.sh --atomic-write <target-file> <tmp-file> # 验证 tmp-file 格式和字符数后 mv 替换 target-file dream-tools.sh --ledger-append <id> <category> <content> [<note>] # 向 ledger.md 追加区块，同时更新 ledger-index.json dream-tools.sh --ledger-search "<keyword>" # 在 ledger-index.json 中检索，返回匹配条目 dream-tools.sh --dedup-index "<url-or-hash>" # 检查 obsidian-index 是否已有该条目，返回 exists / new ``` 依赖：`jq`（JSON）、`wc`（字符计数）、`md5sum`（哈希）。 不依赖 Python / Node。 --- ## 定时触发（SOUL.md 中加入） ``` 每天 03:30 执行 dream review --scheduled ``` 顺延逻辑： - 03:30 检测 idle → busy → 写 pending → 15 分钟后重试 - 重试窗口：03:45 / 04:00 / ... / 06:00 - 06:00 强制执行，不再顺延 - 全程静默，不推送消息，结果写入 meta/review-YYYY-MM.md --- ## 休眠保护 | 条件 | 行为 | |------|------| | 连续 7 日历天无活跃 | 写入 hibernating，03:30 心跳直接跳过，零 IO | | 首次新对话 | 触发 `dream wakeup`，不删除任何内容 | --- ## IO 原则 - 所有写入先写 `.tmp`，原子替换，防崩溃 - ledger / obsidian-index 只追加，不重写 - status 只读 meta 目录小文件，不触碰 ledger / MEMORY.md 正文 - 休眠状态 03:30 心跳直接跳过，零文件操作 - 写入 MEMORY.md 前强制检查字符数，不允许超过 18,000

path: dream-tools.sh
content: #!/usr/bin/env bash
# dream-tools.sh — Dream 技能辅助脚本
# 所有精确计算、文件操作、日期运算由此脚本承担，AI 不做心算
# 依赖：jq, wc, md5sum（macOS 用 md5），openclaw CLI
# 用法：dream-tools.sh <--command> [args...]

set -euo pipefail

# ── 环境变量 ────────────────────────────────────────────────────────────────

DREAM_VAULT_PATH="${DREAM_VAULT_PATH:-$HOME/Documents/Obsidian/dream-vault}"
WORKSPACE_PATH="${OPENCLAW_WORKSPACE:-$HOME/.openclaw/workspace}"
MEMORY_MD="$WORKSPACE_PATH/MEMORY.md"
LEDGER_MD="$DREAM_VAULT_PATH/ledger.md"
LEDGER_INDEX="$DREAM_VAULT_PATH/ledger-index.json"
REMOVED_ENTRIES="$DREAM_VAULT_PATH/meta/removed-entries.json"
OBSIDIAN_INDEX="$DREAM_VAULT_PATH/obsidian-index/_index.md"
MEMORY_HARD_LIMIT=18000
MEMORY_COMPRESS_TRIGGER=16000

# ── 工具函数 ────────────────────────────────────────────────────────────────

log() { echo "[dream-tools] $*" >&2; }

die() { echo "[dream-tools] ERROR: $*" >&2; exit 1; }

# 跨平台 MD5（macOS 用 md5，Linux 用 md5sum）
md5_hash() {
    local input="$1"
    if command -v md5sum &>/dev/null; then
        echo -n "$input" | md5sum | cut -c1-8
    elif command -v md5 &>/dev/null; then
        echo -n "$input" | md5 -q | cut -c1-8
    else
        die "未找到 md5sum 或 md5 命令"
    fi
}

# 确保目录存在
ensure_dir() { mkdir -p "$(dirname "$1")"; }

# ── 命令实现 ────────────────────────────────────────────────────────────────

# --check-idle
# 检查 OpenClaw 是否空闲，返回 idle 或 busy
# 超时 5 秒默认返回 busy，避免 gateway 无响应时挂起
cmd_check_idle() {
    local status
    status=$(timeout 5s openclaw agent status 2>/dev/null) || {
        echo "busy"
        return 0
    }
    # 根据 openclaw agent status 的输出判断
    # 若有 "running" / "active" / "processing" 字样则视为 busy
    if echo "$status" | grep -qiE "running|active|processing|busy"; then
        echo "busy"
    else
        echo "idle"
    fi
}

# --check-size
# 返回 MEMORY.md 当前字符数
# 输出格式：<当前字符数> <压缩触发阈值> <硬上限> <状态:ok|warn|critical>
cmd_check_size() {
    if [[ ! -f "$MEMORY_MD" ]]; then
        echo "0 $MEMORY_COMPRESS_TRIGGER $MEMORY_HARD_LIMIT ok"
        return 0
    fi
    local size
    size=$(wc -c < "$MEMORY_MD" | tr -d ' ')
    local state="ok"
    if [[ $size -ge $MEMORY_HARD_LIMIT ]]; then
        state="critical"
    elif [[ $size -ge $MEMORY_COMPRESS_TRIGGER ]]; then
        state="warn"
    fi
    echo "$size $MEMORY_COMPRESS_TRIGGER $MEMORY_HARD_LIMIT $state"
}

# --hash "<内容>"
# 输出 8 位短哈希，用于条目 ID 生成和去重
cmd_hash() {
    local input="${1:-}"
    [[ -z "$input" ]] && die "--hash 需要提供内容参数"
    md5_hash "$input"
}

# --atomic-write <target-file> <tmp-file>
# 验证 tmp-file 存在且字符数不超过硬上限后，原子替换 target-file
# 针对 MEMORY.md 额外做字符数校验，其他文件只做存在性校验
cmd_atomic_write() {
    local target="${1:-}"
    local tmpfile="${2:-}"
    [[ -z "$target" || -z "$tmpfile" ]] && die "--atomic-write 需要 <target> <tmpfile>"
    [[ ! -f "$tmpfile" ]] && die "tmp 文件不存在：$tmpfile"

    # 若目标是 MEMORY.md，检查字符数上限
    if [[ "$(realpath "$target" 2>/dev/null)" == "$(realpath "$MEMORY_MD" 2>/dev/null)" ]] || \
       [[ "$target" == *"MEMORY.md" ]]; then
        local size
        size=$(wc -c < "$tmpfile" | tr -d ' ')
        if [[ $size -gt $MEMORY_HARD_LIMIT ]]; then
            die "写入中止：tmp 文件大小 ${size} 字符，超过硬上限 ${MEMORY_HARD_LIMIT}。请先压缩内容。"
        fi
        log "MEMORY.md 写入校验通过：${size}/${MEMORY_HARD_LIMIT} 字符"
    fi

    ensure_dir "$target"
    # mv 在同一文件系统上是原子操作
    mv "$tmpfile" "$target"
    log "原子写入完成：$target"
}

# --ledger-append <id> <category> <content> [<note>]
# 向 ledger.md 追加一条记录区块，同时更新 ledger-index.json
cmd_ledger_append() {
    local id="${1:-}"
    local category="${2:-}"
    local content="${3:-}"
    local note="${4:-首次入档}"
    [[ -z "$id" || -z "$category" || -z "$content" ]] && \
        die "--ledger-append 需要 <id> <category> <content>"

    ensure_dir "$LEDGER_MD"
    ensure_dir "$LEDGER_INDEX"

    local timestamp
    timestamp=$(date '+%Y-%m-%d %H:%M')
    local datestamp
    datestamp=$(date '+%Y-%m-%d')

    # 追加到 ledger.md
    cat >> "$LEDGER_MD" << EOF

---
ID: $id
入档时间: $timestamp
类别: $category
内容: $content
备注: $note
---
EOF

    # 更新 ledger-index.json
    # 若文件不存在或为空，初始化为空数组
    if [[ ! -f "$LEDGER_INDEX" ]] || [[ ! -s "$LEDGER_INDEX" ]]; then
        echo '[]' > "$LEDGER_INDEX"
    fi

    # 检查 ID 是否已存在
    local exists
    exists=$(jq --arg id "$id" 'any(.[]; .id == $id)' "$LEDGER_INDEX")

    if [[ "$exists" == "true" ]]; then
        # 已存在：追加事件记录到该条目的 events 数组
        local tmp_index
        tmp_index=$(mktemp)
        jq --arg id "$id" \
           --arg ts "$timestamp" \
           --arg note "$note" \
           'map(if .id == $id then
               .events += [{"time": $ts, "note": $note}] |
               .last_updated = $ts
           else . end)' \
           "$LEDGER_INDEX" > "$tmp_index"
        mv "$tmp_index" "$LEDGER_INDEX"
        log "ledger-index 更新（已有条目追加事件）：$id"
    else
        # 不存在：新增条目
        local tmp_index
        tmp_index=$(mktemp)
        jq --arg id "$id" \
           --arg category "$category" \
           --arg content "$content" \
           --arg ts "$timestamp" \
           --arg date "$datestamp" \
           --arg note "$note" \
           '. += [{
               "id": $id,
               "category": $category,
               "summary": ($content | .[0:80]),
               "first_archived": $date,
               "last_updated": $ts,
               "status": "active",
               "events": [{"time": $ts, "note": $note}]
           }]' \
           "$LEDGER_INDEX" > "$tmp_index"
        mv "$tmp_index" "$LEDGER_INDEX"
        log "ledger-index 新增条目：$id"
    fi

    log "ledger 追加完成：[$category] ${content:0:60}..."
}

# --ledger-search "<keyword>"
# 在 ledger-index.json 中检索，输出匹配条目的 ID、类别、摘要、入档时间
cmd_ledger_search() {
    local keyword="${1:-}"
    [[ -z "$keyword" ]] && die "--ledger-search 需要提供关键词"
    [[ ! -f "$LEDGER_INDEX" ]] && { echo "[]"; return 0; }

    # 在 summary 和 category 中进行大小写不敏感的字符串匹配
    jq --arg kw "$keyword" \
       '[.[] | select(
           (.summary | ascii_downcase | contains($kw | ascii_downcase)) or
           (.category | ascii_downcase | contains($kw | ascii_downcase))
       ) | {id, category, summary, first_archived, status}]' \
       "$LEDGER_INDEX"
}

# --ledger-mark-reemergence <id>
# 将指定条目标记为 re-emerged，在 ledger 追加事件记录
cmd_ledger_mark_reemergence() {
    local id="${1:-}"
    [[ -z "$id" ]] && die "--ledger-mark-reemergence 需要提供 ID"
    [[ ! -f "$LEDGER_INDEX" ]] && die "ledger-index.json 不存在"

    local timestamp
    timestamp=$(date '+%Y-%m-%d %H:%M')

    # 追加事件到 ledger.md
    cat >> "$LEDGER_MD" << EOF

---
ID: $id
更新时间: $timestamp
事件: re-emerged
备注: 该条目曾被遗忘，在后续对话中再次出现，重要性提升
---
EOF

    # 更新 ledger-index.json 中的状态和事件
    local tmp_index
    tmp_index=$(mktemp)
    jq --arg id "$id" \
       --arg ts "$timestamp" \
       'map(if .id == $id then
           .status = "re-emerged" |
           .last_updated = $ts |
           .events += [{"time": $ts, "note": "re-emerged"}]
       else . end)' \
       "$LEDGER_INDEX" > "$tmp_index"
    mv "$tmp_index" "$LEDGER_INDEX"
    log "ledger re-emergence 标记完成：$id"
}

# --dedup-index "<url-or-hash>"
# 检查 obsidian-index/_index.md 是否已有该条目
# 返回 exists 或 new
cmd_dedup_index() {
    local input="${1:-}"
    [[ -z "$input" ]] && die "--dedup-index 需要提供 URL 或哈希"

    if [[ ! -f "$OBSIDIAN_INDEX" ]]; then
        echo "new"
        return 0
    fi

    local hash
    hash=$(md5_hash "$input")

    # 在索引文件中搜索原始 URL 或其哈希
    if grep -qF "$input" "$OBSIDIAN_INDEX" 2>/dev/null || \
       grep -qF "$hash" "$OBSIDIAN_INDEX" 2>/dev/null; then
        echo "exists"
    else
        echo "new"
    fi
}

# --record-removed "<id>" "<summary>" "<content-hash>"
# 将从 MEMORY.md 移除的条目记录到 removed-entries.json（Re-emergence 用）
cmd_record_removed() {
    local id="${1:-}"
    local summary="${2:-}"
    local content_hash="${3:-}"
    [[ -z "$id" || -z "$summary" ]] && die "--record-removed 需要 <id> <summary>"

    ensure_dir "$REMOVED_ENTRIES"

    if [[ ! -f "$REMOVED_ENTRIES" ]] || [[ ! -s "$REMOVED_ENTRIES" ]]; then
        echo '[]' > "$REMOVED_ENTRIES"
    fi

    local timestamp
    timestamp=$(date '+%Y-%m-%d %H:%M')
    local datestamp
    datestamp=$(date '+%Y-%m-%d')

    local tmp
    tmp=$(mktemp)
    jq --arg id "$id" \
       --arg summary "$summary" \
       --arg hash "$content_hash" \
       --arg ts "$timestamp" \
       --arg date "$datestamp" \
       '. += [{
           "id": $id,
           "summary": $summary,
           "content_hash": $hash,
           "removed_at": $date,
           "removed_ts": $ts,
           "reemergence_count": 0
       }]' \
       "$REMOVED_ENTRIES" > "$tmp"
    mv "$tmp" "$REMOVED_ENTRIES"
    log "removed-entries 记录：$id"
}

# --check-reemergence "<content>"
# 检查新内容是否与曾被移除的条目相似
# 当前实现：关键词匹配（精度够用，避免引入向量依赖）
# 返回：matched <id> <summary> 或 no-match
cmd_check_reemergence() {
    local content="${1:-}"
    [[ -z "$content" ]] && die "--check-reemergence 需要提供内容"
    [[ ! -f "$REMOVED_ENTRIES" ]] && { echo "no-match"; return 0; }

    # 提取内容中的关键词（去除常见停用词，取前 5 个有意义的词）
    local keywords
    keywords=$(echo "$content" | \
        tr '[:upper:]' '[:lower:]' | \
        grep -oE '[a-z\u4e00-\u9fa5]{2,}' | \
        grep -vE '^(the|and|for|with|that|this|from|have|will|been|they|them|我|的|是|了|在|和|也|就|都|但)$' | \
        head -5 | tr '\n' '|' | sed 's/|$//')

    if [[ -z "$keywords" ]]; then
        echo "no-match"
        return 0
    fi

    # 在 removed-entries 的 summary 中搜索关键词
    local match
    match=$(jq --arg pattern "$keywords" \
        '[.[] | select(.summary | test($pattern; "i"))] | first // empty' \
        "$REMOVED_ENTRIES" 2>/dev/null)

    if [[ -n "$match" && "$match" != "null" ]]; then
        local matched_id matched_summary
        matched_id=$(echo "$match" | jq -r '.id')
        matched_summary=$(echo "$match" | jq -r '.summary')

        # 更新 reemergence_count
        local tmp
        tmp=$(mktemp)
        jq --arg id "$matched_id" \
            'map(if .id == $id then
                .reemergence_count += 1
            else . end)' \
            "$REMOVED_ENTRIES" > "$tmp"
        mv "$tmp" "$REMOVED_ENTRIES"

        echo "matched $matched_id $matched_summary"
    else
        echo "no-match"
    fi
}

# --active-days-since "<YYYY-MM-DD>"
# 计算从指定日期到今天之间记录在 active-days.json 中的活跃天数
cmd_active_days_since() {
    local since="${1:-}"
    [[ -z "$since" ]] && die "--active-days-since 需要提供日期（YYYY-MM-DD）"

    local active_days_file="$DREAM_VAULT_PATH/meta/active-days.json"
    if [[ ! -f "$active_days_file" ]]; then
        echo "0"
        return 0
    fi

    jq --arg since "$since" \
        '[.[] | select(. >= $since)] | length' \
        "$active_days_file"
}

# --record-active-day
# 将今日加入 active-days.json（去重）
cmd_record_active_day() {
    local active_days_file="$DREAM_VAULT_PATH/meta/active-days.json"
    ensure_dir "$active_days_file"

    local today
    today=$(date '+%Y-%m-%d')

    if [[ ! -f "$active_days_file" ]] || [[ ! -s "$active_days_file" ]]; then
        echo '[]' > "$active_days_file"
    fi

    local tmp
    tmp=$(mktemp)
    jq --arg today "$today" \
        'if any(.[]; . == $today) then . else . += [$today] | sort end' \
        "$active_days_file" > "$tmp"
    mv "$tmp" "$active_days_file"
    log "活跃天记录：$today"
}

# --init
# 初始化 Dream vault 目录结构
cmd_init() {
    log "初始化 Dream vault：$DREAM_VAULT_PATH"

    mkdir -p "$DREAM_VAULT_PATH/meta"
    mkdir -p "$DREAM_VAULT_PATH/obsidian-index/topics"

    # 初始化 JSON 文件
    [[ ! -f "$LEDGER_INDEX" ]]      && echo '[]' > "$LEDGER_INDEX"
    [[ ! -f "$REMOVED_ENTRIES" ]]   && echo '[]' > "$REMOVED_ENTRIES"
    [[ ! -f "$DREAM_VAULT_PATH/meta/active-days.json" ]] && \
        echo '[]' > "$DREAM_VAULT_PATH/meta/active-days.json"

    # 初始化 dream-state.txt
    if [[ ! -f "$DREAM_VAULT_PATH/meta/dream-state.txt" ]]; then
        echo "active" > "$DREAM_VAULT_PATH/meta/dream-state.txt"
    fi

    # 初始化 ledger.md（带说明头）
    if [[ ! -f "$LEDGER_MD" ]]; then
        cat > "$LEDGER_MD" << 'EOF'
# Dream Ledger — 永久档案

> 只追加，永不删除。每条记录代表曾经到达过长期记忆的内容。
> 即使被遗忘、被移除，历史记录永久保留。

EOF
    fi

    # 初始化 obsidian-index/_index.md
    if [[ ! -f "$OBSIDIAN_INDEX" ]]; then
        cat > "$OBSIDIAN_INDEX" << 'EOF'
# Obsidian 内容索引

> 由 Dream 技能维护。文章、笔记、网页的结构化索引，按日期倒序。

EOF
    fi

    log "初始化完成"
    echo "ok"
}

# --status
# 输出 Dream 系统状态摘要（低 IO，只读 meta）
cmd_status() {
    local state_file="$DREAM_VAULT_PATH/meta/dream-state.txt"
    local last_review_file="$DREAM_VAULT_PATH/meta/last-review.txt"
    local active_days_file="$DREAM_VAULT_PATH/meta/active-days.json"

    local state="unknown"
    [[ -f "$state_file" ]] && state=$(cat "$state_file")

    local last_review="从未"
    local hours_since="-"
    if [[ -f "$last_review_file" ]]; then
        last_review=$(cat "$last_review_file")
        # 计算距上次蒸馏的小时数
        if command -v python3 &>/dev/null; then
            hours_since=$(python3 -c "
from datetime import datetime
last = datetime.strptime('$last_review', '%Y-%m-%d %H:%M')
diff = datetime.now() - last
print(int(diff.total_seconds() / 3600))
" 2>/dev/null || echo "-")
        fi
    fi

    local memory_size=0
    [[ -f "$MEMORY_MD" ]] && memory_size=$(wc -c < "$MEMORY_MD" | tr -d ' ')

    local ledger_count=0
    [[ -f "$LEDGER_INDEX" ]] && \
        ledger_count=$(jq 'length' "$LEDGER_INDEX" 2>/dev/null || echo 0)

    local active_days=0
    [[ -f "$active_days_file" ]] && \
        active_days=$(jq 'length' "$active_days_file" 2>/dev/null || echo 0)

    local obsidian_count=0
    [[ -f "$OBSIDIAN_INDEX" ]] && \
        obsidian_count=$(grep -c '^## ' "$OBSIDIAN_INDEX" 2>/dev/null || echo 0)

    cat << EOF
MEMORY.md: ${memory_size} 字符 / ${MEMORY_HARD_LIMIT} 上限
永久档案: ${ledger_count} 条记录
活跃天数: ${active_days} 天
上次蒸馏: ${last_review}（${hours_since} 小时前）
系统状态: ${state}
Obsidian 索引: ${obsidian_count} 条
EOF
}

# ── 主入口 ──────────────────────────────────────────────────────────────────

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

case "$CMD" in
    --check-idle)             cmd_check_idle ;;
    --check-size)             cmd_check_size ;;
    --hash)                   cmd_hash "$@" ;;
    --atomic-write)           cmd_atomic_write "$@" ;;
    --ledger-append)          cmd_ledger_append "$@" ;;
    --ledger-search)          cmd_ledger_search "$@" ;;
    --ledger-mark-reemergence) cmd_ledger_mark_reemergence "$@" ;;
    --dedup-index)            cmd_dedup_index "$@" ;;
    --record-removed)         cmd_record_removed "$@" ;;
    --check-reemergence)      cmd_check_reemergence "$@" ;;
    --active-days-since)      cmd_active_days_since "$@" ;;
    --record-active-day)      cmd_record_active_day ;;
    --init)                   cmd_init ;;
    --status)                 cmd_status ;;
    *)
        cat << 'EOF'
用法：dream-tools.sh <命令> [参数...]

命令列表：
  --check-idle                        检查 OpenClaw 是否空闲（返回 idle/busy）
  --check-size                        返回 MEMORY.md 字符数和状态
  --hash "<内容>"                     生成 8 位短哈希（ID 生成/去重用）
  --atomic-write <目标文件> <tmp文件>  原子替换文件（含 MEMORY.md 字符数校验）
  --ledger-append <id> <类别> <内容> [备注]  向 ledger 追加记录
  --ledger-search "<关键词>"           检索 ledger-index.json
  --ledger-mark-reemergence <id>       标记 ledger 条目为 re-emerged
  --dedup-index "<url或哈希>"          检查 obsidian-index 是否已有该条目
  --record-removed <id> <摘要> <哈希>  记录从 MEMORY.md 移除的条目
  --check-reemergence "<内容>"         检查内容是否与曾被移除条目相似
  --active-days-since "<YYYY-MM-DD>"  计算指定日期后的活跃天数
  --record-active-day                 记录今日为活跃天
  --init                              初始化 Dream vault 目录结构
  --status                            输出系统状态摘要

环境变量：
  DREAM_VAULT_PATH    Dream vault 路径（默认：~/Documents/Obsidian/dream-vault）
  OPENCLAW_WORKSPACE  OpenClaw workspace 路径（默认：~/.openclaw/workspace）
EOF
        exit 1
        ;;
esac


path: readme.md
content: # Dream — OpenClaw Memory Distillation Skill

> Actively maintains your `MEMORY.md` so OpenClaw truly remembers you.

---

## What problem does it solve

OpenClaw's native memory mechanism has three unhandled issues:

**1. MEMORY.md grows indefinitely and silently truncates**
OpenClaw truncates MEMORY.md at 20,000 characters — no error, no warning. The AI quietly loses the second half of your context. You think it remembers, but it doesn't. Dream triggers compression at 18,000 characters, archiving stale content to the ledger so MEMORY.md always stays within the effective range.

**2. No permanent archive**
Important memories disappear once cleaned up. Dream maintains a `ledger.md` — append-only, never deleted. Anything that has ever reached long-term memory is preserved forever, even after being forgotten, and remains searchable for deep recall.

**3. No Re-emergence detection**
You deliberately clear a memory, but it surfaces again in later conversations — that means it mattered more than you thought. Dream detects this "forgotten then re-emerged" pattern, automatically rewrites the entry into memory, and elevates its priority.

---

## How it works

```
memory/YYYY-MM-DD.md        ← Written automatically by OpenClaw (compaction flush)
         │
         │  Daily at 03:30, Dream reads and distills
         ▼
     MEMORY.md               ← Actively maintained by Dream, hard limit 18,000 chars
         │
         │  Important but expired content
         ▼
     ledger.md               ← Permanent archive, append-only, never deleted
```

**Dream is a distiller, not a capturer.**
Capture is handled by OpenClaw's native compaction flush. Search is handled by the native `openclaw memory search`. Dream only does what the native system doesn't: distillation, compression, archiving, and Re-emergence detection.

---

## File structure

After installation, the following files are created:

```
~/.openclaw/workspace/
└── MEMORY.md                    ← Maintained by Dream (existing file, Dream takes over)

~/Documents/Obsidian/dream-vault/    ← Configured via DREAM_VAULT_PATH
├── ledger.md                    ← Permanent archive
├── ledger-index.json            ← Structured index for archive search
├── meta/
│   ├── active-days.json         ← Active day log
│   ├── last-review.txt          ← Last distillation timestamp
│   ├── dream-state.txt          ← System state
│   ├── removed-entries.json     ← Re-emergence tracking
│   └── review-YYYY-MM.md        ← Monthly distillation log
└── obsidian-index/
    ├── _index.md                ← Main content index
    └── topics/<topic>.md        ← Topic-based content index
```

---

## Installation

### Prerequisites

- OpenClaw (Node ≥ 22, Gateway port 18789)
- `jq` (JSON processing)
- `wc` (character count, built-in)
- `md5sum` or `md5` (hashing, built-in)

```bash
# macOS
brew install jq

# Linux
apt install jq
```

### Steps

```bash
# 1. Clone into OpenClaw skills directory
cd ~/.openclaw/workspace/skills
git clone https://github.com/teman2050/dream-skill dream

# 2. Make the script executable
chmod +x dream/dream-tools.sh

# 3. Set vault path (add to your shell config)
echo 'export DREAM_VAULT_PATH="$HOME/Documents/Obsidian/dream-vault"' >> ~/.zshrc
source ~/.zshrc

# 4. Initialize
./dream/dream-tools.sh --init

# 5. Restart OpenClaw gateway to load the skill
openclaw gateway restart
```

### Configure scheduled distillation

Add the following to the end of your `SOUL.md`:

```markdown
## Dream Schedule
Run dream review --scheduled every day at 03:30
```

---

## Usage

### Automatic (recommended)

No action needed after installation. Dream distills at 03:30 every night, runs silently, and never interrupts you.

### Manual trigger

Say any of the following in an OpenClaw conversation:

| You say | Dream does |
|---------|------------|
| `dream` / `review` | Run distillation immediately |
| `what do you remember about me` | Show current MEMORY.md snapshot |
| `dream status` | Show system status |
| `dream search <keyword>` | Search memory + permanent archive + Obsidian index |
| `dream index <content>` | Save an article or webpage to Obsidian index |
| `dream forget <description>` | Remove from memory (archive is kept) |
| `dream init` | First-time setup guide |

---

## What is `dream-tools.sh`

This is Dream's execution engine. **The AI handles semantic judgment; the script handles precise execution.**

File operations, date calculations, character counting, and atomic writes are delegated to the shell script rather than the AI — because AI miscalculates date arithmetic, atomic file replacement requires shell-level `mv`, and character counting needs `wc`, not estimation.

| Command | Purpose |
|---------|---------|
| `--check-idle` | Check if OpenClaw is idle (5s timeout, defaults to busy) |
| `--check-size` | Return MEMORY.md character count and compression state |
| `--hash` | Generate 8-char short hash for entry ID and dedup |
| `--atomic-write` | Atomically replace a file; validates char limit for MEMORY.md |
| `--ledger-append` | Append a record to the permanent archive |
| `--ledger-search` | Keyword search in the archive index |
| `--ledger-mark-reemergence` | Mark a ledger entry as re-emerged |
| `--record-removed` | Log a removed MEMORY.md entry for Re-emergence tracking |
| `--check-reemergence` | Detect if new content resembles a previously forgotten entry |
| `--record-active-day` | Record today as an active day (deduped) |
| `--active-days-since` | Count active days since a given date |
| `--dedup-index` | Check if a URL already exists in the Obsidian index |
| `--init` | Initialize Dream vault directory structure |
| `--status` | Print system status summary (low IO, meta-only reads) |

---

## MEMORY.md structure

Dream maintains MEMORY.md in four sections:

```markdown
## Current State
Ongoing projects, pending decisions, recent important changes

## Stable Profile
Tech stack, work environment, decision style, core preferences

## Relationships & Context
Important people, ongoing collaborations

## Dream
(Auto-maintained) Last 5 ledger entries as one-line summaries
```

---

## Relationship with other memory skills

Dream **does not conflict** with existing memory skills — it fills the gaps:

- **rosepuppy/memory-complete**: handles real-time capture, writes to `memory/YYYY-MM-DD.md`
  → Dream reads those files for distillation — complementary, can coexist

- **openclaw native memory search**: handles daily search
  → `dream search` calls the native command, does not build its own index

- **Using Dream standalone**: OpenClaw's native compaction flush already writes `memory/YYYY-MM-DD.md`, no additional skill needed

---

## Security

- All file operations are strictly scoped to `DREAM_VAULT_PATH` and the OpenClaw workspace
- `dream-tools.sh` makes no network calls and uses no external APIs
- `dream forget` only clears active memory; the permanent archive is unaffected
- Atomic writes (tmp → mv) prevent file corruption on crash

---

## License

MIT


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