找回密码
立即注册
搜索
发回帖 发新帖

5031

积分

0

好友

645

主题
发表于 2026-2-23 04:55:42 | 查看: 358| 回复: 0

最近不少用户在讨论使用MemOS这类付费插件来降低OpenClaw的Token消耗。但深入研究其源码后我发现,它的核心逻辑其实并不复杂,完全可以用一套纯本地的方案来实现,甚至效果更好。

本文将手把手教你搭建一个零费用、零云端依赖的记忆增强系统,全程在Cursor中操作,无需编码基础也能轻松搞定。

这个方案能帮你解决什么?

OpenClaw原生的记忆系统存在几个明显的痛点:

  • 全局记忆膨胀:MEMORY.md 文件会越来越大,每次对话都要加载全文,导致无效的Token消耗。
  • 记忆依赖模型自觉:Agent经常“忘记”主动记录关键信息。
  • 记忆召回不精准:要么把所有记忆都塞进上下文,要么完全找不到相关记忆。

我们的本地增强方案致力于解决这些问题:

  • 自动记录:每次对话结束后,自动将内容存入本地数据库,不依赖模型主动记忆。
  • 按需召回:对话开始前,自动搜索相关的近期对话、摘要和用户偏好,只注入必要的部分。
  • 偏好学习:通过夜间任务自动分析你的历史对话,提取并学习你的使用习惯和偏好。
  • 完全本地化:基于 SQLite 数据库和本地的 Ollama 模型,零云端依赖,零费用。

工作原理

整个系统的工作流清晰分为两条路径:

1. 实时路径 (每次对话触发)

