@evermind-ai/openclaw-plugin 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +400 -0
- package/README.zh.md +400 -0
- package/index.js +397 -0
- package/openclaw.plugin.json +50 -0
- package/package.json +40 -0
- package/src/assembler.js +96 -0
- package/src/compaction.js +85 -0
- package/src/config.js +14 -0
- package/src/context-engine.js +283 -0
- package/src/formatter.js +152 -0
- package/src/http-client.js +46 -0
- package/src/lifecycle.js +65 -0
- package/src/memory-api.js +77 -0
- package/src/message-utils.js +163 -0
- package/src/subagent.js +116 -0
- package/src/types.js +107 -0
package/index.js
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EverMemOS ContextEngine Plugin for OpenClaw
|
|
3
|
+
* Registers EverMemOS as the context engine for memory management
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { resolveConfig } from "./src/config.js";
|
|
7
|
+
import { searchMemories, saveMemories } from "./src/memory-api.js";
|
|
8
|
+
import { buildMemoryPrompt, parseSearchResponse } from "./src/formatter.js";
|
|
9
|
+
import { collectMessages, toText, isSessionResetPrompt } from "./src/message-utils.js";
|
|
10
|
+
|
|
11
|
+
const PLUGIN_ID = "evermemos-openclaw-plugin";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Convert OpenClaw AgentMessage to EverMemOS message format
|
|
15
|
+
* Handles both OpenClaw format (toolCall/toolResult) and Anthropic format (tool_use/tool_result)
|
|
16
|
+
* @param {Object} msg - OpenClaw AgentMessage
|
|
17
|
+
* @returns {Object} - EverMemOS message format
|
|
18
|
+
*/
|
|
19
|
+
function convertMessage(msg) {
|
|
20
|
+
const content = msg.content;
|
|
21
|
+
const originalRole = msg.role;
|
|
22
|
+
let role = originalRole;
|
|
23
|
+
let toolCalls = undefined;
|
|
24
|
+
let toolCallId = undefined;
|
|
25
|
+
let textContent = "";
|
|
26
|
+
|
|
27
|
+
// Normalize role - preserve toolResult for EverMemOS
|
|
28
|
+
// OpenClaw uses "toolResult", we map it to "tool" for EverMemOS
|
|
29
|
+
if (role === "toolResult") {
|
|
30
|
+
role = "tool";
|
|
31
|
+
toolCallId = msg.toolCallId || undefined;
|
|
32
|
+
} else if (role !== "user" && role !== "assistant" && role !== "tool") {
|
|
33
|
+
role = "user";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Handle text content (simple string)
|
|
37
|
+
if (typeof content === "string") {
|
|
38
|
+
const result = { role, content };
|
|
39
|
+
if (toolCallId) result.tool_call_id = toolCallId;
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Handle content blocks (array of {type, ...})
|
|
44
|
+
if (Array.isArray(content)) {
|
|
45
|
+
for (const block of content) {
|
|
46
|
+
if (!block || !block.type) continue;
|
|
47
|
+
|
|
48
|
+
if (block.type === "text") {
|
|
49
|
+
const text = block.text ?? "";
|
|
50
|
+
textContent += (textContent ? "\n" : "") + text;
|
|
51
|
+
}
|
|
52
|
+
// OpenClaw format: toolCall
|
|
53
|
+
else if (block.type === "toolCall") {
|
|
54
|
+
toolCalls = toolCalls || [];
|
|
55
|
+
const tc = {
|
|
56
|
+
id: block.id,
|
|
57
|
+
type: "function",
|
|
58
|
+
function: {
|
|
59
|
+
name: block.name || "unknown",
|
|
60
|
+
arguments: typeof block.arguments === "string"
|
|
61
|
+
? block.arguments
|
|
62
|
+
: JSON.stringify(block.arguments ?? {}),
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
toolCalls.push(tc);
|
|
66
|
+
textContent += (textContent ? "\n" : "") + `[Tool: ${block.name || "unknown"}]`;
|
|
67
|
+
}
|
|
68
|
+
// Anthropic format: tool_use
|
|
69
|
+
else if (block.type === "tool_use") {
|
|
70
|
+
toolCalls = toolCalls || [];
|
|
71
|
+
const tc = {
|
|
72
|
+
id: block.id,
|
|
73
|
+
type: "function",
|
|
74
|
+
function: {
|
|
75
|
+
name: block.name || "unknown",
|
|
76
|
+
arguments: typeof block.input === "string"
|
|
77
|
+
? block.input
|
|
78
|
+
: JSON.stringify(block.input ?? {}),
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
toolCalls.push(tc);
|
|
82
|
+
textContent += (textContent ? "\n" : "") + `[Tool: ${block.name || "unknown"}]`;
|
|
83
|
+
}
|
|
84
|
+
// Anthropic format: tool_result (in assistant content array)
|
|
85
|
+
else if (block.type === "tool_result") {
|
|
86
|
+
toolCallId = block.tool_use_id;
|
|
87
|
+
const preview = safePreview(block.content);
|
|
88
|
+
textContent += (textContent ? "\n" : "") + `[Tool Result: ${preview}]`;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const result = { role, content: textContent || "" };
|
|
93
|
+
if (toolCalls) result.tool_calls = toolCalls;
|
|
94
|
+
if (toolCallId) result.tool_call_id = toolCallId;
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Fallback for unexpected content types
|
|
99
|
+
const fallbackContent = content == null ? "" : String(content);
|
|
100
|
+
const result = { role, content: fallbackContent };
|
|
101
|
+
if (toolCallId) result.tool_call_id = toolCallId;
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Safely create a preview of tool result content
|
|
107
|
+
* @param {*} content - Tool result content (string, object, array, null, undefined, Symbol, function)
|
|
108
|
+
* @param {number} maxLength - Maximum preview length
|
|
109
|
+
* @returns {string} - Safe preview string
|
|
110
|
+
*/
|
|
111
|
+
function safePreview(content, maxLength = 200) {
|
|
112
|
+
if (content == null) return "(empty)";
|
|
113
|
+
|
|
114
|
+
let str = "";
|
|
115
|
+
if (typeof content === "string") {
|
|
116
|
+
str = content;
|
|
117
|
+
} else {
|
|
118
|
+
try {
|
|
119
|
+
str = JSON.stringify(content);
|
|
120
|
+
// JSON.stringify returns undefined for Symbol/function/undefined values
|
|
121
|
+
if (typeof str !== "string") {
|
|
122
|
+
str = String(content);
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
str = String(content);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (str.length <= maxLength) return str;
|
|
130
|
+
return str.slice(0, maxLength) + "...";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Create EverMemOS ContextEngine instance
|
|
135
|
+
* @param {Object} pluginConfig - Plugin configuration
|
|
136
|
+
* @param {Object} logger - Logger instance
|
|
137
|
+
* @returns {Object} - ContextEngine implementation
|
|
138
|
+
*/
|
|
139
|
+
function createContextEngineInstance(pluginConfig, logger) {
|
|
140
|
+
const cfg = resolveConfig(pluginConfig);
|
|
141
|
+
const log = logger || { info: (...a) => console.log(...a), warn: (...a) => console.warn(...a) };
|
|
142
|
+
|
|
143
|
+
log.info(`[evermemos] ContextEngine config: baseUrl=${cfg.serverUrl}, userId=${cfg.userId}`);
|
|
144
|
+
|
|
145
|
+
// Session state - shared across all sessions for this engine instance
|
|
146
|
+
const sessionState = new Map();
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
info: {
|
|
150
|
+
id: PLUGIN_ID,
|
|
151
|
+
name: "EverMemOS ContextEngine",
|
|
152
|
+
version: "1.0.0",
|
|
153
|
+
ownsCompaction: false,
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
async bootstrap({ sessionId, sessionKey }) {
|
|
157
|
+
log.info(`[evermemos] bootstrap: session=${sessionId}, key=${sessionKey}`);
|
|
158
|
+
|
|
159
|
+
// Verify EverMemOS backend health
|
|
160
|
+
try {
|
|
161
|
+
const response = await fetch(`${cfg.serverUrl}/health`, {
|
|
162
|
+
signal: AbortSignal.timeout(5000),
|
|
163
|
+
});
|
|
164
|
+
if (response.ok) {
|
|
165
|
+
const result = await response.json();
|
|
166
|
+
log.info(`[evermemos] bootstrap: backend healthy, status=${result.status}`);
|
|
167
|
+
} else {
|
|
168
|
+
log.warn(`[evermemos] bootstrap: backend unhealthy, status=${response.status}`);
|
|
169
|
+
}
|
|
170
|
+
} catch (err) {
|
|
171
|
+
log.warn(`[evermemos] bootstrap: health check failed: ${err.message}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Initialize or get session state
|
|
175
|
+
if (!sessionState.has(sessionKey)) {
|
|
176
|
+
sessionState.set(sessionKey, {
|
|
177
|
+
turnCount: 0,
|
|
178
|
+
lastAssembleTime: 0,
|
|
179
|
+
pendingFlush: false,
|
|
180
|
+
pendingMessages: [],
|
|
181
|
+
});
|
|
182
|
+
log.info(`[evermemos] bootstrap: initialized state for ${sessionKey}`);
|
|
183
|
+
} else {
|
|
184
|
+
log.info(`[evermemos] bootstrap: reusing existing state for ${sessionKey}, turn=${sessionState.get(sessionKey).turnCount}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { bootstrapped: true };
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
async ingest({ sessionId, sessionKey, message }) {
|
|
191
|
+
log.info(`[evermemos] ingest: session=${sessionKey}, role=${message?.role}, isHeartbeat=${message?.isHeartbeat}`);
|
|
192
|
+
|
|
193
|
+
const state = sessionState.get(sessionKey);
|
|
194
|
+
if (!state) {
|
|
195
|
+
log.warn(`[evermemos] ingest: no state for session=${sessionKey}`);
|
|
196
|
+
return { ingested: false };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Don't ingest heartbeats
|
|
200
|
+
if (message.isHeartbeat) {
|
|
201
|
+
return { ingested: false };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Store for batch processing in afterTurn
|
|
205
|
+
state.pendingMessages.push(message);
|
|
206
|
+
log.info(`[evermemos] ingest: collected ${state.pendingMessages.length} messages so far`);
|
|
207
|
+
|
|
208
|
+
return { ingested: true };
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
async ingestBatch({ sessionId, sessionKey, messages, isHeartbeat }) {
|
|
212
|
+
log.info(`[evermemos] ingestBatch: session=${sessionKey}, count=${messages?.length}, isHeartbeat=${isHeartbeat}`);
|
|
213
|
+
|
|
214
|
+
if (isHeartbeat) {
|
|
215
|
+
return { ingestedCount: 0 };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const state = sessionState.get(sessionKey);
|
|
219
|
+
if (!state) {
|
|
220
|
+
log.warn(`[evermemos] ingestBatch: no state for session=${sessionKey}`);
|
|
221
|
+
return { ingestedCount: 0 };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Store messages for later processing
|
|
225
|
+
state.pendingMessages.push(...messages);
|
|
226
|
+
log.info(`[evermemos] ingestBatch: collected ${state.pendingMessages.length} messages so far`);
|
|
227
|
+
|
|
228
|
+
return { ingestedCount: messages.length };
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
async afterTurn({ sessionId, sessionKey, messages, prePromptMessageCount }) {
|
|
232
|
+
const state = sessionState.get(sessionKey);
|
|
233
|
+
if (!state) {
|
|
234
|
+
log.warn(`[evermemos] afterTurn: no state for session=${sessionKey}`);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
state.turnCount++;
|
|
239
|
+
|
|
240
|
+
// Get new messages (those added after prePromptMessageCount)
|
|
241
|
+
const newMessages = prePromptMessageCount !== undefined
|
|
242
|
+
? messages.slice(prePromptMessageCount)
|
|
243
|
+
: messages;
|
|
244
|
+
|
|
245
|
+
log.info(`[evermemos] afterTurn: session=${sessionKey}, turn=${state.turnCount}, totalMessages=${messages.length}, newMessages=${newMessages.length}`);
|
|
246
|
+
|
|
247
|
+
if (newMessages.length === 0) {
|
|
248
|
+
log.info(`[evermemos] afterTurn: session=${sessionKey}, turn=${state.turnCount}, no new messages to save`);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
const evermemosMessages = newMessages.map(convertMessage).filter((m) => m.content);
|
|
254
|
+
if (evermemosMessages.length === 0) {
|
|
255
|
+
log.info(`[evermemos] afterTurn: session=${sessionKey}, turn=${state.turnCount}, no valid messages to save`);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
await saveMemories(cfg, {
|
|
259
|
+
userId: cfg.userId,
|
|
260
|
+
groupId: cfg.groupId,
|
|
261
|
+
messages: evermemosMessages,
|
|
262
|
+
flush: state.pendingFlush || false,
|
|
263
|
+
});
|
|
264
|
+
log.info(`[evermemos] afterTurn: session=${sessionKey}, turn=${state.turnCount}, saved ${evermemosMessages.length} messages`);
|
|
265
|
+
|
|
266
|
+
if (state.pendingFlush) {
|
|
267
|
+
state.pendingFlush = false;
|
|
268
|
+
log.info(`[evermemos] afterTurn: flush flag consumed`);
|
|
269
|
+
}
|
|
270
|
+
} catch (err) {
|
|
271
|
+
log.warn(`[evermemos] afterTurn: save failed: ${err.message}`);
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
async assemble({ sessionId, sessionKey, messages, tokenBudget }) {
|
|
276
|
+
// Initialize state if not exists (assemble can be called before bootstrap)
|
|
277
|
+
if (!sessionState.has(sessionKey)) {
|
|
278
|
+
sessionState.set(sessionKey, {
|
|
279
|
+
turnCount: 0,
|
|
280
|
+
lastAssembleTime: 0,
|
|
281
|
+
pendingFlush: false,
|
|
282
|
+
pendingMessages: [],
|
|
283
|
+
});
|
|
284
|
+
log.info(`[evermemos] assemble: initialized state for ${sessionKey}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const state = sessionState.get(sessionKey);
|
|
288
|
+
|
|
289
|
+
// Get the last user message as query
|
|
290
|
+
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
|
|
291
|
+
const query = lastUserMsg ? toText(lastUserMsg) : "";
|
|
292
|
+
|
|
293
|
+
if (!query || query.length < 3) {
|
|
294
|
+
return { messages, estimatedTokens: 0 };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Detect session reset - flush memory but keep current messages
|
|
298
|
+
if (isSessionResetPrompt(query)) {
|
|
299
|
+
log.info(`[evermemos] assemble: session reset detected, keeping current messages`);
|
|
300
|
+
state.pendingFlush = true;
|
|
301
|
+
// Return original messages without memory injection
|
|
302
|
+
// The reset intent is captured in the query itself
|
|
303
|
+
return { messages, estimatedTokens: 0 };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
state.lastAssembleTime = Date.now();
|
|
308
|
+
|
|
309
|
+
// Early turns: retrieve more context
|
|
310
|
+
const earlyTurnMultiplier = state.turnCount <= 2 ? 2 : 1;
|
|
311
|
+
const topK = Math.min(cfg.topK * earlyTurnMultiplier, 20);
|
|
312
|
+
|
|
313
|
+
const params = {
|
|
314
|
+
query,
|
|
315
|
+
user_id: cfg.userId,
|
|
316
|
+
group_ids: cfg.groupId ? [cfg.groupId] : undefined,
|
|
317
|
+
memory_types: cfg.memoryTypes,
|
|
318
|
+
retrieve_method: cfg.retrieveMethod,
|
|
319
|
+
top_k,
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const result = await searchMemories(cfg, params);
|
|
323
|
+
const parsed = parseSearchResponse(result);
|
|
324
|
+
|
|
325
|
+
const memoryCount =
|
|
326
|
+
(parsed.episodic?.length || 0) +
|
|
327
|
+
(parsed.traits?.length || 0) +
|
|
328
|
+
(parsed.case ? 1 : 0) +
|
|
329
|
+
(parsed.skill ? 1 : 0);
|
|
330
|
+
|
|
331
|
+
if (memoryCount === 0) {
|
|
332
|
+
return { messages, estimatedTokens: 0 };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Build memory context as a system message
|
|
336
|
+
const memoryText = buildMemoryPrompt(parsed, { wrapInCodeBlock: true });
|
|
337
|
+
const memoryMessage = {
|
|
338
|
+
role: "system",
|
|
339
|
+
content: `[Relevant Memory]\n${memoryText}`,
|
|
340
|
+
_memory: true,
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
log.info(`[evermemos] assemble: session=${sessionKey}, retrieved ${memoryCount} memories`);
|
|
344
|
+
|
|
345
|
+
// Return memory message + existing messages
|
|
346
|
+
return {
|
|
347
|
+
messages: [memoryMessage, ...messages],
|
|
348
|
+
estimatedTokens: Math.floor((memoryText.length + JSON.stringify(messages).length) / 3),
|
|
349
|
+
};
|
|
350
|
+
} catch (err) {
|
|
351
|
+
log.warn(`[evermemos] assemble: ${err.message}`);
|
|
352
|
+
return { messages, estimatedTokens: 0 };
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
async compact({ sessionId, sessionKey, tokenBudget, currentTokenCount }) {
|
|
357
|
+
const state = sessionState.get(sessionKey);
|
|
358
|
+
if (!state) {
|
|
359
|
+
return { ok: true, compacted: false, reason: "no session state" };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
log.info(`[evermemos] compact: session=${sessionKey}, tokens=${currentTokenCount}, budget=${tokenBudget}`);
|
|
363
|
+
|
|
364
|
+
// Simple compaction strategy: if over 80% of budget, recommend compaction
|
|
365
|
+
const threshold = tokenBudget ? tokenBudget * 0.8 : 8000;
|
|
366
|
+
if (currentTokenCount && currentTokenCount > threshold) {
|
|
367
|
+
return {
|
|
368
|
+
ok: true,
|
|
369
|
+
compacted: false,
|
|
370
|
+
reason: `token count (${currentTokenCount}) exceeds threshold (${threshold})`,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return { ok: true, compacted: false, reason: "within threshold" };
|
|
375
|
+
},
|
|
376
|
+
|
|
377
|
+
async dispose() {
|
|
378
|
+
// Clean up session states
|
|
379
|
+
sessionState.clear();
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Plugin entry point - registers the ContextEngine
|
|
386
|
+
* @param {Object} api - OpenClaw API
|
|
387
|
+
*/
|
|
388
|
+
export default function register(api) {
|
|
389
|
+
const log = api.logger || { info: (...a) => console.log(...a), warn: (...a) => console.warn(...a) };
|
|
390
|
+
|
|
391
|
+
log.info(`[evermemos] Registering EverMemOS ContextEngine plugin`);
|
|
392
|
+
|
|
393
|
+
// Register the ContextEngine factory
|
|
394
|
+
api.registerContextEngine(PLUGIN_ID, (pluginConfig) => {
|
|
395
|
+
return createContextEngineInstance(pluginConfig, api.logger);
|
|
396
|
+
});
|
|
397
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "@evermind-ai/openclaw-plugin",
|
|
3
|
+
"name": "EverMemOS ContextEngine",
|
|
4
|
+
"description": "Full-lifecycle memory management with EverMemOS - supports bootstrap, assemble, afterTurn, compact, and subagent tracking",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"kind": "context-engine",
|
|
7
|
+
"contextEngine": true,
|
|
8
|
+
"main": "./index.js",
|
|
9
|
+
"configSchema": {
|
|
10
|
+
"type": "object",
|
|
11
|
+
"additionalProperties": false,
|
|
12
|
+
"properties": {
|
|
13
|
+
"baseUrl": {
|
|
14
|
+
"type": "string",
|
|
15
|
+
"description": "EverMemOS server base URL",
|
|
16
|
+
"default": "http://localhost:1995"
|
|
17
|
+
},
|
|
18
|
+
"userId": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"description": "Identity used for memory ownership and as message sender",
|
|
21
|
+
"default": "evermemos-user"
|
|
22
|
+
},
|
|
23
|
+
"groupId": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"description": "Group id for shared memory",
|
|
26
|
+
"default": "evermemos-group"
|
|
27
|
+
},
|
|
28
|
+
"topK": {
|
|
29
|
+
"type": "integer",
|
|
30
|
+
"description": "Maximum number of memory entries to retrieve",
|
|
31
|
+
"default": 5
|
|
32
|
+
},
|
|
33
|
+
"memoryTypes": {
|
|
34
|
+
"type": "array",
|
|
35
|
+
"description": "EverMemOS memory types to search",
|
|
36
|
+
"items": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"enum": ["episodic_memory", "profile", "agent_skill", "agent_case"]
|
|
39
|
+
},
|
|
40
|
+
"default": ["episodic_memory", "profile", "agent_skill", "agent_case"]
|
|
41
|
+
},
|
|
42
|
+
"retrieveMethod": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"description": "Retrieval strategy used by EverMemOS",
|
|
45
|
+
"enum": ["keyword", "vector", "hybrid", "rrf", "agentic"],
|
|
46
|
+
"default": "hybrid"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@evermind-ai/openclaw-plugin",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "EverMemOS ContextEngine integration for OpenClaw 3.8+",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.js",
|
|
12
|
+
"openclaw.plugin.json",
|
|
13
|
+
"README.md",
|
|
14
|
+
"README.zh.md",
|
|
15
|
+
"src/"
|
|
16
|
+
],
|
|
17
|
+
"keywords": [
|
|
18
|
+
"openclaw",
|
|
19
|
+
"plugin",
|
|
20
|
+
"context-engine",
|
|
21
|
+
"memory",
|
|
22
|
+
"evermemos",
|
|
23
|
+
"ai",
|
|
24
|
+
"agent"
|
|
25
|
+
],
|
|
26
|
+
"author": "EverMind",
|
|
27
|
+
"license": "Apache-2.0",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "https://github.com/evermind-ai/evermemos-openclaw-plugin"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18.0.0"
|
|
34
|
+
},
|
|
35
|
+
"openclaw": {
|
|
36
|
+
"id": "@evermind-ai/openclaw-plugin",
|
|
37
|
+
"kind": "context-engine",
|
|
38
|
+
"contextEngine": true
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/assembler.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Assembler Module
|
|
3
|
+
* Handles query-aware context assembly from EverMemOS memories
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { searchMemories } from "./memory-api.js";
|
|
7
|
+
import { buildMemoryPrompt, parseSearchResponse } from "./formatter.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {import("./types.js").EverMemOSConfig} EverMemOSConfig
|
|
11
|
+
* @typedef {import("./types.js").Logger} Logger
|
|
12
|
+
* @typedef {import("./types.js").ParsedMemoryResponse} ParsedMemoryResponse
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Handles query-aware context assembly
|
|
17
|
+
* Retrieves relevant memories based on current query and conversation state
|
|
18
|
+
*/
|
|
19
|
+
export class ContextAssembler {
|
|
20
|
+
/**
|
|
21
|
+
* @param {EverMemOSConfig} cfg
|
|
22
|
+
* @param {Logger} logger
|
|
23
|
+
*/
|
|
24
|
+
constructor(cfg, logger) {
|
|
25
|
+
this.cfg = cfg;
|
|
26
|
+
this.log = logger;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Assemble context from memories based on current query and conversation state
|
|
31
|
+
* @param {string} query - Current user query
|
|
32
|
+
* @param {Array} messages - Full conversation history
|
|
33
|
+
* @param {number} turnCount - Current turn number
|
|
34
|
+
* @returns {Promise<{context: string, memoryCount: number}>}
|
|
35
|
+
*/
|
|
36
|
+
async assemble(query, messages, turnCount) {
|
|
37
|
+
// Early turns: retrieve more context for grounding
|
|
38
|
+
const earlyTurnMultiplier = turnCount <= 2 ? 2 : 1;
|
|
39
|
+
const topK = Math.min(this.cfg.topK * earlyTurnMultiplier, 20);
|
|
40
|
+
|
|
41
|
+
/** @type {Object} */
|
|
42
|
+
const params = {
|
|
43
|
+
query,
|
|
44
|
+
user_id: this.cfg.userId,
|
|
45
|
+
group_ids: this.cfg.groupId ? [this.cfg.groupId] : undefined,
|
|
46
|
+
memory_types: this.cfg.memoryTypes,
|
|
47
|
+
retrieve_method: this.cfg.retrieveMethod,
|
|
48
|
+
top_k: topK,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** @type {any} */
|
|
52
|
+
const result = await searchMemories(this.cfg, params);
|
|
53
|
+
/** @type {ParsedMemoryResponse} */
|
|
54
|
+
const parsed = parseSearchResponse(result);
|
|
55
|
+
|
|
56
|
+
// Count total memories
|
|
57
|
+
const memoryCount =
|
|
58
|
+
(parsed.episodic?.length || 0) +
|
|
59
|
+
(parsed.traits?.length || 0) +
|
|
60
|
+
(parsed.case ? 1 : 0) +
|
|
61
|
+
(parsed.skill ? 1 : 0);
|
|
62
|
+
|
|
63
|
+
const context = buildMemoryPrompt(parsed, { wrapInCodeBlock: true });
|
|
64
|
+
|
|
65
|
+
return { context, memoryCount };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Build minimal context for subagents (smaller context window)
|
|
70
|
+
* @param {string} query - Subagent query
|
|
71
|
+
* @returns {Promise<string>}
|
|
72
|
+
*/
|
|
73
|
+
async assembleForSubagent(query) {
|
|
74
|
+
if (!query || query.length < 3) return "";
|
|
75
|
+
|
|
76
|
+
const topK = Math.min(this.cfg.topK, 3);
|
|
77
|
+
|
|
78
|
+
/** @type {Object} */
|
|
79
|
+
const params = {
|
|
80
|
+
query,
|
|
81
|
+
user_id: this.cfg.userId,
|
|
82
|
+
group_ids: this.cfg.groupId ? [this.cfg.groupId] : undefined,
|
|
83
|
+
memory_types: this.cfg.memoryTypes,
|
|
84
|
+
retrieve_method: this.cfg.retrieveMethod,
|
|
85
|
+
top_k: topK,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** @type {any} */
|
|
89
|
+
const result = await searchMemories(this.cfg, params);
|
|
90
|
+
/** @type {ParsedMemoryResponse} */
|
|
91
|
+
const parsed = parseSearchResponse(result);
|
|
92
|
+
|
|
93
|
+
// Use no code block for subagents (cleaner format)
|
|
94
|
+
return buildMemoryPrompt(parsed, { wrapInCodeBlock: false });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compaction Handler Module
|
|
3
|
+
* Handles session compaction evaluation and participation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {import("./types.js").EverMemOSConfig} EverMemOSConfig
|
|
8
|
+
* @typedef {import("./types.js").Logger} Logger
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Compaction evaluation parameters
|
|
13
|
+
* @typedef {Object} CompactionEvalParams
|
|
14
|
+
* @property {Array} messages
|
|
15
|
+
* @property {number} tokenCount
|
|
16
|
+
* @property {number} turnCount
|
|
17
|
+
* @property {number} lastAssembleTime
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Compaction decision result
|
|
22
|
+
* @typedef {Object} CompactionDecision
|
|
23
|
+
* @property {boolean} shouldCompact
|
|
24
|
+
* @property {string} reason
|
|
25
|
+
* @property {string} [memoryStrategy]
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Handles session compaction evaluation and participation
|
|
30
|
+
* Evaluates when to compact the conversation context
|
|
31
|
+
*/
|
|
32
|
+
export class CompactionHandler {
|
|
33
|
+
/**
|
|
34
|
+
* @param {EverMemOSConfig} cfg
|
|
35
|
+
* @param {Logger} logger
|
|
36
|
+
*/
|
|
37
|
+
constructor(cfg, logger) {
|
|
38
|
+
this.cfg = cfg;
|
|
39
|
+
this.log = logger;
|
|
40
|
+
|
|
41
|
+
// Configurable thresholds
|
|
42
|
+
this.compactThresholdTokens = 8000;
|
|
43
|
+
this.compactThresholdTurns = 10;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Evaluate whether session should be compacted
|
|
48
|
+
* @param {CompactionEvalParams} params
|
|
49
|
+
* @returns {Promise<CompactionDecision>}
|
|
50
|
+
*/
|
|
51
|
+
async evaluate({ messages, tokenCount, turnCount, lastAssembleTime }) {
|
|
52
|
+
// Compact if token count exceeds threshold
|
|
53
|
+
if (tokenCount > this.compactThresholdTokens) {
|
|
54
|
+
return {
|
|
55
|
+
shouldCompact: true,
|
|
56
|
+
reason: `token count (${tokenCount}) exceeds threshold (${this.compactThresholdTokens})`,
|
|
57
|
+
memoryStrategy: "consolidate_to_long_term",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Compact if turn count exceeds threshold
|
|
62
|
+
if (turnCount > this.compactThresholdTurns) {
|
|
63
|
+
return {
|
|
64
|
+
shouldCompact: true,
|
|
65
|
+
reason: `turn count (${turnCount}) exceeds threshold (${this.compactThresholdTurns})`,
|
|
66
|
+
memoryStrategy: "consolidate_to_long_term",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Compact if no recent memory assembly (stale session)
|
|
71
|
+
const timeSinceAssemble = Date.now() - lastAssembleTime;
|
|
72
|
+
if (lastAssembleTime > 0 && timeSinceAssemble > 30 * 60 * 1000) { // 30 minutes
|
|
73
|
+
return {
|
|
74
|
+
shouldCompact: true,
|
|
75
|
+
reason: `session inactive for ${Math.round(timeSinceAssemble / 60000)} minutes`,
|
|
76
|
+
memoryStrategy: "consolidate_to_long_term",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
shouldCompact: false,
|
|
82
|
+
reason: "token and turn counts within acceptable range",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const DEFAULT_URL = "http://localhost:1995";
|
|
2
|
+
|
|
3
|
+
export const TIMEOUT_MS = 60000;
|
|
4
|
+
|
|
5
|
+
export function resolveConfig(pc = {}) {
|
|
6
|
+
return {
|
|
7
|
+
serverUrl: (pc.baseUrl || DEFAULT_URL).replace(/\/*$/, ""),
|
|
8
|
+
userId: pc.userId || "evermemos-user",
|
|
9
|
+
groupId: pc.groupId || "evermemos-group",
|
|
10
|
+
topK: pc.topK ?? 5,
|
|
11
|
+
memoryTypes: pc.memoryTypes ?? ["episodic_memory", "profile", "agent_skill", "agent_case"],
|
|
12
|
+
retrieveMethod: pc.retrieveMethod ?? "hybrid",
|
|
13
|
+
};
|
|
14
|
+
}
|