mr-memory 2.0.0 → 2.3.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.
Files changed (2) hide show
  1. package/index.ts +356 -206
  2. package/package.json +1 -1
package/index.ts CHANGED
@@ -1,88 +1,127 @@
1
1
  /**
2
- * MemoryRouter Plugin for OpenClaw (v2 — Relay Architecture)
2
+ * MemoryRouter Plugin for OpenClaw
3
3
  *
4
4
  * Persistent AI memory via MemoryRouter (memoryrouter.ai).
5
- * Uses OpenClaw's native plugin hooks (before_agent_start + agent_end)
6
- * to inject relevant memories and capture conversations.
5
+ * Uses before_agent_start + agent_end hooks to inject/store memories
6
+ * via the MemoryRouter relay API. No proxy interception needed.
7
7
  *
8
- * No patching required. Works with stock OpenClaw.
8
+ * BYOK provider API keys never touch MemoryRouter.
9
9
  */
10
10
 
11
+ import { readFile, readdir, stat } from "node:fs/promises";
12
+ import { join } from "node:path";
11
13
  import { spawn } from "node:child_process";
12
14
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
13
15
 
14
16
  const DEFAULT_ENDPOINT = "https://api.memoryrouter.ai";
15
17
 
18
+ // Workspace files OpenClaw loads into the system prompt
19
+ const WORKSPACE_FILES = [
20
+ "IDENTITY.md", "USER.md", "MEMORY.md", "HEARTBEAT.md",
21
+ "TOOLS.md", "AGENTS.md", "SOUL.md", "BOOTSTRAP.md",
22
+ ];
23
+
16
24
  type MemoryRouterConfig = {
17
25
  key: string;
18
26
  endpoint?: string;
19
- density?: 'low' | 'high' | 'xhigh';
27
+ density?: "low" | "high" | "xhigh";
28
+ mode?: "relay" | "proxy";
20
29
  };
21
30
 
31
+ // ──────────────────────────────────────────────────────
32
+ // Helpers
33
+ // ──────────────────────────────────────────────────────
34
+
22
35
  function resolveOpenClawInvocation(): { command: string; args: string[] } {
23
36
  const entry = process.argv[1];
24
- if (entry) {
25
- return {
26
- command: process.execPath,
27
- args: [entry],
28
- };
29
- }
30
-
31
- return {
32
- command: "openclaw",
33
- args: [],
34
- };
37
+ if (entry) return { command: process.execPath, args: [entry] };
38
+ return { command: "openclaw", args: [] };
35
39
  }
36
40
 
37
41
  async function runOpenClawConfigSet(path: string, value: string, json = false): Promise<void> {
38
42
  const base = resolveOpenClawInvocation();
39
43
  const args = [...base.args, "config", "set", path, value];
40
- if (json) {
41
- args.push("--json");
42
- }
44
+ if (json) args.push("--json");
43
45
 
44
46
  await new Promise<void>((resolve, reject) => {
45
47
  const child = spawn(base.command, args, {
46
48
  stdio: ["ignore", "ignore", "pipe"],
47
49
  env: process.env,
48
50
  });
49
-
50
51
  let stderr = "";
51
- child.stderr.on("data", (chunk) => {
52
- stderr += String(chunk);
53
- });
54
-
55
- child.on("error", (err) => reject(err));
52
+ child.stderr.on("data", (chunk) => { stderr += String(chunk); });
53
+ child.on("error", reject);
56
54
  child.on("close", (code) => {
57
- if (code === 0) {
58
- resolve();
59
- return;
60
- }
61
- reject(new Error(`openclaw config set failed (exit ${code}): ${stderr.trim()}`));
55
+ if (code === 0) resolve();
56
+ else reject(new Error(`openclaw config set failed (exit ${code}): ${stderr.trim()}`));
62
57
  });
63
58
  });
64
59
  }
65
60
 