用户发送消息
→ 触发 `before_agent_start` 钩子
→ 通过 QMD 搜索长期记忆(workspace/memory/ 下的文件)
→ 从 SQLite 搜索近期相关对话与摘要
→ 从 SQLite 读取用户偏好信息
→ 将所有信息拼装成 `prependContext`,注入到提示词前部
→ [AI Agent](https://yunpan.plus/f/29-1) 基于已有记忆上下文进行回复(无需再读取庞大的 memory 文件)
→ 触发 `agent_end` 钩子
→ 将本轮对话存储到 SQLite(毫秒级 INSERT 操作)

2. 夜间路径 (每日自动执行)

定时任务(Cron Job)触发
→ 读取当天所有未处理的对话记录
→ 发送给本地运行的 Ollama 模型进行分析
→ 提取用户偏好 → 写入 preferences 表
→ 生成对话摘要 → 写入 summaries 表
→ 标记这些对话为已处理

前置条件

开始之前,请确保你的环境已满足以下要求:

  • 已安装 OpenClaw(版本 2026.2.13 或更高)。
  • 已安装并配置好 QMD 插件。
  • 已安装 Ollama(用于夜间偏好提取),安装命令:brew install ollama。
  • 已在 Ollama 中拉取一个小模型,例如:ollama pull qwen3:8b。
  • 准备好 Cursor IDE(用于跟随教程操作)。

第一步:创建插件目录

打开 Cursor 的终端,运行以下命令创建必要的目录结构:

mkdir -p ~/.openclaw/extensions/local-memory-recall/lib
mkdir -p ~/.openclaw/extensions/local-memory-recall/scripts
mkdir -p ~/.openclaw/extensions/local-memory-recall/data

第二步:创建插件描述文件

在 Cursor 中新建文件,路径为:~/.openclaw/extensions/local-memory-recall/openclaw.plugin.json

将以下 JSON 配置内容粘贴进去:

{
  "id": "local-memory-recall",
  "name": "Local Memory Recall",
  "description": "Auto-capture conversations and recall relevant memories via local SQLite + QMD. Zero cloud dependencies, zero cost.",
  "version": "1.0.0",
  "kind": "lifecycle",
  "main": "./index.js",
  "configSchema": {
    "type": "object",
    "properties": {
      "agentIds": {
        "type": "array",
        "items": { "type": "string" },
        "description": "Only activate for these agent IDs. Empty = all agents."
      },
      "recallEnabled": { "type": "boolean", "default": true },
      "addEnabled": { "type": "boolean", "default": true },
      "memoryLimit": { "type": "integer", "default": 6 },
      "conversationLimit": { "type": "integer", "default": 5 },
      "preferenceLimit": { "type": "integer", "default": 6 },
      "maxDaysBack": { "type": "integer", "default": 30 },
      "captureStrategy": {
        "type": "string",
        "enum": ["last_turn", "full_session"],
        "default": "last_turn"
      },
      "maxMessageChars": { "type": "integer", "default": 20000 },
      "dbPath": { "type": "string" }
    },
    "additionalProperties": false
  }
}

第三步:创建 SQLite 存储层

新建文件,路径为:~/.openclaw/extensions/local-memory-recall/lib/local-store.js

这是核心的数据库操作层,负责所有数据的存储和检索,请完整粘贴以下代码:

import { DatabaseSync } from "node:sqlite";
import { mkdirSync, existsSync } from "node:fs";
import { dirname, join } from "node:path";

const DEFAULT_DB_DIR = join(dirname(new URL(import.meta.url).pathname), "..", "data");

export class LocalMemoryStore {
  #db;

  constructor(dbPath) {
    const resolvedPath = dbPath || join(DEFAULT_DB_DIR, "memories.db");
    const dir = dirname(resolvedPath);
    if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
    this.#db = new DatabaseSync(resolvedPath);
    this.#db.exec("PRAGMA journal_mode=WAL");
    this.#db.exec("PRAGMA busy_timeout=3000");
    this.#migrate();
  }

  #migrate() {
    this.#db.exec(`
      CREATE TABLE IF NOT EXISTS conversations (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        session_id TEXT,
        agent_id TEXT NOT NULL,
        role TEXT NOT NULL,
        content TEXT NOT NULL,
        created_at TEXT NOT NULL DEFAULT (datetime('now')),
        processed INTEGER NOT NULL DEFAULT 0
      );
      CREATE INDEX IF NOT EXISTS idx_conv_agent_created
        ON conversations(agent_id, created_at DESC);
      CREATE INDEX IF NOT EXISTS idx_conv_processed
        ON conversations(processed, created_at);
      CREATE TABLE IF NOT EXISTS preferences (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        agent_id TEXT NOT NULL,
        preference TEXT NOT NULL,
        type TEXT NOT NULL DEFAULT 'explicit',
        source_conversation_id INTEGER,
        created_at TEXT NOT NULL DEFAULT (datetime('now')),
        updated_at TEXT NOT NULL DEFAULT (datetime('now')),
        UNIQUE(agent_id, preference)
      );
      CREATE INDEX IF NOT EXISTS idx_pref_agent ON preferences(agent_id);
      CREATE TABLE IF NOT EXISTS summaries (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        conversation_group_id TEXT NOT NULL,
        agent_id TEXT NOT NULL,
        summary TEXT NOT NULL,
        created_at TEXT NOT NULL DEFAULT (datetime('now'))
      );
      CREATE INDEX IF NOT EXISTS idx_sum_agent_created
        ON summaries(agent_id, created_at DESC);
    `);
  }

  addConversation({ sessionId, agentId, role, content }) {
    this.#db.prepare(
      `INSERT INTO conversations (session_id, agent_id, role, content) VALUES (?, ?, ?, ?)`
    ).run(sessionId || "", agentId, role, content);
  }

  upsertPreference({ agentId, preference, type, sourceConversationId }) {
    this.#db.prepare(
      `INSERT INTO preferences (agent_id, preference, type, source_conversation_id)
       VALUES (?, ?, ?, ?)
       ON CONFLICT(agent_id, preference) DO UPDATE SET
         type = excluded.type, updated_at = datetime('now')`
    ).run(agentId, preference, type || "explicit", sourceConversationId || null);
  }

  addSummary({ conversationGroupId, agentId, summary }) {
    this.#db.prepare(
      `INSERT INTO summaries (conversation_group_id, agent_id, summary) VALUES (?, ?, ?)`
    ).run(conversationGroupId, agentId, summary);
  }

  markConversationsProcessed(ids) {
    if (!ids?.length) return;
    const placeholders = ids.map(() => "?").join(",");
    this.#db.prepare(
      `UPDATE conversations SET processed = 1 WHERE id IN (${placeholders})`
    ).run(...ids);
  }

  searchConversations({ agentId, query, limit = 5, maxDaysBack = 30 }) {
    const summaries = this.#searchSummaries({ agentId, query, limit, maxDaysBack });
    if (summaries.length >= limit) return summaries;
    const remaining = limit - summaries.length;
    const conversations = this.#searchRawConversations({ agentId, query, limit: remaining, maxDaysBack });
    return [...summaries, ...conversations];
  }

  #searchSummaries({ agentId, query, limit, maxDaysBack }) {
    const keywords = this.#extractKeywords(query);
    if (!keywords.length) {
      return this.#db.prepare(
        `SELECT summary AS content, 'summary' AS source, created_at FROM summaries
         WHERE agent_id = ? AND created_at > datetime('now', ?) ORDER BY created_at DESC LIMIT ?`
      ).all(agentId, `-${maxDaysBack} days`, limit);
    }
    const likeClause = keywords.map(() => "summary LIKE ?").join(" OR ");
    const likeParams = keywords.map((k) => `%${k}%`);
    return this.#db.prepare(
      `SELECT summary AS content, 'summary' AS source, created_at FROM summaries
       WHERE agent_id = ? AND created_at > datetime('now', ?) AND (${likeClause})
       ORDER BY created_at DESC LIMIT ?`
    ).all(agentId, `-${maxDaysBack} days`, ...likeParams, limit);
  }

  #searchRawConversations({ agentId, query, limit, maxDaysBack }) {
    const keywords = this.#extractKeywords(query);
    if (!keywords.length) {
      return this.#db.prepare(
        `SELECT role, content, 'conversation' AS source, created_at FROM conversations
         WHERE agent_id = ? AND created_at > datetime('now', ?) ORDER BY created_at DESC LIMIT ?`
      ).all(agentId, `-${maxDaysBack} days`, limit);
    }
    const likeClause = keywords.map(() => "content LIKE ?").join(" OR ");
    const likeParams = keywords.map((k) => `%${k}%`);
    return this.#db.prepare(
      `SELECT role, content, 'conversation' AS source, created_at FROM conversations
       WHERE agent_id = ? AND created_at > datetime('now', ?) AND (${likeClause})
       ORDER BY created_at DESC LIMIT ?`
    ).all(agentId, `-${maxDaysBack} days`, ...likeParams, limit);
  }

  getPreferences({ agentId, limit = 6 }) {
    return this.#db.prepare(
      `SELECT preference, type, updated_at FROM preferences
       WHERE agent_id = ? ORDER BY updated_at DESC LIMIT ?`
    ).all(agentId, limit);
  }

  getUnprocessedConversations({ limit = 200 } = {}) {
    return this.#db.prepare(
      `SELECT id, session_id, agent_id, role, content, created_at FROM conversations
       WHERE processed = 0 ORDER BY created_at ASC LIMIT ?`
    ).all(limit);
  }

  cleanup() {
    this.#db.exec(`DELETE FROM conversations WHERE created_at < datetime('now', '-30 days')`);
    this.#db.exec(`DELETE FROM summaries WHERE created_at < datetime('now', '-90 days')`);
  }

  close() { this.#db.close(); }

  #extractKeywords(query) {
    if (!query) return [];
    return query.replace(/[^\p{L}\p{N}\s]/gu, " ").split(/\s+/).filter((w) => w.length >= 2).slice(0, 8);
  }
}

第四步:创建主插件文件

新建文件,路径为:~/.openclaw/extensions/local-memory-recall/index.js

这是插件的主逻辑文件,负责钩子事件的监听与处理,请完整粘贴以下代码:

import { LocalMemoryStore } from "./lib/local-store.js";

const PLUGIN_TAG = "[local-memory-recall]";

function truncate(text, maxLen) {
  if (!text || !maxLen) return text || "";
  return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text;
}

function extractText(content) {
  if (!content) return "";
  if (typeof content === "string") return content;
  if (Array.isArray(content)) {
    return content.filter((b) => b?.type === "text").map((b) => b.text).join(" ");
  }
  return "";
}

function pad2(v) { return String(v).padStart(2, "0"); }

function formatTime(ts) {
  const d = ts ? new Date(ts) : new Date();
  return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
}

function pickLastTurnMessages(messages, maxChars) {
  const lastUserIdx = messages.map((m, i) => ({ m, i })).filter(({ m }) => m?.role === "user").map(({ i }) => i).pop();
  if (lastUserIdx === undefined) return [];
  const results = [];
  for (const msg of messages.slice(lastUserIdx)) {
    if (!msg?.role) continue;
    const content = extractText(msg.content);
    if (!content) continue;
    if (msg.role === "user" || msg.role === "assistant") {
      results.push({ role: msg.role, content: truncate(content, maxChars) });
    }
  }
  return results;
}

function buildPrependContext({ qmdResults, conversations, preferences, nowText }) {
  const lines = [];
  lines.push("# Role", "",
    "You are an intelligent assistant with long-term memory. " +
    "Use the retrieved memory fragments below to provide personalized, accurate responses.", "");
  lines.push("# System Context", "", `* Current Time: ${nowText}`, "");
  lines.push("# Memory Data", "");
  if (qmdResults?.length) {
    lines.push("## Long-term Memory (from knowledge base)", "```text", "<facts>");
    for (const item of qmdResults) {
      const snippet = item.snippet || item.content || "";
      if (snippet) lines.push(`  - ${snippet.replace(/\n/g, " ").trim()}`);
    }
    lines.push("</facts>", "```", "");
  }
  if (conversations?.length) {
    lines.push("## Recent Conversations", "```text", "<recent_context>");
    for (const item of conversations) {
      const time = item.created_at || "";
      if (item.source === "summary") {
        lines.push(`  - [${time}] (summary) ${item.content}`);
      } else {
        lines.push(`  - [${time}] [${item.role}] ${truncate(item.content, 500)}`);
      }
    }
    lines.push("</recent_context>", "```", "");
  }
  if (preferences?.length) {
    lines.push("## User Preferences", "```text", "<preferences>");
    for (const pref of preferences) {
      const typeLabel = pref.type === "implicit" ? "[Implicit]" : "[Explicit]";
      lines.push(`  - ${typeLabel} ${pref.preference}`);
    }
    lines.push("</preferences>", "```", "");
  }
  lines.push("# Memory Safety Protocol", "",
    "Before using any memory above, apply these checks:",
    "1. **Source**: Is this a direct user statement or AI inference?",
    "2. **Attribution**: Is the subject definitely the user?",
    "3. **Relevance**: Does this directly help answer the current query?",
    "4. **Freshness**: If memory conflicts with current intent, prioritize the current query.", "");
  lines.push("# Attention", "",
    "Relevant memory context is already provided above. " +
    "Do NOT read from or write to local `MEMORY.md` or `memory/*` files for reference — " +
    "they may be outdated or redundant with the injected context. " +
    "Focus on the user's current query.", "");
  return lines.join("\n");
}

