@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
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EverMemOS ContextEngine Implementation for OpenClaw 3.8+
|
|
3
|
+
* Implements full-lifecycle memory management: bootstrap, assemble, afterTurn, compact, subagent
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { resolveConfig } from "./config.js";
|
|
7
|
+
import { toText, isSessionResetPrompt, collectMessages } from "./message-utils.js";
|
|
8
|
+
import { ContextAssembler } from "./assembler.js";
|
|
9
|
+
import { LifecycleManager } from "./lifecycle.js";
|
|
10
|
+
import { CompactionHandler } from "./compaction.js";
|
|
11
|
+
import { SubagentTracker } from "./subagent.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {import("./types.js").EverMemOSConfig} EverMemOSConfig
|
|
15
|
+
* @typedef {import("./types.js").Logger} Logger
|
|
16
|
+
* @typedef {import("./types.js").BootstrapContext} BootstrapContext
|
|
17
|
+
* @typedef {import("./types.js").AssembleContext} AssembleContext
|
|
18
|
+
* @typedef {import("./types.js").AssembleResult} AssembleResult
|
|
19
|
+
* @typedef {import("./types.js").AfterTurnContext} AfterTurnContext
|
|
20
|
+
* @typedef {import("./types.js").CompactContext} CompactContext
|
|
21
|
+
* @typedef {import("./types.js").CompactResult} CompactResult
|
|
22
|
+
* @typedef {import("./types.js").PrepareSubagentContext} PrepareSubagentContext
|
|
23
|
+
* @typedef {import("./types.js").PrepareSubagentResult} PrepareSubagentResult
|
|
24
|
+
* @typedef {import("./types.js").SubagentEndedContext} SubagentEndedContext
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* EverMemOS ContextEngine implementation for OpenClaw 3.8+
|
|
29
|
+
* Implements full-lifecycle memory management: bootstrap, assemble, afterTurn, compact, subagent
|
|
30
|
+
*/
|
|
31
|
+
export class EverMemOSContextEngine {
|
|
32
|
+
/**
|
|
33
|
+
* @param {EverMemOSConfig} config
|
|
34
|
+
* @param {Logger} logger
|
|
35
|
+
*/
|
|
36
|
+
constructor(config, logger = console) {
|
|
37
|
+
this.cfg = resolveConfig(config);
|
|
38
|
+
this.log = logger;
|
|
39
|
+
|
|
40
|
+
// Session state tracking
|
|
41
|
+
this.turnCount = 0;
|
|
42
|
+
this.lastAssembleTime = 0;
|
|
43
|
+
this.pendingFlush = false;
|
|
44
|
+
|
|
45
|
+
// Submodules
|
|
46
|
+
this.assembler = new ContextAssembler(this.cfg, this.log);
|
|
47
|
+
this.lifecycle = new LifecycleManager(this.cfg, this.log);
|
|
48
|
+
this.compaction = new CompactionHandler(this.cfg, this.log);
|
|
49
|
+
this.subagentTracker = new SubagentTracker(this.cfg, this.log);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Bootstrap: Called once when plugin loads. Initialize connection and verify backend health.
|
|
54
|
+
* @param {BootstrapContext} ctx
|
|
55
|
+
* @returns {Promise<void>}
|
|
56
|
+
*/
|
|
57
|
+
async bootstrap(ctx) {
|
|
58
|
+
this.log("[evermemos] bootstrap: initializing EverMemOS connection");
|
|
59
|
+
try {
|
|
60
|
+
const result = await this._healthCheck();
|
|
61
|
+
if (result?.status === "ok") {
|
|
62
|
+
this.log("[evermemos] bootstrap: backend healthy");
|
|
63
|
+
} else {
|
|
64
|
+
this.log.warn("[evermemos] bootstrap: backend unhealthy, will degrade gracefully");
|
|
65
|
+
}
|
|
66
|
+
} catch (err) {
|
|
67
|
+
this.log.warn(`[evermemos] bootstrap: health check failed: ${err.message}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Assemble: Build context from memories before agent processes the prompt.
|
|
73
|
+
* This replaces the old before_agent_start hook with query-aware assembly.
|
|
74
|
+
* @param {AssembleContext} ctx
|
|
75
|
+
* @returns {Promise<AssembleResult>}
|
|
76
|
+
*/
|
|
77
|
+
async assemble(ctx) {
|
|
78
|
+
const { prompt, messages } = ctx;
|
|
79
|
+
const query = toText(prompt);
|
|
80
|
+
|
|
81
|
+
// Detect session reset (/new, /reset)
|
|
82
|
+
if (isSessionResetPrompt(query)) {
|
|
83
|
+
this.log("[evermemos] assemble: session reset detected, skipping search");
|
|
84
|
+
this.pendingFlush = true;
|
|
85
|
+
return { context: "", metadata: { skipped: "session_reset" } };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!query || query.length < 3) {
|
|
89
|
+
return { context: "", metadata: { skipped: "empty_query" } };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
this.lastAssembleTime = Date.now();
|
|
94
|
+
const result = await this.assembler.assemble(query, messages, this.turnCount);
|
|
95
|
+
this.log(`[evermemos] assemble: retrieved ${result.memoryCount} memories`);
|
|
96
|
+
return {
|
|
97
|
+
context: result.context,
|
|
98
|
+
metadata: {
|
|
99
|
+
memoryCount: result.memoryCount,
|
|
100
|
+
retrieveMethod: this.cfg.retrieveMethod,
|
|
101
|
+
turnCount: this.turnCount,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
} catch (err) {
|
|
105
|
+
this.log.warn(`[evermemos] assemble: ${err.message}`);
|
|
106
|
+
return { context: "", metadata: { error: err.message } };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* AfterTurn: Called after each agent turn. Extract memories promptly.
|
|
112
|
+
* This enables timely extraction instead of waiting for agent_end.
|
|
113
|
+
* @param {AfterTurnContext} ctx
|
|
114
|
+
* @returns {Promise<void>}
|
|
115
|
+
*/
|
|
116
|
+
async afterTurn(ctx) {
|
|
117
|
+
const { messages, success } = ctx;
|
|
118
|
+
this.turnCount++;
|
|
119
|
+
|
|
120
|
+
if (!success || !messages?.length) {
|
|
121
|
+
this.log(`[evermemos] afterTurn: turn ${this.turnCount} skipped (no success/messages)`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const collected = collectMessages(messages);
|
|
127
|
+
if (!collected.length) {
|
|
128
|
+
this.log(`[evermemos] afterTurn: turn ${this.turnCount} - no messages to save`);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
await this.lifecycle.ingestTurn({
|
|
133
|
+
userId: this.cfg.userId,
|
|
134
|
+
groupId: this.cfg.groupId,
|
|
135
|
+
messages: collected,
|
|
136
|
+
turnCount: this.turnCount,
|
|
137
|
+
flush: this.pendingFlush,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
this.log(`[evermemos] afterTurn: turn ${this.turnCount} - saved ${collected.length} messages`);
|
|
141
|
+
|
|
142
|
+
// Consume flush flag after successful save
|
|
143
|
+
if (this.pendingFlush) {
|
|
144
|
+
this.pendingFlush = false;
|
|
145
|
+
this.log("[evermemos] afterTurn: flush flag consumed");
|
|
146
|
+
}
|
|
147
|
+
} catch (err) {
|
|
148
|
+
this.log.warn(`[evermemos] afterTurn: ${err.message}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Compact: Participate in session compaction decision and memory consolidation.
|
|
154
|
+
* @param {CompactContext} ctx
|
|
155
|
+
* @returns {Promise<CompactResult>}
|
|
156
|
+
*/
|
|
157
|
+
async compact(ctx) {
|
|
158
|
+
const { messages, tokenCount } = ctx;
|
|
159
|
+
|
|
160
|
+
this.log(`[evermemos] compact: evaluating compaction (tokens: ${tokenCount}, turns: ${this.turnCount})`);
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const decision = await this.compaction.evaluate({
|
|
164
|
+
messages,
|
|
165
|
+
tokenCount,
|
|
166
|
+
turnCount: this.turnCount,
|
|
167
|
+
lastAssembleTime: this.lastAssembleTime,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
if (decision.shouldCompact) {
|
|
171
|
+
this.log(`[evermemos] compact: compaction recommended - ${decision.reason}`);
|
|
172
|
+
return {
|
|
173
|
+
shouldCompact: true,
|
|
174
|
+
reason: decision.reason,
|
|
175
|
+
metadata: {
|
|
176
|
+
turnCount: this.turnCount,
|
|
177
|
+
memoryStrategy: decision.memoryStrategy,
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
this.log("[evermemos] compact: no compaction needed");
|
|
183
|
+
return { shouldCompact: false, reason: decision.reason };
|
|
184
|
+
} catch (err) {
|
|
185
|
+
this.log.warn(`[evermemos] compact: ${err.message}`);
|
|
186
|
+
return { shouldCompact: false, reason: "error evaluating compaction" };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* PrepareSubagentSpawn: Called before spawning a subagent.
|
|
192
|
+
* @param {PrepareSubagentContext} ctx
|
|
193
|
+
* @returns {Promise<PrepareSubagentResult>}
|
|
194
|
+
*/
|
|
195
|
+
async prepareSubagentSpawn(ctx) {
|
|
196
|
+
const { subagentId, parentMessages } = ctx;
|
|
197
|
+
|
|
198
|
+
this.log(`[evermemos] prepareSubagentSpawn: tracking subagent ${subagentId}`);
|
|
199
|
+
|
|
200
|
+
// Register the subagent
|
|
201
|
+
this.subagentTracker.register(subagentId, {
|
|
202
|
+
subagentType: ctx.subagentType,
|
|
203
|
+
parentTurnCount: this.turnCount,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
// Collect relevant memories for the subagent
|
|
208
|
+
const query = toText(ctx.prompt);
|
|
209
|
+
const context = query
|
|
210
|
+
? await this.assembler.assembleForSubagent(query)
|
|
211
|
+
: "";
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
prependContext: context || "",
|
|
215
|
+
metadata: {
|
|
216
|
+
subagentId,
|
|
217
|
+
parentTurnCount: this.turnCount,
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
} catch (err) {
|
|
221
|
+
this.log.warn(`[evermemos] prepareSubagentSpawn: ${err.message}`);
|
|
222
|
+
return { prependContext: "", metadata: {} };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* OnSubagentEnded: Called after a subagent completes.
|
|
228
|
+
* @param {SubagentEndedContext} ctx
|
|
229
|
+
* @returns {Promise<void>}
|
|
230
|
+
*/
|
|
231
|
+
async onSubagentEnded(ctx) {
|
|
232
|
+
const { subagentId, messages, success } = ctx;
|
|
233
|
+
|
|
234
|
+
this.log(`[evermemos] onSubagentEnded: subagent ${subagentId} ended, success=${success}`);
|
|
235
|
+
|
|
236
|
+
// Unregister the subagent
|
|
237
|
+
this.subagentTracker.unregister(subagentId);
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
if (success && messages?.length) {
|
|
241
|
+
const collected = collectMessages(messages);
|
|
242
|
+
if (collected.length) {
|
|
243
|
+
await this.lifecycle.ingestTurn({
|
|
244
|
+
userId: this.cfg.userId,
|
|
245
|
+
groupId: this.cfg.groupId,
|
|
246
|
+
messages: collected,
|
|
247
|
+
turnCount: this.turnCount,
|
|
248
|
+
flush: false,
|
|
249
|
+
});
|
|
250
|
+
this.log(`[evermemos] onSubagentEnded: saved ${collected.length} messages from subagent`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
} catch (err) {
|
|
254
|
+
this.log.warn(`[evermemos] onSubagentEnded: ${err.message}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Internal: Verify backend health
|
|
260
|
+
* @private
|
|
261
|
+
* @returns {Promise<{status: string}|null>}
|
|
262
|
+
*/
|
|
263
|
+
async _healthCheck() {
|
|
264
|
+
const { serverUrl } = this.cfg;
|
|
265
|
+
try {
|
|
266
|
+
const controller = new AbortController();
|
|
267
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
268
|
+
|
|
269
|
+
const response = await fetch(`${serverUrl}/api/v0/health`, {
|
|
270
|
+
method: "GET",
|
|
271
|
+
signal: controller.signal,
|
|
272
|
+
});
|
|
273
|
+
clearTimeout(timeout);
|
|
274
|
+
|
|
275
|
+
if (response.ok) {
|
|
276
|
+
return await response.json();
|
|
277
|
+
}
|
|
278
|
+
return { status: "error", httpStatus: response.status };
|
|
279
|
+
} catch {
|
|
280
|
+
return { status: "error" };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
package/src/formatter.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
export const CONTEXT_BOUNDARY = "user\u200b原\u200b始\u200bquery\u200b:\u200b\u200b\u200b\u200b";
|
|
2
|
+
|
|
3
|
+
function timestampToLabel(ts) {
|
|
4
|
+
if (ts == null || ts === "") return "";
|
|
5
|
+
|
|
6
|
+
if (typeof ts === "number") {
|
|
7
|
+
const d = new Date(ts);
|
|
8
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
9
|
+
const p = (n) => `${n}`.padStart(2, "0");
|
|
10
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (typeof ts === "string") {
|
|
14
|
+
const s = ts.trim();
|
|
15
|
+
if (!s) return "";
|
|
16
|
+
// Unix epoch as string
|
|
17
|
+
if (/^\d{10,13}$/.test(s)) return timestampToLabel(Number(s));
|
|
18
|
+
// ISO 8601: extract date and HH:MM
|
|
19
|
+
const dateEnd = s.indexOf("T");
|
|
20
|
+
if (dateEnd === 10 && s.length > 15) return `${s.slice(0, 10)} ${s.slice(11, 16)}`;
|
|
21
|
+
return s;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseSearchResponse(raw) {
|
|
28
|
+
if (raw?.status !== "ok" || !raw?.result) return null;
|
|
29
|
+
|
|
30
|
+
const allMemories = raw.result.memories ?? [];
|
|
31
|
+
|
|
32
|
+
// episodic memories
|
|
33
|
+
const episodic = allMemories
|
|
34
|
+
.filter((m) => m.memory_type === "episodic_memory" && (m.score ?? 0) >= 0.1)
|
|
35
|
+
.map((m) => {
|
|
36
|
+
const body = m.summary || m.episode || m.content || "";
|
|
37
|
+
const subject = m.subject || "";
|
|
38
|
+
return {
|
|
39
|
+
text: subject ? `${subject}: ${body}` : body,
|
|
40
|
+
timestamp: m.timestamp ?? null,
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const traits = (raw.result.profiles ?? []).filter((p) => (p.score ?? 0) >= 0.1).map((p) => {
|
|
45
|
+
const label = p.category || p.trait_name || "";
|
|
46
|
+
let kind = p.item_type || "";
|
|
47
|
+
if (kind === "explicit_info") kind = "explicit";
|
|
48
|
+
else if (kind === "implicit_trait") kind = "implicit";
|
|
49
|
+
return {
|
|
50
|
+
text: label ? `[${label}] ${p.description || ""}` : (p.description || ""),
|
|
51
|
+
kind,
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// agent_case: top 1 by score (min 0.01)
|
|
56
|
+
const topCase = allMemories
|
|
57
|
+
.filter((m) => m.memory_type === "agent_case" && (m.score ?? 0) >= 0.01)
|
|
58
|
+
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))[0] || null;
|
|
59
|
+
|
|
60
|
+
// agent_skill: top 1 by score (min 0.01)
|
|
61
|
+
const topSkill = allMemories
|
|
62
|
+
.filter((m) => m.memory_type === "agent_skill" && (m.score ?? 0) >= 0.01)
|
|
63
|
+
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))[0] || null;
|
|
64
|
+
|
|
65
|
+
return { episodic, traits, case: topCase, skill: topSkill };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function oneLiner(text) {
|
|
69
|
+
return text == null ? "" : String(text).replace(/[\r\n]+/g, " ").trim();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function factLine(fact) {
|
|
73
|
+
const t = oneLiner(fact.text);
|
|
74
|
+
if (!t) return "";
|
|
75
|
+
const when = timestampToLabel(fact.timestamp);
|
|
76
|
+
return when ? ` - [${when}] ${t}` : ` - ${t}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function traitLine(trait) {
|
|
80
|
+
const t = oneLiner(trait.text);
|
|
81
|
+
if (!t) return "";
|
|
82
|
+
const k = trait.kind?.toLowerCase() ?? "";
|
|
83
|
+
const badge = k.includes("explicit") ? " [Explicit]"
|
|
84
|
+
: k.includes("implicit") ? " [Implicit]"
|
|
85
|
+
: trait.kind ? ` [${trait.kind.replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}]`
|
|
86
|
+
: "";
|
|
87
|
+
return ` -${badge} ${t}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function caseBlock(c) {
|
|
91
|
+
if (!c) return [];
|
|
92
|
+
const intent = oneLiner(c.task_intent || "");
|
|
93
|
+
const approach = c.approach || "";
|
|
94
|
+
if (!intent && !approach) return [];
|
|
95
|
+
const when = timestampToLabel(c.timestamp ?? c.created_at ?? null);
|
|
96
|
+
return [
|
|
97
|
+
" <case>",
|
|
98
|
+
...(when ? [` - time: ${when}`] : []),
|
|
99
|
+
...(intent ? [` - intent: ${intent}`] : []),
|
|
100
|
+
...(approach ? [` - approach: ${approach}`] : []),
|
|
101
|
+
" </case>",
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function skillBlock(s) {
|
|
106
|
+
if (!s) return [];
|
|
107
|
+
const name = oneLiner(s.name || "");
|
|
108
|
+
const desc = oneLiner(s.description || "");
|
|
109
|
+
const content = s.content || "";
|
|
110
|
+
if (!name && !content) return [];
|
|
111
|
+
return [
|
|
112
|
+
" <skill>",
|
|
113
|
+
...(name ? [` - name: ${name}`] : []),
|
|
114
|
+
...(desc ? [` - description: ${desc}`] : []),
|
|
115
|
+
...(content ? [` - content: ${content}`] : []),
|
|
116
|
+
" </skill>",
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function buildMemoryPrompt(parsed, opts = {}) {
|
|
121
|
+
if (!parsed) return "";
|
|
122
|
+
|
|
123
|
+
const episodicLines = parsed.episodic.map(factLine).filter(Boolean);
|
|
124
|
+
const traitLines = parsed.traits.map(traitLine).filter(Boolean);
|
|
125
|
+
const caseLines = caseBlock(parsed.case);
|
|
126
|
+
const skillLines = skillBlock(parsed.skill);
|
|
127
|
+
|
|
128
|
+
if (!episodicLines.length && !traitLines.length && !caseLines.length && !skillLines.length) return "";
|
|
129
|
+
|
|
130
|
+
const xmlBlock = [
|
|
131
|
+
"<memory>",
|
|
132
|
+
...(episodicLines.length ? [" <episodic>", ...episodicLines, " </episodic>"] : []),
|
|
133
|
+
...(traitLines.length ? [" <trait>", ...traitLines, " </trait>"] : []),
|
|
134
|
+
...(caseLines.length ? [" <!-- Similar past case. Use as reference if applicable to the current task. -->", ...caseLines] : []),
|
|
135
|
+
...(skillLines.length ? [" <!-- Relevant skill. Use as reference if applicable to the current task. -->", ...skillLines] : []),
|
|
136
|
+
"</memory>",
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
const memSection = opts.wrapInCodeBlock ? ["```text", ...xmlBlock, "```"] : xmlBlock;
|
|
140
|
+
const nowLabel = timestampToLabel(Date.now());
|
|
141
|
+
|
|
142
|
+
return [
|
|
143
|
+
"Note: Reference memory below. Build on past successes; avoid repeating failed approaches.",
|
|
144
|
+
...(nowLabel ? [`- Time: ${nowLabel}`] : []),
|
|
145
|
+
"",
|
|
146
|
+
...memSection,
|
|
147
|
+
"",
|
|
148
|
+
"**Note**: for memory, please not read from or write to local `MEMORY.md` or `memory/*` files as they are provided above.",
|
|
149
|
+
"",
|
|
150
|
+
CONTEXT_BOUNDARY,
|
|
151
|
+
].join("\n");
|
|
152
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
|
+
import { TIMEOUT_MS } from "./config.js";
|
|
3
|
+
|
|
4
|
+
function buildUrl(base, path, params) {
|
|
5
|
+
const qs = new URLSearchParams();
|
|
6
|
+
for (const [k, v] of Object.entries(params ?? {})) {
|
|
7
|
+
if (v == null || v === "") continue;
|
|
8
|
+
if (Array.isArray(v)) {
|
|
9
|
+
v.filter((x) => x != null && x !== "").forEach((x) => qs.append(k, String(x)));
|
|
10
|
+
} else {
|
|
11
|
+
qs.set(k, String(v));
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const q = qs.toString();
|
|
15
|
+
return q ? `${base}${path}?${q}` : `${base}${path}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function send(url, method, headers, body, timeoutMs) {
|
|
19
|
+
const ac = new AbortController();
|
|
20
|
+
const t = setTimeout(() => ac.abort(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetch(url, { method, headers, body, signal: ac.signal });
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
const msg = await res.text().catch(() => "");
|
|
25
|
+
throw new Error(`HTTP ${res.status}${msg ? ` – ${msg.slice(0, 200)}` : ""}`);
|
|
26
|
+
}
|
|
27
|
+
return res.json();
|
|
28
|
+
} finally {
|
|
29
|
+
clearTimeout(t);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function request(cfg, method, path, params) {
|
|
34
|
+
const headers = { "Content-Type": "application/json" };
|
|
35
|
+
// GET and DELETE use query string, POST uses body
|
|
36
|
+
const url = (method === "GET" || method === "DELETE") ? buildUrl(cfg.serverUrl, path, params) : `${cfg.serverUrl}${path}`;
|
|
37
|
+
const body = method === "POST" && params ? JSON.stringify(params) : undefined;
|
|
38
|
+
const ms = TIMEOUT_MS;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
return await send(url, method, headers, body, ms);
|
|
42
|
+
} catch {
|
|
43
|
+
await sleep(150);
|
|
44
|
+
return send(url, method, headers, body, ms);
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/lifecycle.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lifecycle Manager Module
|
|
3
|
+
* Handles turn-level memory ingestion
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { saveMemories } from "./memory-api.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {import("./types.js").EverMemOSConfig} EverMemOSConfig
|
|
10
|
+
* @typedef {import("./types.js").Logger} Logger
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Turn ingestion parameters
|
|
15
|
+
* @typedef {Object} IngestTurnParams
|
|
16
|
+
* @property {string} userId
|
|
17
|
+
* @property {string} groupId
|
|
18
|
+
* @property {Array} messages
|
|
19
|
+
* @property {number} turnCount
|
|
20
|
+
* @property {boolean} flush
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Handles turn-level memory ingestion
|
|
25
|
+
* Extracts and saves memories after each conversation turn
|
|
26
|
+
*/
|
|
27
|
+
export class LifecycleManager {
|
|
28
|
+
/**
|
|
29
|
+
* @param {EverMemOSConfig} cfg
|
|
30
|
+
* @param {Logger} logger
|
|
31
|
+
*/
|
|
32
|
+
constructor(cfg, logger) {
|
|
33
|
+
this.cfg = cfg;
|
|
34
|
+
this.log = logger;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Ingest messages from a single turn
|
|
39
|
+
* @param {IngestTurnParams} params
|
|
40
|
+
* @returns {Promise<void>}
|
|
41
|
+
*/
|
|
42
|
+
async ingestTurn({ userId, groupId, messages, turnCount, flush }) {
|
|
43
|
+
await saveMemories(this.cfg, {
|
|
44
|
+
userId,
|
|
45
|
+
groupId,
|
|
46
|
+
messages,
|
|
47
|
+
flush,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Flush pending memories to storage
|
|
53
|
+
* Called when session ends or reset is detected
|
|
54
|
+
* @returns {Promise<void>}
|
|
55
|
+
*/
|
|
56
|
+
async flush() {
|
|
57
|
+
// Force save with flush=true
|
|
58
|
+
await saveMemories(this.cfg, {
|
|
59
|
+
userId: this.cfg.userId,
|
|
60
|
+
groupId: this.cfg.groupId,
|
|
61
|
+
messages: [],
|
|
62
|
+
flush: true,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { request } from "./http-client.js";
|
|
2
|
+
|
|
3
|
+
export async function searchMemories(cfg, params) {
|
|
4
|
+
const { memory_types, ...baseParams } = params;
|
|
5
|
+
|
|
6
|
+
const episodicTypes = (memory_types ?? []).filter((t) => t === "episodic_memory" || t === "profile");
|
|
7
|
+
const caseTypes = (memory_types ?? []).filter((t) => t === "agent_case" || t === "agent_skill");
|
|
8
|
+
|
|
9
|
+
const searches = [];
|
|
10
|
+
if (episodicTypes.length) searches.push({ label: "episodic+profile", types: episodicTypes });
|
|
11
|
+
if (caseTypes.length) searches.push({ label: "case+skill", types: caseTypes });
|
|
12
|
+
|
|
13
|
+
const results = await Promise.all(
|
|
14
|
+
searches.map(async ({ label, types }) => {
|
|
15
|
+
const p = { ...baseParams, memory_types: types };
|
|
16
|
+
console.log("[memory-api] GET /api/v1/memories/search", label, JSON.stringify(p));
|
|
17
|
+
const r = await request(cfg, "GET", "/api/v1/memories/search", p);
|
|
18
|
+
console.log("[memory-api] GET response", label, JSON.stringify(r));
|
|
19
|
+
return r;
|
|
20
|
+
}),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
// merge into a single response in order: episodic/profile first, then case/skill
|
|
24
|
+
const merged = {
|
|
25
|
+
status: "ok",
|
|
26
|
+
result: {
|
|
27
|
+
profiles: [],
|
|
28
|
+
memories: [],
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
for (const r of results) {
|
|
32
|
+
if (r?.result?.profiles?.length) merged.result.profiles.push(...r.result.profiles);
|
|
33
|
+
if (r?.result?.memories?.length) merged.result.memories.push(...r.result.memories);
|
|
34
|
+
}
|
|
35
|
+
return merged;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function saveMemories(cfg, { userId, groupId, messages = [], flush = false }) {
|
|
39
|
+
if (!messages.length) return;
|
|
40
|
+
const stamp = Date.now();
|
|
41
|
+
for (let i = 0; i < messages.length; i++) {
|
|
42
|
+
const { role = "user", content = "", tool_calls, tool_call_id } = messages[i];
|
|
43
|
+
const sender = role === "assistant" ? role : (role === "tool" ? "tool" : userId);
|
|
44
|
+
const isLast = i === messages.length - 1;
|
|
45
|
+
|
|
46
|
+
const payload = {
|
|
47
|
+
message_id: `em_${stamp}_${i}`,
|
|
48
|
+
create_time: new Date().toISOString(),
|
|
49
|
+
role,
|
|
50
|
+
sender,
|
|
51
|
+
sender_name: sender,
|
|
52
|
+
content,
|
|
53
|
+
group_id: groupId,
|
|
54
|
+
group_name: groupId,
|
|
55
|
+
scene: "assistant",
|
|
56
|
+
raw_data_type: "AgentConversation",
|
|
57
|
+
...(tool_calls && { tool_calls }),
|
|
58
|
+
...(tool_call_id && { tool_call_id }),
|
|
59
|
+
...(flush && isLast && { flush: true }),
|
|
60
|
+
};
|
|
61
|
+
console.log("[memory-api] POST /api/v1/memories", JSON.stringify(payload));
|
|
62
|
+
const result = await request(cfg, "POST", "/api/v1/memories", payload);
|
|
63
|
+
console.log("[memory-api] POST response", JSON.stringify(result));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function deleteMemories(cfg, { memoryId, userId, groupId }) {
|
|
68
|
+
const payload = {};
|
|
69
|
+
if (memoryId) payload.memory_id = memoryId;
|
|
70
|
+
if (userId) payload.user_id = userId;
|
|
71
|
+
if (groupId) payload.group_id = groupId;
|
|
72
|
+
|
|
73
|
+
console.log("[memory-api] DELETE /api/v1/memories", JSON.stringify(payload));
|
|
74
|
+
const result = await request(cfg, "DELETE", "/api/v1/memories", payload);
|
|
75
|
+
console.log("[memory-api] DELETE response", JSON.stringify(result));
|
|
76
|
+
return result;
|
|
77
|
+
}
|