61
+ type CompatApi = OpenClawPluginApi & {
62
+ updatePluginConfig?: (config: Record<string, unknown>) => Promise<void>;
63
+ updatePluginEnabled?: (enabled: boolean) => Promise<void>;
64
+ };
65
+
66
66
  async function setPluginConfig(api: OpenClawPluginApi, config: Record<string, unknown>): Promise<void> {
67
- const anyApi = api as any;
68
- if (typeof anyApi.updatePluginConfig === "function") {
69
- await anyApi.updatePluginConfig(config);
67
+ const compat = api as CompatApi;
68
+ if (typeof compat.updatePluginConfig === "function") {
69
+ await compat.updatePluginConfig(config);
70
70
  return;
71
71
  }
72
-
73
72
  await runOpenClawConfigSet(`plugins.entries.${api.id}.config`, JSON.stringify(config), true);
74
73
  }
75
74
 
76
75
  async function setPluginEnabled(api: OpenClawPluginApi, enabled: boolean): Promise<void> {
77
- const anyApi = api as any;
78
- if (typeof anyApi.updatePluginEnabled === "function") {
79
- await anyApi.updatePluginEnabled(enabled);
76
+ const compat = api as CompatApi;
77
+ if (typeof compat.updatePluginEnabled === "function") {
78
+ await compat.updatePluginEnabled(enabled);
80
79
  return;
81
80
  }
82
-
83
81
  await runOpenClawConfigSet(`plugins.entries.${api.id}.enabled`, enabled ? "true" : "false", true);
84
82
  }
85
83
 
84
+ /**
85
+ * Read all workspace files and return as a single text blob for token counting.
86
+ */
87
+ async function readWorkspaceFiles(workspaceDir: string): Promise<string> {
88
+ const parts: string[] = [];
89
+ for (const file of WORKSPACE_FILES) {
90
+ try {
91
+ const content = await readFile(join(workspaceDir, file), "utf8");
92
+ if (content.trim()) parts.push(`## ${file}\n${content}`);
93
+ } catch { /* file doesn't exist — skip */ }
94
+ }
95
+ return parts.join("\n\n");
96
+ }
97
+
98
+ /**
99
+ * Build a text representation of tools config for token counting.
100
+ */
101
+ function serializeToolsConfig(config: Record<string, unknown>): string {
102
+ const tools = config.tools;
103
+ if (!tools) return "";
104
+ try {
105
+ return `## Tools Config\n${JSON.stringify(tools, null, 2)}`;
106
+ } catch { return ""; }
107
+ }
108
+
109
+ /**
110
+ * Build a text representation of skills for token counting.
111
+ */
112
+ function serializeSkillsConfig(config: Record<string, unknown>): string {
113
+ const skills = (config as any).skills?.entries;
114
+ if (!skills || typeof skills !== "object") return "";
115
+ try {
116
+ const names = Object.keys(skills);
117
+ return `## Skills (${names.length})\n${names.join(", ")}`;
118
+ } catch { return ""; }
119
+ }
120
+
121
+ // ──────────────────────────────────────────────────────
122
+ // Plugin Definition
123
+ // ──────────────────────────────────────────────────────
124
+
86
125
  const memoryRouterPlugin = {
87
126
  id: "mr-memory",
88
127
  name: "MemoryRouter",
@@ -92,34 +131,171 @@ const memoryRouterPlugin = {
92
131
  const cfg = api.pluginConfig as MemoryRouterConfig | undefined;
93
132
  const endpoint = cfg?.endpoint?.replace(/\/v1\/?$/, "") || DEFAULT_ENDPOINT;
94
133
  const memoryKey = cfg?.key;
95
- const density = cfg?.density || 'high';
96
-
97
- // ==================================================================
98
- // Core: Relay architecture — memory via API hooks (no patching needed)
99
- // ==================================================================
134
+ const density = cfg?.density || "high";
135
+ const mode = cfg?.mode || "relay";
100
136
 
101
137
  if (memoryKey) {
102
- api.logger.info?.(`memoryrouter: registered (endpoint: ${endpoint})`);
138
+ api.logger.info?.(`memoryrouter: active (key: ${memoryKey.slice(0, 6)}..., mode: ${mode})`);
103
139
  } else {
104
140
  api.logger.info?.("memoryrouter: no key configured — run: openclaw mr <key>");
105
141
  }
106
142
 
143
+ // ==================================================================
144
+ // Core: before_agent_start — search memories, inject context
145
+ // ==================================================================
146
+
107
147
  if (memoryKey) {
108
- // RETRIEVAL inject relevant memories before every agent turn
109
- api.on("before_agent_start", async (event: Record<string, unknown>) => {
110
- const prompt = event.prompt as string | undefined;
111
- if (!prompt || prompt.length < 5) return;
148
+ // Track whether we've already fired for this prompt (dedup double-fire)
149
+ let lastPreparedPrompt = "";
150
+ // Track whether before_prompt_build already handled the first call in this run
151
+ let promptBuildFiredThisRun = false;
152
+
153
+ // ── llm_input: fires on EVERY LLM call (tool iterations, cron, sub-agents)
154
+ // On stock OpenClaw, the return value is ignored (fire-and-forget).
155
+ // When PR #24122 merges, OpenClaw will use the returned prependContext.
156
+ // This gives forward compatibility — no plugin update needed.
157
+ api.on("llm_input", async (event, ctx) => {
158
+ // Skip the first call — before_prompt_build already handled it
159
+ // (before_prompt_build includes workspace+tools+skills for accurate billing)
160
+ if (promptBuildFiredThisRun) {
161
+ promptBuildFiredThisRun = false; // reset so subsequent calls go through
162
+ return;
163
+ }
112
164
 
113
165
  try {
166
+ const prompt = event.prompt;
167
+ if (prompt === lastPreparedPrompt && lastPreparedPrompt !== "") return;
168
+ lastPreparedPrompt = prompt;
169
+
170
+ // Build lightweight context (no workspace/tools — just history + prompt)
171
+ const contextPayload: Array<{ role: string; content: string }> = [];
172
+ if (event.historyMessages && Array.isArray(event.historyMessages)) {
173
+ for (const msg of event.historyMessages) {
174
+ const m = msg as { role?: string; content?: unknown };
175
+ if (!m.role) continue;
176
+ let text = "";
177
+ if (typeof m.content === "string") text = m.content;
178
+ else if (Array.isArray(m.content)) {
179
+ text = (m.content as Array<{ type?: string; text?: string }>)
180
+ .filter(b => b.type === "text" && b.text)
181
+ .map(b => b.text!)
182
+ .join("\n");
183
+ }
184
+ if (text) contextPayload.push({ role: m.role, content: text });
185
+ }
186
+ }
187
+ contextPayload.push({ role: "user", content: prompt });
188
+
189
+ const densityMap: Record<string, number> = { low: 40, high: 80, xhigh: 160 };
190
+ const contextLimit = densityMap[density] || 80;
191
+
192
+ const res = await fetch(`${endpoint}/v1/memory/prepare`, {
193
+ method: "POST",
194
+ headers: {
195
+ "Content-Type": "application/json",
196
+ Authorization: `Bearer ${memoryKey}`,
197
+ },
198
+ body: JSON.stringify({
199
+ messages: contextPayload,
200
+ density,
201
+ context_limit: contextLimit,
202
+ }),
203
+ });
204
+
205
+ if (!res.ok) return;
206
+
207
+ const data = (await res.json()) as {
208
+ context?: string;
209
+ memories_found?: number;
210
+ tokens_billed?: number;
211
+ };
212
+
213
+ if (data.context) {
214
+ api.logger.info?.(
215
+ `memoryrouter: injected ${data.memories_found || 0} memories on tool iteration (${data.tokens_billed || 0} tokens billed)`,
216
+ );
217
+ return { prependContext: data.context };
218
+ }
219
+ } catch {
220
+ // Silent fail on tool iterations — don't block the agent
221
+ }
222
+ });
223
+
224
+ // ── before_prompt_build: fires once per run (primary, includes full billing context)
225
+ api.on("before_prompt_build", async (event, ctx) => {
226
+ promptBuildFiredThisRun = true;
227
+ try {
228
+ const prompt = event.prompt;
229
+
230
+ // Deduplicate — if we already prepared this exact prompt, skip
231
+ if (prompt === lastPreparedPrompt && lastPreparedPrompt !== "") {
232
+ return;
233
+ }
234
+ lastPreparedPrompt = prompt;
235
+
236
+ // 1. Read workspace files for full token count
237
+ const workspaceDir = ctx.workspaceDir || "";
238
+ let workspaceText = "";
239
+ if (workspaceDir) {
240
+ workspaceText = await readWorkspaceFiles(workspaceDir);
241
+ }
242
+
243
+ // 2. Serialize tools + skills from config
244
+ const toolsText = serializeToolsConfig(api.config as unknown as Record<string, unknown>);
245
+ const skillsText = serializeSkillsConfig(api.config as unknown as Record<string, unknown>);
246
+
247
+ // 3. Build full context payload (messages + workspace + tools + skills)
248
+ const contextPayload: Array<{ role: string; content: string }> = [];
249
+
250
+ // Add workspace context as a system-level entry
251
+ const fullContext = [workspaceText, toolsText, skillsText].filter(Boolean).join("\n\n");
252
+ if (fullContext) {
253
+ contextPayload.push({ role: "system", content: fullContext });
254
+ }
255
+
256
+ // Add conversation history
257
+ if (event.messages && Array.isArray(event.messages)) {
258
+ let skipped = 0;
259
+ for (const msg of event.messages) {
260
+ const m = msg as { role?: string; content?: unknown };
261
+ if (!m.role) continue;
262
+
263
+ let text = "";
264
+ if (typeof m.content === "string") {
265
+ text = m.content;
266
+ } else if (Array.isArray(m.content)) {
267
+ // Handle Anthropic-style content blocks [{type:"text", text:"..."}, ...]
268
+ text = (m.content as Array<{ type?: string; text?: string }>)
269
+ .filter(b => b.type === "text" && b.text)
270
+ .map(b => b.text!)
271
+ .join("\n");
272
+ }
273
+
274
+ if (text) {
275
+ contextPayload.push({ role: m.role, content: text });
276
+ } else {
277
+ skipped++;
278
+ }
279
+ }
280
+ }
281
+
282
+ // Add current user prompt
283
+ contextPayload.push({ role: "user", content: prompt });
284
+
285
+ // 4. Call /v1/memory/prepare
286
+ const densityMap: Record<string, number> = { low: 40, high: 80, xhigh: 160 };
287
+ const contextLimit = densityMap[density] || 80;
288
+
114
289
  const res = await fetch(`${endpoint}/v1/memory/prepare`, {
115
290
  method: "POST",
116
291
  headers: {
117
- "Authorization": `Bearer ${memoryKey}`,
118
292
  "Content-Type": "application/json",
293
+ Authorization: `Bearer ${memoryKey}`,
119
294
  },
120
295
  body: JSON.stringify({
121
- messages: [{ role: "user", content: prompt }],
296
+ messages: contextPayload,
122
297
  density,
298
+ context_limit: contextLimit,
123
299
  }),
124
300
  });
125
301
 
@@ -128,94 +304,112 @@ const memoryRouterPlugin = {
128
304
  return;
129
305
  }
130
306
 
131
- const data = await res.json() as { context: string | null; tokens_billed: number; memories_found: number };
307
+ const data = (await res.json()) as {
308
+ context?: string;
309
+ memories_found?: number;
310
+ tokens_billed?: number;
311
+ };
132
312
 
133
313
  if (data.context) {
134
- api.logger.info?.(`memoryrouter: injected ${data.memories_found} memories (${data.tokens_billed} tokens)`);
314
+ api.logger.info?.(
315
+ `memoryrouter: injected ${data.memories_found || 0} memories (${data.tokens_billed || 0} tokens billed)`,
316
+ );
135
317
  return { prependContext: data.context };
136
318
  }
137
319
  } catch (err) {
138
- api.logger.warn?.(`memoryrouter: prepare error: ${String(err)}`);
320
+ api.logger.warn?.(
321
+ `memoryrouter: prepare error — ${err instanceof Error ? err.message : String(err)}`,
322
+ );
139
323
  }
140
324
  });
141
325
 
142
- // STORAGE — capture conversation after every agent turn
143
- // Only stores the LAST user message + assistant response per turn (no duplication)
144
- api.on("agent_end", async (event: Record<string, unknown>) => {
145
- if (!event.success || !event.messages) return;
146
-
147
- const msgs = event.messages as Array<Record<string, unknown>>;
148
- if (!msgs.length) return;
326
+ // ==================================================================
327
+ // Core: agent_end store this turn's conversation
328
+ // ==================================================================
149
329
 
330
+ api.on("agent_end", async (event, ctx) => {
150
331
  try {
151
- // Walk backwards: find the last assistant, then the last user before it
152
- let assistantMsg: { role: string; content: string } | null = null;
153
- let userMsg: { role: string; content: string } | null = null;
154
-
155
- for (let i = msgs.length - 1; i >= 0; i--) {
156
- const role = msgs[i].role as string;
157
- if (!role) continue;
158
-
159
- // Extract text content
160
- let text = "";
161
- const content = msgs[i].content;
162
- if (typeof content === "string") {
163
- text = content;
164
- } else if (Array.isArray(content)) {
165
- for (const block of content as Array<Record<string, unknown>>) {
166
- if (typeof block.text === "string") {
167
- text += (text ? "\n" : "") + block.text;
168
- }
169
- }
332
+ const msgs = event.messages;
333
+ if (!msgs || !Array.isArray(msgs) || msgs.length === 0) return;
334
+
335
+ // Extract text from a message (handles string + content block arrays)
336
+ function extractText(content: unknown): string {
337
+ if (typeof content === "string") return content;
338
+ if (Array.isArray(content)) {
339
+ return (content as Array<{ type?: string; text?: string }>)
340
+ .filter(b => b.type === "text" && b.text)
341
+ .map(b => b.text!)
342
+ .join("\n");
170
343
  }
344
+ return "";
345
+ }
171
346
 
172
- if (!text || text.length < 10) continue;
173
-
174
- if (!assistantMsg && role === "assistant") {
175
- assistantMsg = { role, content: text };
176
- }
177
- if (assistantMsg && !userMsg && role === "user") {
178
- // Strip any injected memory context from user messages
179
- let cleanText = text;
180
- if (cleanText.includes("[Memory Context]")) {
181
- cleanText = cleanText.replace(/\[Memory Context\][\s\S]*?\n\n/g, "").trim();
182
- }
183
- if (cleanText.length >= 10) {
184
- userMsg = { role, content: cleanText };
185
- }
347
+ // Find the last user message, then collect ALL assistant messages after it
348
+ // This captures the full response even if she sent multiple messages
349
+ let lastUserIdx = -1;
350
+ for (let i = msgs.length - 1; i >= 0; i--) {
351
+ const msg = msgs[i] as { role?: string; content?: unknown };
352
+ const text = extractText(msg.content);
353
+ if (msg.role === "user" && text) {
354
+ lastUserIdx = i;
186
355
  break;
187
356
  }
188
357
  }
189
358
 
190
- // Only store this turn's pair — no duplication
191
359
  const toStore: Array<{ role: string; content: string }> = [];
192
- if (userMsg) toStore.push(userMsg);
193
- if (assistantMsg) toStore.push(assistantMsg);
360
+
361
+ // Add the user message
362
+ if (lastUserIdx >= 0) {
363
+ const userMsg = msgs[lastUserIdx] as { content?: unknown };
364
+ const userText = extractText(userMsg.content);
365
+ if (userText) toStore.push({ role: "user", content: userText });
366
+ }
367
+
368
+ // Collect ALL assistant messages after the last user message
369
+ const assistantParts: string[] = [];
370
+ for (let i = (lastUserIdx >= 0 ? lastUserIdx + 1 : 0); i < msgs.length; i++) {
371
+ const msg = msgs[i] as { role?: string; content?: unknown };
372
+ if (msg.role === "assistant") {
373
+ const text = extractText(msg.content);
374
+ if (text) assistantParts.push(text);
375
+ }
376
+ }
377
+ if (assistantParts.length > 0) {
378
+ toStore.push({ role: "assistant", content: assistantParts.join("\n\n") });
379
+ }
194
380
 
195
381
  if (toStore.length === 0) return;
196
382
 
197
- const res = await fetch(`${endpoint}/v1/memory/ingest`, {
383
+ // Fire and forget don't block the response
384
+ fetch(`${endpoint}/v1/memory/ingest`, {
198
385
  method: "POST",
199
386
  headers: {
200
- "Authorization": `Bearer ${memoryKey}`,
201
387
  "Content-Type": "application/json",
388
+ Authorization: `Bearer ${memoryKey}`,
202
389
  },
203
390
  body: JSON.stringify({
204
391
  messages: toStore,
392
+ session_id: ctx.sessionKey,
205
393
  }),
394
+ }).then(async (res) => {
395
+ if (!res.ok) {
396
+ api.logger.warn?.(`memoryrouter: ingest failed (${res.status})`);
397
+ }
398
+ }).catch((err) => {
399
+ api.logger.warn?.(
400
+ `memoryrouter: ingest error — ${err instanceof Error ? err.message : String(err)}`,
401
+ );
206
402
  });
207
-
208
- if (!res.ok) {
209
- api.logger.warn?.(`memoryrouter: ingest failed (${res.status})`);
210
- }
211
403
  } catch (err) {
212
- api.logger.warn?.(`memoryrouter: ingest error: ${String(err)}`);
404
+ api.logger.warn?.(
405
+ `memoryrouter: agent_end error — ${err instanceof Error ? err.message : String(err)}`,
406
+ );
213
407
  }
214
408
  });
215
- } // end if (memoryKey) for relay hooks
409
+ } // end if (memoryKey)
216
410
 
217
411
  // ==================================================================
218
- // CLI Commands (always registered — even without key, for enable/off)
412
+ // CLI Commands
219
413
  // ==================================================================
220
414
 
221
415
  api.registerCli(
@@ -225,7 +419,6 @@ const memoryRouterPlugin = {
225
419
  console.error("Invalid key format. Keys start with 'mk' (e.g. mk_xxx)");
226
420
  return;
227
421
  }
228
-
229
422
  try {
230
423
  await setPluginConfig(api, { key });
231
424
  await setPluginEnabled(api, true);
@@ -234,30 +427,22 @@ const memoryRouterPlugin = {
234
427
  } catch (err) {
235
428
  const message = err instanceof Error ? err.message : String(err);
236
429
  console.error(`Failed to enable MemoryRouter: ${message}`);
237
- console.error("Fallback: openclaw config set plugins.entries.mr-memory.config.key <key>");
238
- console.error("Then: openclaw config set plugins.entries.mr-memory.enabled true --json");
239
430
  }
240
431
  };
241
432
 
242
- const mr = program.command("mr")
433
+ const mr = program
434
+ .command("mr")
243
435
  .description("MemoryRouter memory commands")
244
436
  .argument("[key]", "Your MemoryRouter memory key (mk_xxx)")
245
437
  .action(async (key: string | undefined) => {
246
- if (!key) {
247
- // No key provided — show help
248
- mr.help();
249
- return;
250
- }
438
+ if (!key) { mr.help(); return; }
251
439
  await applyKey(key);
252
440
  });
253
441
 
254
- // Backward compat: `openclaw mr enable <key>` still works
255
442
  mr.command("enable")
256
443
  .description("Enable MemoryRouter with a memory key (alias)")
257
444
  .argument("<key>", "Your MemoryRouter memory key (mk_xxx)")
258
- .action(async (key: string) => {
259
- await applyKey(key);
260
- });
445
+ .action(async (key: string) => { await applyKey(key); });
261
446
 
262
447
  mr.command("off")
263
448
  .description("Disable MemoryRouter (removes key)")
@@ -265,143 +450,110 @@ const memoryRouterPlugin = {
265
450
  try {
266
451
  await setPluginConfig(api, {});
267
452
  await setPluginEnabled(api, false);
268
- console.log("✓ MemoryRouter disabled. LLM calls go direct to provider.");
269
- console.log(" Key removed. Re-enable with: openclaw mr <key>");
453
+ console.log("✓ MemoryRouter disabled.");
270
454
  } catch (err) {
271
- console.error(`Failed to disable MemoryRouter: ${err instanceof Error ? err.message : String(err)}`);
455
+ console.error(`Failed to disable: ${err instanceof Error ? err.message : String(err)}`);
272
456
  }
273
457
  });
274
458
 
275
459
  // Density commands
276
- mr.command("xhigh")
277
- .description("Set memory density to xhigh (160 results, ~50k tokens)")
278
- .action(async () => {
279
- if (!memoryKey) {
280
- console.error("MemoryRouter not configured. Run: openclaw mr <key>");
281
- return;
282
- }
283
- try {
284
- await setPluginConfig(api, { key: memoryKey, endpoint: cfg?.endpoint, density: 'xhigh' });
285
- console.log("✓ Memory density set to xhigh (160 results, ~50k tokens)");
286
- console.log(" Maximum context injection for deepest memory.");
287
- } catch (err) {
288
- console.error(`Failed to set density: ${err instanceof Error ? err.message : String(err)}`);
289
- }
290
- });
291
-
292
- mr.command("high")
293
- .description("Set memory density to high (80 results, ~24k tokens) [default]")
294
- .action(async () => {
295
- if (!memoryKey) {
296
- console.error("MemoryRouter not configured. Run: openclaw mr <key>");
297
- return;
298
- }
299
- try {
300
- await setPluginConfig(api, { key: memoryKey, endpoint: cfg?.endpoint, density: 'high' });
301
- console.log("✓ Memory density set to high (80 results, ~24k tokens)");
302
- } catch (err) {
303
- console.error(`Failed to set density: ${err instanceof Error ? err.message : String(err)}`);
304
- }
305
- });
460
+ for (const [name, desc] of [
461
+ ["xhigh", "Set density to xhigh (160 results, ~50k tokens)"],
462
+ ["high", "Set density to high (80 results, ~24k tokens) [default]"],
463
+ ["low", "Set density to low (40 results, ~12k tokens)"],
464
+ ] as const) {
465
+ mr.command(name)
466
+ .description(desc)
467
+ .action(async () => {
468
+ if (!memoryKey) { console.error("Not configured. Run: openclaw mr <key>"); return; }
469
+ try {
470
+ await setPluginConfig(api, { key: memoryKey, endpoint: cfg?.endpoint, density: name });
471
+ console.log(`✓ Memory density set to ${name}`);
472
+ } catch (err) {
473
+ console.error(`Failed: ${err instanceof Error ? err.message : String(err)}`);
474
+ }
475
+ });
476
+ }
306
477
 
307
- mr.command("low")
308
- .description("Set memory density to low (40 results, ~12k tokens)")
309
- .action(async () => {
310
- if (!memoryKey) {
311
- console.error("MemoryRouter not configured. Run: openclaw mr <key>");
312
- return;
313
- }
314
- try {
315
- await setPluginConfig(api, { key: memoryKey, endpoint: cfg?.endpoint, density: 'low' });
316
- console.log("✓ Memory density set to low (40 results, ~12k tokens)");
317
- console.log(" Lighter context for faster responses or smaller models.");
318
- } catch (err) {
319
- console.error(`Failed to set density: ${err instanceof Error ? err.message : String(err)}`);
320
- }
321
- });
478
+ // Mode commands
479
+ for (const [modeName, modeDesc] of [
480
+ ["relay", "Relay mode — hooks only, works on stock OpenClaw [default]"],
481
+ ["proxy", "Proxy mode — memory on every LLM call (requires registerStreamFnWrapper)"],
482
+ ] as const) {
483
+ mr.command(modeName)
484
+ .description(modeDesc)
485
+ .action(async () => {
486
+ if (!memoryKey) { console.error("Not configured. Run: openclaw mr <key>"); return; }
487
+ try {
488
+ await setPluginConfig(api, { key: memoryKey, endpoint: cfg?.endpoint, density, mode: modeName });
489
+ console.log(`✓ Mode set to ${modeName}`);
490
+ } catch (err) {
491
+ console.error(`Failed: ${err instanceof Error ? err.message : String(err)}`);
492
+ }
493
+ });
494
+ }
322
495
 
323
496
  mr.command("status")
324
497
  .description("Show MemoryRouter vault stats")
325
498
  .option("--json", "JSON output")
326
499
  .action(async (opts) => {
327
- if (!memoryKey) {
328
- console.error("MemoryRouter not configured. Run: openclaw mr <key>");
329
- return;
330
- }
500
+ if (!memoryKey) { console.error("Not configured. Run: openclaw mr <key>"); return; }
331
501
  try {
332
502
  const res = await fetch(`${endpoint}/v1/memory/stats`, {
333
503
  headers: { Authorization: `Bearer ${memoryKey}` },
334
504
  });
335
- const data = await res.json() as Record<string, unknown>;
336
-
505
+ const data = (await res.json()) as { totalVectors?: number; totalTokens?: number };
337
506
  if (opts.json) {
338
- console.log(JSON.stringify({ enabled: true, key: memoryKey, density, stats: data }, null, 2));
507
+ console.log(JSON.stringify({ enabled: true, key: memoryKey, density, mode, stats: data }, null, 2));
339
508
  } else {
340
509
  console.log("MemoryRouter Status");
341
510
  console.log("───────────────────────────");
342
511
  console.log(`Enabled: ✓ Yes`);
343
512
  console.log(`Key: ${memoryKey.slice(0, 6)}...${memoryKey.slice(-3)}`);
344
- console.log(`Density: ${density} (openclaw mr xhigh|high|low)`);
513
+ console.log(`Mode: ${mode}`);
514
+ console.log(`Density: ${density}`);
345
515
  console.log(`Endpoint: ${endpoint}`);
346
- console.log("");
347
- console.log("Vault Stats:");
348
- const stats = data as { totalVectors?: number; totalTokens?: number };
349
- console.log(` Memories: ${stats.totalVectors ?? 0}`);
350
- console.log(` Tokens: ${stats.totalTokens ?? 0}`);
516
+ console.log(`Memories: ${data.totalVectors ?? 0}`);
517
+ console.log(`Tokens: ${data.totalTokens ?? 0}`);
351
518
  }
352
519
  } catch (err) {
353
- console.error(`Failed to fetch stats: ${err instanceof Error ? err.message : String(err)}`);
520
+ console.error(`Failed: ${err instanceof Error ? err.message : String(err)}`);
354
521
  }
355
522
  });
356
523
 
357
524
  mr.command("upload")
358
525
  .description("Upload workspace + session history to vault")
359
526
  .argument("[path]", "Specific file or directory to upload")
360
- .option("--workspace <dir>", "Workspace directory (default: cwd)")
361
- .option("--brain <dir>", "State directory with sessions (default: ~/.openclaw)")
527
+ .option("--workspace <dir>", "Workspace directory")
528
+ .option("--brain <dir>", "State directory with sessions")
362
529
  .action(async (targetPath: string | undefined, opts: { workspace?: string; brain?: string }) => {
363
- if (!memoryKey) {
364
- console.error("MemoryRouter not configured. Run: openclaw mr <key>");
365
- return;
366
- }
530
+ if (!memoryKey) { console.error("Not configured. Run: openclaw mr <key>"); return; }
367
531
  const os = await import("node:os");
368
532
  const path = await import("node:path");
369
533
  const stateDir = opts.brain ? path.resolve(opts.brain) : path.join(os.homedir(), ".openclaw");
370
- // Use OpenClaw's configured workspace, not cwd
371
- const configWorkspace = api.config.workspace || api.config.agents?.defaults?.workspace;
534
+ const configWorkspace = (api.config as any).workspace || (api.config as any).agents?.defaults?.workspace;
372
535
  const workspacePath = opts.workspace
373
536
  ? path.resolve(opts.workspace)
374
537
  : configWorkspace
375
538
  ? path.resolve(configWorkspace.replace(/^~/, os.homedir()))
376
539
  : path.join(os.homedir(), ".openclaw", "workspace");
377
540
  const { runUpload } = await import("./upload.js");
378
- await runUpload({
379
- memoryKey,
380
- endpoint,
381
- targetPath,
382
- stateDir,
383
- workspacePath,
384
- hasWorkspaceFlag: !!opts.workspace,
385
- hasBrainFlag: !!opts.brain,
386
- });
541
+ await runUpload({ memoryKey, endpoint, targetPath, stateDir, workspacePath, hasWorkspaceFlag: !!opts.workspace, hasBrainFlag: !!opts.brain });
387
542
  });
388
543
 
389
544
  mr.command("delete")
390
545
  .description("Clear all memories from vault")
391
546
  .action(async () => {
392
- if (!memoryKey) {
393
- console.error("MemoryRouter not configured. Run: openclaw mr <key>");
394
- return;
395
- }
547
+ if (!memoryKey) { console.error("Not configured. Run: openclaw mr <key>"); return; }
396
548
  try {
397
549
  const res = await fetch(`${endpoint}/v1/memory`, {
398
550
  method: "DELETE",
399
551
  headers: { Authorization: `Bearer ${memoryKey}` },
400
552
  });
401
- const data = await res.json() as { message?: string };
553
+ const data = (await res.json()) as { message?: string };
402
554
  console.log(`✓ ${data.message || "Vault cleared"}`);
403
555
  } catch (err) {
404
- console.error(`Failed to clear vault: ${err instanceof Error ? err.message : String(err)}`);
556
+ console.error(`Failed: ${err instanceof Error ? err.message : String(err)}`);
405
557
  }
406
558
  });
407
559
  },
@@ -415,9 +567,7 @@ const memoryRouterPlugin = {
415
567
  api.registerService({
416
568
  id: "mr-memory",
417
569
  start: () => {
418
- if (memoryKey) {
419
- api.logger.info?.(`memoryrouter: active (key: ${memoryKey.slice(0, 6)}...)`);
420
- }
570
+ if (memoryKey) api.logger.info?.(`memoryrouter: active (key: ${memoryKey.slice(0, 6)}...)`);
421
571
  },
422
572
  stop: () => {
423
573
  api.logger.info?.("memoryrouter: stopped");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mr-memory",
3
- "version": "2.0.0",
3
+ "version": "2.3.0",
4
4
  "description": "MemoryRouter persistent memory plugin for OpenClaw — your AI remembers every conversation",
5
5
  "type": "module",
6
6
  "files": [