export default {
  id: "local-memory-recall",
  name: "Local Memory Recall",
  description: "Auto-capture conversations and recall relevant memories via local SQLite + QMD.",
  kind: "lifecycle",
  register(api) {
    const cfg = api.pluginConfig || {};
    const log = api.logger ?? console;
    const agentIds = cfg.agentIds || [];
    const recallEnabled = cfg.recallEnabled !== false;
    const addEnabled = cfg.addEnabled !== false;
    const memoryLimit = cfg.memoryLimit ?? 6;
    const conversationLimit = cfg.conversationLimit ?? 5;
    const preferenceLimit = cfg.preferenceLimit ?? 6;
    const maxDaysBack = cfg.maxDaysBack ?? 30;
    const captureStrategy = cfg.captureStrategy ?? "last_turn";
    const maxMessageChars = cfg.maxMessageChars ?? 20000;
    let store;
    try {
      store = new LocalMemoryStore(cfg.dbPath);
      log.info?.(`${PLUGIN_TAG} SQLite store initialized`);
    } catch (err) {
      log.error?.(`${PLUGIN_TAG} Failed to init SQLite: ${err}`);
      return;
    }
    const isAgentAllowed = (ctx) => {
      if (!agentIds.length) return true;
      return agentIds.includes(ctx?.agentId);
    };
    api.on("before_agent_start", async (event, ctx) => {
      if (!recallEnabled || !isAgentAllowed(ctx)) return;
      if (!event?.prompt || event.prompt.length < 3) return;
      try {
        const agentId = ctx?.agentId || "unknown";
        const nowText = formatTime();
        let qmdResults = [];
        try {
          if (api.runtime?.tools?.memory_search) {
            const r = await api.runtime.tools.memory_search({ query: event.prompt, limit: memoryLimit });
            if (r?.results) qmdResults = r.results;
          }
        } catch (e) { log.warn?.(`${PLUGIN_TAG} QMD search failed: ${e}`); }
        const conversations = store.searchConversations({ agentId, query: event.prompt, limit: conversationLimit, maxDaysBack });
        const preferences = store.getPreferences({ agentId, limit: preferenceLimit });
        if (!qmdResults.length && !conversations.length && !preferences.length) return;
        return { prependContext: buildPrependContext({ qmdResults, conversations, preferences, nowText }) };
      } catch (err) { log.warn?.(`${PLUGIN_TAG} recall failed: ${err}`); }
    });
    api.on("agent_end", async (event, ctx) => {
      if (!addEnabled || !isAgentAllowed(ctx)) return;
      if (!event?.success || !event?.messages?.length) return;
      try {
        const agentId = ctx?.agentId || "unknown";
        const sessionId = ctx?.sessionKey || ctx?.sessionId || "";
        const messages = pickLastTurnMessages(event.messages, maxMessageChars);
        for (const msg of messages) {
          store.addConversation({ sessionId, agentId, role: msg.role, content: msg.content });
        }
      } catch (err) { log.warn?.(`${PLUGIN_TAG} capture failed: ${err}`); }
    });
    try { store.cleanup(); log.info?.(`${PLUGIN_TAG} Cleanup completed`); }
    catch (err) { log.warn?.(`${PLUGIN_TAG} Cleanup failed: ${err}`); }
  },
};

