Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ export default {
id: "memos-cloud-openclaw-plugin",
name: "MemOS Cloud OpenClaw Plugin",
description: "MemOS Cloud recall + add memory via lifecycle hooks",
kind: "lifecycle",
kind: "memory",

register(api) {
const cfg = buildConfig(api.pluginConfig);
Expand Down Expand Up @@ -427,7 +427,8 @@ export default {
api.on("before_agent_start", async (event, ctx) => {
if (!cfg.recallEnabled) return;
if (!event?.prompt || event.prompt.length < 3) return;
if (!cfg.apiKey) {
// Self-hosted mode doesn't require API key
if (!cfg.apiKey && cfg.serverMode !== "self-hosted") {
warnMissingApiKey(log, "recall");
return;
}
Expand All @@ -454,7 +455,8 @@ export default {
api.on("agent_end", async (event, ctx) => {
if (!cfg.addEnabled) return;
if (!event?.success || !event?.messages?.length) return;
if (!cfg.apiKey) {
// Self-hosted mode doesn't require API key
if (!cfg.apiKey && cfg.serverMode !== "self-hosted") {
warnMissingApiKey(log, "add");
return;
}
Expand Down
157 changes: 135 additions & 22 deletions lib/memos-cloud-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ function stripQuotes(value) {

export function extractResultData(result) {
if (!result || typeof result !== "object") return null;
return result.data ?? result.data?.data ?? result.data?.result ?? null;
// Support both cloud and self-hosted response formats
return result.data ?? result.data?.data ?? result.data?.result ?? result;
}

function pad2(value) {
Expand Down Expand Up @@ -126,13 +127,45 @@ function parseNumber(value, fallback) {
return Number.isFinite(n) ? n : fallback;
}

/**
* Detect server mode based on baseUrl
* - cloud: official MemOS cloud service
* - self-hosted: local or custom MemOS server
*/
function detectServerMode(baseUrl, explicitMode) {
if (explicitMode) return explicitMode;

// Check if explicitly set to cloud or self-hosted
const envMode = loadEnvVar("MEMOS_SERVER_MODE");
if (envMode === "self-hosted" || envMode === "cloud") return envMode;

// Auto-detect: if URL is localhost, 127.0.0.1, or not the official cloud URL, treat as self-hosted
const isLocalhost = baseUrl.includes("localhost") ||
baseUrl.includes("127.0.0.1") ||
baseUrl.includes("192.168.") ||
baseUrl.includes("10.") ||
baseUrl.includes("172.");

const isOfficialCloud = baseUrl.includes("memtensor.cn") ||
baseUrl.includes("memos.openmem.net");

if (isLocalhost || !isOfficialCloud) {
return "self-hosted";
}

return "cloud";
}

export function buildConfig(pluginConfig = {}) {
const cfg = pluginConfig ?? {};

const baseUrl = cfg.baseUrl || loadEnvVar("MEMOS_BASE_URL") || DEFAULT_BASE_URL;
const apiKey = cfg.apiKey || loadEnvVar("MEMOS_API_KEY") || "";
const userId = cfg.userId || loadEnvVar("MEMOS_USER_ID") || "openclaw-user";
const conversationId = cfg.conversationId || loadEnvVar("MEMOS_CONVERSATION_ID") || "";

// NEW: server mode detection
const serverMode = detectServerMode(baseUrl, cfg.serverMode);

const recallGlobal = parseBool(
cfg.recallGlobal,
Expand Down Expand Up @@ -200,6 +233,8 @@ export function buildConfig(pluginConfig = {}) {
allowKnowledgebaseIds: cfg.allowKnowledgebaseIds ?? [],
asyncMode: cfg.asyncMode ?? true,
multiAgentMode,
// NEW: server mode
serverMode,
recallFilterEnabled,
recallFilterBaseUrl:
(cfg.recallFilterBaseUrl ?? loadEnvVar("MEMOS_RECALL_FILTER_BASE_URL") ?? "").replace(/\/+$/, ""),
Expand All @@ -221,16 +256,33 @@ export function buildConfig(pluginConfig = {}) {
};
}

export async function callApi({ baseUrl, apiKey, timeoutMs = 5000, retries = 1 }, path, body) {
if (!apiKey) {
throw new Error("Missing MEMOS API key (Token auth)");
/**
* Call API with support for both cloud and self-hosted modes
* - cloud mode: requires API key, uses /search/memory and /add/message
* - self-hosted mode: no API key required, uses /product/search and /product/add
*/
export async function callApi(cfg, path, body) {
const { baseUrl, apiKey, timeoutMs = 5000, retries = 1, serverMode } = cfg;

// Self-hosted mode: skip API key check
if (serverMode === "self-hosted") {
// No auth required for self-hosted
} else {
// Cloud mode: require API key
if (!apiKey) {
throw new Error("Missing MEMOS API key (Token auth). Set MEMOS_API_KEY or configure apiKey in plugin config.");
}
}

const headers = {
"Content-Type": "application/json",
Authorization: `Token ${apiKey}`,
};

// Add auth header only for cloud mode
if (serverMode !== "self-hosted" && apiKey) {
headers.Authorization = `Token ${apiKey}`;
}

let lastError;
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
Expand Down Expand Up @@ -262,12 +314,50 @@ export async function callApi({ baseUrl, apiKey, timeoutMs = 5000, retries = 1 }
throw lastError;
}

/**
* Search memory - uses different endpoints based on server mode
* - cloud: /search/memory
* - self-hosted: /product/search
*/
export async function searchMemory(cfg, payload) {
return callApi(cfg, "/search/memory", payload);
const endpoint = cfg.serverMode === "self-hosted" ? "/product/search" : "/search/memory";

// Transform payload for self-hosted mode
if (cfg.serverMode === "self-hosted") {
const selfHostedPayload = {
query: payload.query,
user_id: payload.user_id,
mem_cube_id: payload.conversation_id || "main",
};
return callApi(cfg, endpoint, selfHostedPayload);
}

return callApi(cfg, endpoint, payload);
}

/**
* Add message - uses different endpoints based on server mode
* - cloud: /add/message
* - self-hosted: /product/add
*/
export async function addMessage(cfg, payload) {
return callApi(cfg, "/add/message", payload);
const endpoint = cfg.serverMode === "self-hosted" ? "/product/add" : "/add/message";

// Transform payload for self-hosted mode
if (cfg.serverMode === "self-hosted") {
const selfHostedPayload = {
user_id: payload.user_id,
mem_cube_id: payload.conversation_id || "main",
messages: payload.messages,
async_mode: cfg.asyncMode ? "async" : "sync",
};
if (payload.agent_id) {
selfHostedPayload.agent_id = payload.agent_id;
}
return callApi(cfg, endpoint, selfHostedPayload);
}

return callApi(cfg, endpoint, payload);
}

export function extractText(content) {
Expand Down Expand Up @@ -303,7 +393,7 @@ function formatMemoryLine(item, text, options = {}) {
if (!cleaned) return "";
const maxChars = options.maxItemChars;
const truncated = truncate(cleaned, maxChars);
const time = formatTime(item?.create_time);
const time = formatTime(item?.create_time || item?.updated_at || item?.created_at);
if (time) return ` -[${time}] ${truncated}`;
return ` - ${truncated}`;
}
Expand All @@ -313,7 +403,7 @@ function formatPreferenceLine(item, text, options = {}) {
if (!cleaned) return "";
const maxChars = options.maxItemChars;
const truncated = truncate(cleaned, maxChars);
const time = formatTime(item?.create_time);
const time = formatTime(item?.create_time || item?.updated_at || item?.created_at);
const type = normalizePreferenceType(item?.preference_type);
const typeLabel = type ? ` [${type}]` : "";
if (time) return ` -[${time}]${typeLabel} ${truncated}`;
Expand All @@ -325,21 +415,35 @@ function wrapCodeBlock(lines, options = {}) {
return ["```text", ...lines, "```"];
}

/**
* Build memory sections - supports both cloud and self-hosted response formats
*/
function buildMemorySections(data, options = {}) {
// Cloud format
const memoryList = data?.memory_detail_list ?? [];
const preferenceList = data?.preference_detail_list ?? [];

const memoryLines = memoryList
.filter((item) => {
const score = item?.relativity ?? 1;
const threshold = options.relativity ?? 0;
return score > threshold;
})
.map((item) => {
const text = item?.memory_value || item?.memory_key || "";
return formatMemoryLine(item, text, options);
})
.filter(Boolean);

// Self-hosted format
const textMems = data?.text_mem?.[0]?.memories ?? [];

const memoryLines = [];

// Process cloud format memories
for (const item of memoryList) {
const score = item?.relativity ?? 1;
const threshold = options.relativity ?? 0;
if (score <= threshold) continue;
const text = item?.memory_value || item?.memory_key || "";
if (!text) continue;
memoryLines.push(formatMemoryLine(item, text, options));
}

// Process self-hosted format memories
for (const item of textMems) {
const text = item?.memory || "";
if (!text) continue;
memoryLines.push(formatMemoryLine(item, text, options));
}

const preferenceLines = preferenceList
.filter((item) => {
Expand Down Expand Up @@ -441,15 +545,24 @@ export function formatContextBlock(result, options = {}) {
const prefList = data.preference_detail_list ?? [];
const toolList = data.tool_memory_detail_list ?? [];
const preferenceNote = data.preference_note;

// Self-hosted format
const textMems = data?.text_mem?.[0]?.memories ?? [];

const lines = [];
if (memoryList.length > 0) {

if (memoryList.length > 0 || textMems.length > 0) {
lines.push("Facts:");
for (const item of memoryList) {
const text = item?.memory_value || item?.memory_key || "";
if (!text) continue;
lines.push(`- ${truncate(text, options.maxItemChars)}`);
}
for (const item of textMems) {
const text = item?.memory || "";
if (!text) continue;
lines.push(`- ${truncate(text, options.maxItemChars)}`);
}
}

if (prefList.length > 0) {
Expand Down
2 changes: 1 addition & 1 deletion openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "MemOS Cloud OpenClaw Plugin",
"description": "MemOS Cloud recall + add memory via lifecycle hooks",
"version": "0.1.9",
"kind": "lifecycle",
"kind": "memory",
"main": "./index.js",
"configSchema": {
"type": "object",
Expand Down