第五步:创建夜间处理脚本

新建文件,路径为:~/.openclaw/extensions/local-memory-recall/scripts/nightly-process.sh

这个脚本负责在夜间调用 Ollama 处理未完成的对话,提取偏好和摘要,请完整粘贴以下内容:

#!/bin/bash
# 每晚自动提取偏好 + 生成对话摘要
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DB_PATH="${SCRIPT_DIR}/../data/memories.db"
OLLAMA_URL="${OLLAMA_URL:-http://localhost:11434/api/generate}"
OLLAMA_MODEL="${OLLAMA_MODEL:-qwen3:8b}"
LOG_PREFIX="[nightly-process]"
log() { echo "${LOG_PREFIX}$(date '+%H:%M:%S') $*"; }
for cmd in sqlite3 curl jq; do
  command -v "$cmd" &>/dev/null || { log "ERROR: $cmd not found"; exit 1; }
done
[ -f "$DB_PATH" ] || { log "ERROR: DB not found at $DB_PATH"; exit 1; }
curl -s --max-time 5 http://localhost:11434/api/tags >/dev/null 2>&1 || { log "ERROR: Ollama not running"; exit 1; }
log "Starting..."
# 1. 先只读 ID 列表(安全,不解析内容)
IDS=$(sqlite3 "$DB_PATH" "
  SELECT GROUP_CONCAT(id) FROM (
    SELECT id FROM conversations
    WHERE processed = 0
    ORDER BY created_at ASC
    LIMIT 200
  );" 2>/dev/null || echo "")
[ -z "$IDS" ] && { log "No unprocessed conversations. Done."; exit 0; }
CONV_COUNT=$(echo "$IDS" | tr ',' '\n' | wc -l | tr -d ' ')
log "Found $CONV_COUNT entries."
# 2. 用临时文件构建对话文本(避免 shell 转义问题)
TMPFILE=$(mktemp)
SQLFILE=$(mktemp)
trap 'rm -f "$TMPFILE" "$SQLFILE"' EXIT
sqlite3 "$DB_PATH" "
  SELECT '[' || created_at || '] [' || agent_id || '] [' || role || ']: ' ||
         SUBSTR(REPLACE(content, CHAR(10), ' '), 1, 500)
  FROM conversations
  WHERE id IN ($IDS)
  ORDER BY created_at ASC;
" > "$TMPFILE" 2>/dev/null
CONV_TEXT=$(cat "$TMPFILE")
# 3. 调用 Ollama
PROMPT="Analyze these conversations. Output JSON with two fields:
1. \"preferences\": [{\"preference\": \"...\", \"type\": \"explicit|implicit\", \"agent_id\": \"...\"}]
2. \"summaries\": [{\"agent_id\": \"...\", \"summary\": \"...\", \"group_id\": \"YYYY-MM-DD-topic\"}]
Rules: Only extract REAL preferences. Summaries should be 1-2 sentences. Output ONLY valid JSON.
Conversations:
---
${CONV_TEXT}
---"
log "Calling Ollama ($OLLAMA_MODEL)..."
RESPONSE=$(curl -s --max-time 120 "$OLLAMA_URL" \
  -d "$(jq -n --arg model "$OLLAMA_MODEL" --arg prompt "$PROMPT" \
    '{model: $model, prompt: $prompt, stream: false, options: {temperature: 0.1, num_predict: 4096}}')" 2>/dev/null || echo "")
RESULT=$(echo "$RESPONSE" | jq -r '.response // empty' 2>/dev/null || echo "")
[ -z "$RESULT" ] && { log "ERROR: Empty Ollama response"; exit 1; }
CLEAN_JSON=$(echo "$RESULT" | sed 's/^```json//; s/^```//; s/```$//' | tr -d '\r')
if ! echo "$CLEAN_JSON" | jq empty 2>/dev/null; then
  log "WARNING: Invalid JSON from Ollama. Marking as processed anyway."
  sqlite3 "$DB_PATH" "UPDATE conversations SET processed = 1 WHERE id IN ($IDS);"
  exit 0
fi
# 4. 写入偏好(通过临时 SQL 文件,避免特殊字符问题)
PREF_COUNT=$(echo "$CLEAN_JSON" | jq '.preferences | length' 2>/dev/null || echo "0")
SUM_COUNT=$(echo "$CLEAN_JSON" | jq '.summaries | length' 2>/dev/null || echo "0")
if [ "$PREF_COUNT" -gt 0 ]; then
  > "$SQLFILE"
  echo "$CLEAN_JSON" | jq -c '.preferences[]' 2>/dev/null | while read -r pref; do
    AGENT=$(echo "$pref" | jq -r '.agent_id // "unknown"' | sed "s/'/''/g")
    PTEXT=$(echo "$pref" | jq -r '.preference // empty' | sed "s/'/''/g")
    PTYPE=$(echo "$pref" | jq -r '.type // "explicit"' | sed "s/'/''/g")
    [ -n "$PTEXT" ] && echo "INSERT INTO preferences (agent_id, preference, type) VALUES ('$AGENT', '$PTEXT', '$PTYPE') ON CONFLICT(agent_id, preference) DO UPDATE SET type=excluded.type, updated_at=datetime('now');" >> "$SQLFILE"
  done
  [ -s "$SQLFILE" ] && sqlite3 "$DB_PATH" < "$SQLFILE" 2>/dev/null || log "WARNING: Some preference inserts failed"
  log "Extracted $PREF_COUNT preferences."
else
  log "No preferences found."
fi
# 5. 写入摘要
if [ "$SUM_COUNT" -gt 0 ]; then
  > "$SQLFILE"
  echo "$CLEAN_JSON" | jq -c '.summaries[]' 2>/dev/null | while read -r summ; do
    AGENT=$(echo "$summ" | jq -r '.agent_id // "unknown"' | sed "s/'/''/g")
    STEXT=$(echo "$summ" | jq -r '.summary // empty' | sed "s/'/''/g")
    GID=$(echo "$summ" | jq -r '.group_id // "unknown"' | sed "s/'/''/g")
    [ -n "$STEXT" ] && echo "INSERT INTO summaries (conversation_group_id, agent_id, summary) VALUES ('$GID', '$AGENT', '$STEXT');" >> "$SQLFILE"
  done
  [ -s "$SQLFILE" ] && sqlite3 "$DB_PATH" < "$SQLFILE" 2>/dev/null || log "WARNING: Some summary inserts failed"
  log "Generated $SUM_COUNT summaries."
else
  log "No summaries generated."
fi
# 6. 标记已处理 + 清理
sqlite3 "$DB_PATH" "UPDATE conversations SET processed = 1 WHERE id IN ($IDS);" 2>/dev/null
log "Marked $CONV_COUNT conversations as processed."
sqlite3 "$DB_PATH" "DELETE FROM conversations WHERE created_at < datetime('now', '-30 days');" 2>/dev/null
sqlite3 "$DB_PATH" "DELETE FROM summaries WHERE created_at < datetime('now', '-90 days');" 2>/dev/null
log "Done. Preferences: $PREF_COUNT, Summaries: $SUM_COUNT, Processed: $CONV_COUNT"

然后在终端中,给脚本添加执行权限:

chmod +x ~/.openclaw/extensions/local-memory-recall/scripts/nightly-process.sh

第六步:配置 OpenClaw

编辑 ~/.openclaw/openclaw.json 配置文件,找到 plugins.entries 部分,加入以下配置项:

"local-memory-recall": {
  "enabled": true,
  "config": {
    "agentIds": [],
    "recallEnabled": true,
    "addEnabled": true,
    "memoryLimit": 6,
    "conversationLimit": 5,
    "preferenceLimit": 6,
    "maxDaysBack": 30,
    "captureStrategy": "last_turn"
  }
}

说明:agentIds 设为空数组 [] 表示对所有 Agent 生效。如果只想对特定 Agent(例如 muse)生效,则写为 ["muse"]。

第七步:配置夜间定时任务

编辑 ~/.openclaw/cron/jobs.json 文件,在 jobs 数组的最后一个 } 后面加一个逗号,然后粘贴以下任务配置:

{
  "id": "nightly-memory-process",
  "agentId": "sentinel",
  "name": "Nightly Memory Process",
  "enabled": true,
  "createdAtMs": 1771113600000,
  "updatedAtMs": 1771113600000,
  "schedule": {
    "kind": "cron",
    "expr": "27 3 * * *"
  },
  "sessionTarget": "isolated",
  "wakeMode": "now",
  "payload": {
    "kind": "agentTurn",
    "model": "ollama/qwen3:8b",
    "thinking": "off",
    "message": "Run: bash ~/.openclaw/extensions/local-memory-recall/scripts/nightly-process.sh\nReport results briefly. File output only."
  },
  "delivery": { "mode": "none" },
  "state": {}
}

注意:"expr": "27 3 * * *" 表示每天凌晨 3:27 执行。你可以根据需要修改为非整点时间以避免拥挤。
如果你没有 sentinel 这个 Agent,请将 agentId 修改为你存在的主 Agent ID(例如 "main")。

第八步:重启 Gateway 并验证

在终端中运行以下命令重启 OpenClaw Gateway:

openclaw gateway restart

然后验证插件是否加载成功:

openclaw plugins list

你应该能看到类似这样的输出,表明插件已成功加载:

Local Memory Recall | local-memory-recall | loaded

同时,检查 OpenClaw 的日志,应该能看到插件初始化的信息:

[local-memory-recall] SQLite store initialized
[local-memory-recall] Cleanup completed

第九步:功能验证

现在,给你的任意一个 Agent 发送一条消息。然后,在终端中检查数据库,确认对话已被记录:

sqlite3 ~/.openclaw/extensions/local-memory-recall/data/memories.db "SELECT id, agent_id, role, substr(content, 1, 50), created_at FROM conversations ORDER BY id DESC LIMIT 5;"

如果能看到你刚才的对话记录,说明整个实时记录和召回链路工作正常。


第十步:可视化记忆面板(可选)

如果你想更直观地查看已提取的偏好、摘要和对话记录,可以搭建一个本地可视化面板。

10.1 创建数据导出脚本

在 extensions/local-memory-recall/scripts/ 目录下创建 export-dashboard.sh 文件,并赋予执行权限。该脚本会将数据库中的数据导出为前端面板可读取的 JSON 文件。

#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DB_PATH="${SCRIPT_DIR}/../data/memories.db"
OUTPUT_DIR="${1:-$HOME/.openclaw/canvas/memory-data}"
if [ ! -f "$DB_PATH" ]; then
  echo "ERROR: Database not found at $DB_PATH"
  exit 1
fi
mkdir -p "$OUTPUT_DIR"
# 统计数据
sqlite3 "$DB_PATH" "
SELECT json_object(
  'total_conversations', (SELECT COUNT(*) FROM conversations),
  'processed_conversations', (SELECT COUNT(*) FROM conversations WHERE processed = 1),
  'unprocessed_conversations', (SELECT COUNT(*) FROM conversations WHERE processed = 0),
  'total_preferences', (SELECT COUNT(*) FROM preferences),
  'total_summaries', (SELECT COUNT(*) FROM summaries),
  'agents', (SELECT json_group_array(DISTINCT agent_id) FROM conversations),
  'earliest_conversation', (SELECT MIN(created_at) FROM conversations),
  'latest_conversation', (SELECT MAX(created_at) FROM conversations),
  'exported_at', datetime('now')
);
" > "$OUTPUT_DIR/stats.json"
# 偏好
sqlite3 "$DB_PATH" "
SELECT json_group_array(json_object(
  'id', id, 'agent_id', agent_id, 'preference', preference,
  'type', type, 'updated_at', updated_at
)) FROM preferences ORDER BY updated_at DESC;
" > "$OUTPUT_DIR/preferences.json"
# 摘要(最近 50 条)
sqlite3 "$DB_PATH" "
SELECT json_group_array(json_object(
  'id', id, 'agent_id', agent_id, 'summary', summary, 'created_at', created_at
)) FROM (SELECT * FROM summaries ORDER BY created_at DESC LIMIT 50);
" > "$OUTPUT_DIR/summaries.json"
# 对话(最近 100 条,内容截断 500 字)
sqlite3 "$DB_PATH" "
SELECT json_group_array(json_object(
  'id', id, 'session_id', session_id, 'agent_id', agent_id,
  'role', role, 'content', SUBSTR(content, 1, 500),
  'created_at', created_at, 'processed', processed
)) FROM (SELECT * FROM conversations ORDER BY created_at DESC LIMIT 100);
" > "$OUTPUT_DIR/conversations.json"
# 每日活动
sqlite3 "$DB_PATH" "
SELECT json_group_array(json_object(
  'date', date, 'agent_id', agent_id, 'count', cnt
)) FROM (
  SELECT DATE(created_at) AS date, agent_id, COUNT(*) AS cnt
  FROM conversations GROUP BY date, agent_id ORDER BY date DESC LIMIT 90
);
" > "$OUTPUT_DIR/activity.json"
echo "导出完成!"

设置权限:

chmod +x ~/.openclaw/extensions/local-memory-recall/scripts/export-dashboard.sh

10.2 放置面板 HTML 文件

在 ~/.openclaw/canvas/ 目录下创建一个名为 memory-dashboard.html 的静态 HTML 文件。这个页面会读取上一步导出的 JSON 文件,以图表和列表的形式展示数据。页面包含顶部统计卡片、每日活动柱状图、以及偏好、摘要、对话记录三个标签页。

OpenClaw本地记忆增强方案记忆面板界面

记忆面板展示总对话数、偏好、摘要统计及每日活动趋势

由于面板文件较长(约700行HTML/CSS/JS),建议从相关仓库复制或使用 Cursor 等工具辅助生成。

10.3 使用方法

  1. 导出数据:运行导出脚本。
    bash ~/.openclaw/extensions/local-memory-recall/scripts/export-dashboard.sh
  2. 启动本地 HTTP 服务:在 Canvas 目录下启动一个简单的 Python HTTP 服务器。
    cd ~/.openclaw/canvas && python3 -m http.server 18794 --bind 0.0.0.0
  3. 访问面板:
    • 本机访问:http://127.0.0.1:18794/memory-dashboard.html
    • 局域网访问:http://<你的本机IP>:18794/memory-dashboard.html

10.4 自动刷新数据

如果你已经配置了第七步的夜间 Cron Job,可以在该任务的 message 字段中追加一行命令,这样每晚处理完数据后,面板的数据也会自动更新。

bash ~/.openclaw/extensions/local-memory-recall/scripts/export-dashboard.sh

方案对比:MemOS vs 本地方案

能力 MemOS Cloud(付费) 本方案(免费)
自动记录对话 云端存储 本地 SQLite
语义搜索 云端 embedding 复用 OpenClaw QMD
偏好提取 实时云端 AI 夜间 Ollama 批处理
对话摘要 无 有(夜间自动生成)
Context 预筛选 无 有(减少重复工具调用)
数据隐私 数据在第三方 全部本地
费用 收费 零
多 Agent 共享 同一账号 同一 SQLite 数据库

常见问题 (FAQ)

Q: 插件加载失败怎么办?
A: 请检查 Node.js 版本是否 >= 22(node:sqlite 模块需要)。在终端运行 node -v 确认。

Q: 夜间定时任务没有执行?
A: 首先确认 Ollama 服务正在运行(ollama serve),并且已成功拉取所需模型(例如 ollama pull qwen3:8b)。

Q: 如何查看已提取的用户偏好?
A: 可以直接查询数据库:

sqlite3 ~/.openclaw/extensions/local-memory-recall/data/memories.db "SELECT * FROM preferences ORDER BY updated_at DESC;"

Q: 如何手动触发夜间处理任务?
A: 直接运行处理脚本即可:

bash ~/.openclaw/extensions/local-memory-recall/scripts/nightly-process.sh

Q: 如何对所有 Agent 启用此插件?
A: 在第六步的配置中,确保 "agentIds": [] 设置为空数组。

Q: 数据库文件越来越大怎么办?
A: 系统已内置清理机制:conversations 表自动保留30天,summaries 表保留90天,preferences 永久保留。你也可以手动清理,例如只保留最近7天的对话:

sqlite3 ~/.openclaw/extensions/local-memory-recall/data/memories.db "DELETE FROM conversations WHERE created_at < datetime('now', '-7 days');"

这套完整的本地记忆增强方案,无需任何云端开销,就能显著提升 OpenClaw 的对话连贯性和个性化程度。如果你在部署过程中遇到任何问题,或是有更好的改进想法,欢迎在云栈社区的技术论坛与其他开发者交流讨论。




上一篇:C++继承体系中默认operator==引发的无限递归问题解析
下一篇:C++未定义行为排查:金融系统中,为何测试环境的“奇数持仓”数据让我白忙三天?
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-10-13 23:29 , Processed in 0.100365 second(s), 42 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表