@mem9/mem9 0.3.7 → 0.4.0-rc.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 CHANGED
@@ -1,6 +1,6 @@
1
- # OpenClaw Plugin for mnemos
1
+ # OpenClaw Plugin for mem9
2
2
 
3
- Memory plugin for [OpenClaw](https://github.com/openclaw) — replaces the built-in memory slot with cloud-persistent shared memory. Runs in server mode only, connecting to `mnemo-server` via `apiUrl` + `apiKey` (preferred) or legacy `tenantID`.
3
+ Memory plugin for [OpenClaw](https://github.com/openclaw) — replaces the built-in memory slot with cloud-persistent shared memory. Runs in server mode only, connecting to `mnemo-server` via `apiUrl` + `apiKey` (preferred) or legacy `tenantID`. Optional `provisionQueryParams` can forward `utm_*` attribution only during first-time auto-provisioning.
4
4
 
5
5
  ## 🚀 Quick Start (Server Mode)
6
6
 
@@ -20,18 +20,19 @@ curl -s -X POST http://localhost:8080/v1alpha1/mem9s \
20
20
  # {"id": "uuid"}
21
21
  ```
22
22
 
23
- Add mnemo to your project's `openclaw.json`:
23
+ Add mem9 to your project's `openclaw.json`:
24
24
 
25
25
  ```json
26
26
  {
27
27
  "plugins": {
28
- "slots": { "memory": "openclaw" },
28
+ "slots": { "memory": "mem9" },
29
29
  "entries": {
30
- "openclaw": {
30
+ "mem9": {
31
31
  "enabled": true,
32
32
  "config": {
33
33
  "apiUrl": "http://localhost:8080",
34
- "apiKey": "uuid"
34
+ "apiKey": "uuid",
35
+ "searchTimeoutMs": 15000
35
36
  }
36
37
  }
37
38
  }
@@ -63,8 +64,8 @@ This is a `kind: "memory"` plugin — OpenClaw's framework manages when to load/
63
64
 
64
65
  | Hook | Trigger | What it does |
65
66
  |---|---|---|
66
- | `before_prompt_build` | Every LLM call | Searches memories by current prompt, injects relevant ones as context (3-min TTL cache) |
67
- | `after_compaction` | After `/compact` | Invalidates cache so the next prompt gets fresh memories from the database |
67
+ | `before_prompt_build` | Every LLM call | Searches memories by current prompt and injects relevant ones as context |
68
+ | `after_compaction` | After `/compact` | Logs compaction so the next prompt re-queries memories from the server |
68
69
  | `before_reset` | Before `/reset` | Saves a session summary (last 3 user messages) as memory before context is wiped |
69
70
  | `agent_end` | Agent finishes | Auto-captures the last assistant response as memory (if substantial) |
70
71
 
@@ -72,7 +73,7 @@ This is a `kind: "memory"` plugin — OpenClaw's framework manages when to load/
72
73
 
73
74
  | Tool | Description |
74
75
  |---|---|
75
- | `memory_store` | Store a new memory (upsert by key) |
76
+ | `memory_store` | Store a new memory |
76
77
  | `memory_search` | Hybrid vector + keyword search (or keyword-only) |
77
78
  | `memory_get` | Retrieve a single memory by ID |
78
79
  | `memory_update` | Update an existing memory |
@@ -90,7 +91,7 @@ This is a `kind: "memory"` plugin — OpenClaw's framework manages when to load/
90
91
  ### Method A: npm install (Recommended)
91
92
 
92
93
  ```bash
93
- openclaw plugins install @mem9/openclaw
94
+ openclaw plugins install @mem9/mem9
94
95
  ```
95
96
 
96
97
  ### Method B: From source
@@ -103,7 +104,7 @@ npm install
103
104
 
104
105
  ### Configure OpenClaw
105
106
 
106
- Add mnemo to your project's `openclaw.json`:
107
+ Add mem9 to your project's `openclaw.json`:
107
108
 
108
109
  OpenClaw is often deployed across teams with multiple agents. Server mode gives you:
109
110
 
@@ -138,10 +139,10 @@ Each agent uses the same `apiKey` for the shared memory pool. The plugin sends t
138
139
  {
139
140
  "plugins": {
140
141
  "slots": {
141
- "memory": "openclaw"
142
+ "memory": "mem9"
142
143
  },
143
144
  "entries": {
144
- "openclaw": {
145
+ "mem9": {
145
146
  "enabled": true,
146
147
  "config": {
147
148
  "apiUrl": "http://your-server:8080",
@@ -159,8 +160,8 @@ That's it. The server handles scoping and conflict resolution. Conceptually, the
159
160
 
160
161
  Start OpenClaw. You should see:
161
162
 
162
- ```
163
- [mem9] Server mode
163
+ ```text
164
+ [mem9] Server mode (v1alpha2)
164
165
  ```
165
166
 
166
167
  If you see `[mem9] No mode configured...`, check your `openclaw.json` config.
@@ -173,9 +174,39 @@ Defined in `openclaw.plugin.json`:
173
174
  |---|---|---|
174
175
  | `apiUrl` | string | mnemo-server URL |
175
176
  | `apiKey` | string | Preferred key. Uses `/v1alpha2/mem9s/...` with `X-API-Key` header |
177
+ | `provisionQueryParams` | object | Optional `utm_*` map forwarded only to the initial `POST /v1alpha1/mem9s` auto-provision request when `apiKey` is absent |
178
+ | `defaultTimeoutMs` | number | Default timeout for non-search mem9 API requests in milliseconds. Default: `8000` |
179
+ | `searchTimeoutMs` | number | Timeout for `memory_search` and automatic recall search in milliseconds. Default: `15000` |
176
180
  | `tenantID` | string | Legacy alias for `apiKey`. The plugin still uses `/v1alpha2/mem9s/...` with `X-API-Key`. |
177
181
 
178
- > **Note**: `apiKey` takes precedence when both fields are set. If only `tenantID` is present, the plugin treats it as a legacy alias for `apiKey`, still uses v1alpha2, and logs a deprecation warning once at startup.
182
+ > **Note**: `apiKey` takes precedence when both fields are set. If only `tenantID` is present, the plugin treats it as a legacy alias for `apiKey`, still uses v1alpha2, and logs a deprecation warning once at startup. `provisionQueryParams` is ignored after an `apiKey` is already configured, and non-`utm_*` keys are dropped before the provision request is sent.
183
+
184
+ ## Timeout Behavior
185
+
186
+ The plugin uses two timeout buckets:
187
+
188
+ - `searchTimeoutMs` applies to `memory_search` and the automatic recall search in `before_prompt_build`
189
+ - `defaultTimeoutMs` applies to all other mem9 HTTP requests, including register, store, get, update, delete, and ingest
190
+
191
+ Example:
192
+
193
+ ```json
194
+ {
195
+ "plugins": {
196
+ "entries": {
197
+ "openclaw": {
198
+ "enabled": true,
199
+ "config": {
200
+ "apiUrl": "http://your-server:8080",
201
+ "apiKey": "uuid",
202
+ "defaultTimeoutMs": 8000,
203
+ "searchTimeoutMs": 15000
204
+ }
205
+ }
206
+ }
207
+ }
208
+ }
209
+ ```
179
210
 
180
211
  ## File Structure
181
212
 
@@ -183,7 +214,7 @@ Defined in `openclaw.plugin.json`:
183
214
  openclaw-plugin/
184
215
  ├── README.md # This file
185
216
  ├── openclaw.plugin.json # Plugin metadata + config schema
186
- ├── package.json # npm package (@mem9/openclaw)
217
+ ├── package.json # npm package (@mem9/mem9)
187
218
  ├── index.ts # Plugin entry point + tool registration
188
219
  ├── backend.ts # MemoryBackend interface
189
220
  ├── server-backend.ts # Server mode: fetch → mnemo API
@@ -197,4 +228,5 @@ openclaw-plugin/
197
228
  |---|---|---|
198
229
  | `No mode configured` | Missing config | Add `apiUrl` and `apiKey` (or legacy `tenantID`) to plugin config |
199
230
  | `Server mode requires...` | Missing key | Add `apiKey` (or legacy `tenantID`) to config |
200
- | Plugin not loading | Not in memory slot | Set `"slots": {"memory": "openclaw"}` in openclaw.json |
231
+ | Search requests time out | Hybrid/vector search exceeds plugin timeout | Increase `searchTimeoutMs` in plugin config |
232
+ | Plugin not loading | Not in memory slot | Set `"slots": {"memory": "mem9"}` in openclaw.json |
package/hooks.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Provides automatic memory recall and capture via OpenClaw's hook system:
5
5
  * - before_prompt_build: inject relevant memories into every LLM call
6
- * (grouped by type: pinned → insights)
6
+ * (preserving the server/backend recall order)
7
7
  * - after_compaction: (no-op placeholder for future use)
8
8
  * - before_reset: save session context before /reset wipes it
9
9
  * - agent_end: auto-capture via smart pipeline with size-aware message selection
@@ -57,6 +57,8 @@ interface HookContext {
57
57
  sessionId?: string;
58
58
  /** Legacy alias for sessionId used by older OpenClaw versions. */
59
59
  sessionKey?: string;
60
+ /** What initiated this agent run: "user", "heartbeat", "cron", or "memory". */
61
+ trigger?: string;
60
62
  }
61
63
 
62
64
  // ---------------------------------------------------------------------------
@@ -105,27 +107,11 @@ function escapeForPrompt(text: string): string {
105
107
  }
106
108
 
107
109
  /**
108
- * Format memories for injection, grouped by type for maximum comprehension:
109
- * 1. Pinned memories first (user-explicit preferences)
110
- * 2. Insights (extracted facts)
110
+ * Format memories for injection while preserving the backend recall order.
111
111
  */
112
112
  function formatMemoriesBlock(memories: Memory[]): string {
113
113
  if (memories.length === 0) return "";
114
114
 
115
- // Group by memory_type, falling back to "pinned" for legacy memories
116
- const pinned: Memory[] = [];
117
- const insights: Memory[] = [];
118
- const other: Memory[] = [];
119
-
120
- for (const m of memories) {
121
- const mtype = m.memory_type ?? "pinned";
122
- switch (mtype) {
123
- case "pinned": pinned.push(m); break;
124
- case "insight": insights.push(m); break;
125
- default: other.push(m); break;
126
- }
127
- }
128
-
129
115
  const lines: string[] = [];
130
116
  let idx = 1;
131
117
 
@@ -140,18 +126,8 @@ function formatMemoriesBlock(memories: Memory[]): string {
140
126
  return `${idx++}.${sep}${escapeForPrompt(content)}`;
141
127
  };
142
128
 
143
- if (pinned.length > 0) {
144
- lines.push("[Preferences]");
145
- for (const m of pinned) lines.push(formatMem(m));
146
- }
147
- if (insights.length > 0) {
148
- if (lines.length > 0) lines.push("");
149
- lines.push("[Knowledge]");
150
- for (const m of insights) lines.push(formatMem(m));
151
- }
152
- if (other.length > 0) {
153
- if (lines.length > 0) lines.push("");
154
- for (const m of other) lines.push(formatMem(m));
129
+ for (const memory of memories) {
130
+ lines.push(formatMem(memory));
155
131
  }
156
132
 
157
133
  return [
@@ -292,6 +268,12 @@ export function registerHooks(
292
268
  const hookCtx = (context ?? {}) as HookContext;
293
269
  if (!evt?.success || !evt.messages || evt.messages.length === 0) return;
294
270
 
271
+ // Skip cron/heartbeat-triggered runs — they produce low-value messages
272
+ if (hookCtx.trigger === "cron" || hookCtx.trigger === "heartbeat") {
273
+ logger.info(`[mem9] Skipping auto-ingest for ${hookCtx.trigger}-triggered run`);
274
+ return;
275
+ }
276
+
295
277
  // Format raw messages into IngestMessage format
296
278
  const formatted: IngestMessage[] = [];
297
279
  for (const msg of evt.messages) {
@@ -300,6 +282,11 @@ export function registerHooks(
300
282
  const role = typeof m.role === "string" ? m.role : "";
301
283
  if (!role) continue;
302
284
 
285
+ // Skip cron tool results — structured JSON job definitions with no memory value
286
+ if (role === "toolResult" && typeof m.toolName === "string" && m.toolName === "cron") {
287
+ continue;
288
+ }
289
+
303
290
  let content = "";
304
291
  if (typeof m.content === "string") {
305
292
  content = m.content;
package/index.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  import type { MemoryBackend } from "./backend.js";
2
- import { ServerBackend } from "./server-backend.js";
2
+ import {
3
+ DEFAULT_SEARCH_TIMEOUT_MS,
4
+ DEFAULT_TIMEOUT_MS,
5
+ ServerBackend,
6
+ type BackendTimeouts,
7
+ } from "./server-backend.js";
3
8
  import { registerHooks } from "./hooks.js";
4
9
  import type {
5
10
  PluginConfig,
@@ -12,8 +17,49 @@ import type {
12
17
  } from "./types.js";
13
18
 
14
19
  const DEFAULT_API_URL = "https://api.mem9.ai";
15
- const MEM9_MEMORY_PATH_PREFIX = "mem9/";
16
- const MEM9_MEMORY_PROVIDER = "mem9";
20
+ const TIMEOUT_FIELDS = ["defaultTimeoutMs", "searchTimeoutMs"] as const;
21
+
22
+ function normalizeTimeoutMs(
23
+ value: unknown,
24
+ field: (typeof TIMEOUT_FIELDS)[number],
25
+ fallback: number,
26
+ logger: OpenClawPluginApi["logger"],
27
+ ): number {
28
+ if (value == null) return fallback;
29
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
30
+ logger.info(`[mem9] invalid ${field}; using ${fallback}ms`);
31
+ return fallback;
32
+ }
33
+ return Math.floor(value);
34
+ }
35
+
36
+ function resolveTimeouts(
37
+ cfg: PluginConfig,
38
+ logger: OpenClawPluginApi["logger"],
39
+ ): Required<BackendTimeouts> {
40
+ const timeouts = {
41
+ defaultTimeoutMs: normalizeTimeoutMs(
42
+ cfg.defaultTimeoutMs,
43
+ "defaultTimeoutMs",
44
+ DEFAULT_TIMEOUT_MS,
45
+ logger,
46
+ ),
47
+ searchTimeoutMs: normalizeTimeoutMs(
48
+ cfg.searchTimeoutMs,
49
+ "searchTimeoutMs",
50
+ DEFAULT_SEARCH_TIMEOUT_MS,
51
+ logger,
52
+ ),
53
+ };
54
+
55
+ if (TIMEOUT_FIELDS.some((field) => cfg[field] != null)) {
56
+ logger.info(
57
+ `[mem9] timeout config: defaultTimeoutMs=${timeouts.defaultTimeoutMs}, searchTimeoutMs=${timeouts.searchTimeoutMs}`,
58
+ );
59
+ }
60
+
61
+ return timeouts;
62
+ }
17
63
 
18
64
  function jsonResult(data: unknown) {
19
65
  // Older OpenClaw versions may assume tool results have a normalized
@@ -29,69 +75,6 @@ function jsonResult(data: unknown) {
29
75
  }
30
76
  }
31
77
 
32
- function buildMemoryPath(id: string): string {
33
- return `${MEM9_MEMORY_PATH_PREFIX}${id}`;
34
- }
35
-
36
- function normalizeMemoryLookup(value: string): string {
37
- const trimmed = value.trim();
38
- return trimmed.startsWith(MEM9_MEMORY_PATH_PREFIX)
39
- ? trimmed.slice(MEM9_MEMORY_PATH_PREFIX.length)
40
- : trimmed;
41
- }
42
-
43
- function normalizeSnippet(text: string, maxLength = 240): string {
44
- const flattened = text.replace(/\s+/g, " ").trim();
45
- if (flattened.length <= maxLength) {
46
- return flattened;
47
- }
48
- return `${flattened.slice(0, maxLength - 3)}...`;
49
- }
50
-
51
- function countLines(text: string): number {
52
- if (!text) {
53
- return 1;
54
- }
55
- return text.split(/\r?\n/).length;
56
- }
57
-
58
- function buildPromptSection(params: {
59
- availableTools: Set<string>;
60
- citationsMode?: string;
61
- }): string[] {
62
- const hasMemorySearch = params.availableTools.has("memory_search");
63
- const hasMemoryGet = params.availableTools.has("memory_get");
64
-
65
- if (!hasMemorySearch && !hasMemoryGet) {
66
- return [];
67
- }
68
-
69
- let toolGuidance: string;
70
- if (hasMemorySearch && hasMemoryGet) {
71
- toolGuidance =
72
- "Before answering anything about prior work, decisions, dates, people, preferences, or todos: run `memory_search` first, then use `memory_get` to pull only the needed memory. If low confidence after search, say you checked.";
73
- } else if (hasMemorySearch) {
74
- toolGuidance =
75
- "Before answering anything about prior work, decisions, dates, people, preferences, or todos: run `memory_search` first and answer from the matching results. If low confidence after search, say you checked.";
76
- } else {
77
- toolGuidance =
78
- "Before answering anything about prior work, decisions, dates, people, preferences, or todos that already point to a specific memory: run `memory_get` to pull only the needed memory. If low confidence after reading it, say you checked.";
79
- }
80
-
81
- const lines = ["## Memory Recall", toolGuidance];
82
- if (params.citationsMode === "off") {
83
- lines.push(
84
- "Citations are disabled: do not mention memory identifiers unless the user explicitly asks.",
85
- );
86
- } else {
87
- lines.push(
88
- "Citations: include the memory identifier only when it helps the user verify recalled memory.",
89
- );
90
- }
91
- lines.push("");
92
- return lines;
93
- }
94
-
95
78
  interface MemoryCapability {
96
79
  search: (query: string, opts?: { limit?: number }) => Promise<{ data: Memory[]; total: number }>;
97
80
  store: (content: string, opts?: { tags?: string[]; source?: string }) => Promise<unknown>;
@@ -99,71 +82,6 @@ interface MemoryCapability {
99
82
  remove: (id: string) => Promise<boolean>;
100
83
  }
101
84
 
102
- interface MemoryPromptSectionBuilder {
103
- (params: { availableTools: Set<string>; citationsMode?: string }): string[];
104
- }
105
-
106
- interface MemorySearchResult {
107
- path: string;
108
- startLine: number;
109
- endLine: number;
110
- score: number;
111
- snippet: string;
112
- source: "memory";
113
- citation?: string;
114
- }
115
-
116
- interface MemoryProviderStatus {
117
- backend: "builtin" | "qmd";
118
- provider: string;
119
- requestedProvider?: string;
120
- custom?: Record<string, unknown>;
121
- }
122
-
123
- interface MemorySearchManager {
124
- search: (
125
- query: string,
126
- opts?: { maxResults?: number; minScore?: number; sessionKey?: string },
127
- ) => Promise<MemorySearchResult[]>;
128
- readFile: (params: {
129
- relPath: string;
130
- from?: number;
131
- lines?: number;
132
- }) => Promise<{ text: string; path: string }>;
133
- status: () => MemoryProviderStatus;
134
- sync?: (params?: {
135
- reason?: string;
136
- force?: boolean;
137
- sessionFiles?: string[];
138
- progress?: (update: { completed: number; total: number; label?: string }) => void;
139
- }) => Promise<void>;
140
- probeEmbeddingAvailability: () => Promise<{ ok: boolean; error?: string }>;
141
- probeVectorAvailability: () => Promise<boolean>;
142
- close?: () => Promise<void>;
143
- }
144
-
145
- interface MemoryRuntime {
146
- getMemorySearchManager: (params: {
147
- cfg?: unknown;
148
- agentId: string;
149
- purpose?: "default" | "status";
150
- }) => Promise<{ manager: MemorySearchManager | null; error?: string }>;
151
- resolveMemoryBackendConfig: (params: { cfg?: unknown; agentId: string }) => {
152
- backend: "builtin" | "qmd";
153
- provider: string;
154
- requestedProvider?: string;
155
- custom?: Record<string, unknown>;
156
- };
157
- closeAllMemorySearchManagers?: () => Promise<void>;
158
- }
159
-
160
- interface MemorySlotCapability {
161
- promptBuilder?: MemoryPromptSectionBuilder;
162
- runtime?: MemoryRuntime;
163
- flushPlanResolver?: unknown;
164
- publicArtifacts?: unknown;
165
- }
166
-
167
85
  interface OpenClawPluginApi {
168
86
  pluginConfig?: unknown;
169
87
  logger: {
@@ -174,9 +92,6 @@ interface OpenClawPluginApi {
174
92
  factory: ToolFactory | (() => AnyAgentTool[]),
175
93
  opts: { names: string[] }
176
94
  ) => void;
177
- registerMemoryCapability?: (capability: MemorySlotCapability) => void;
178
- registerMemoryPromptSection?: (builder: MemoryPromptSectionBuilder) => void;
179
- registerMemoryRuntime?: (runtime: MemoryRuntime) => void;
180
95
  registerCapability?: (slot: string, capability: MemoryCapability) => void;
181
96
  on: (hookName: string, handler: (...args: unknown[]) => unknown, opts?: { priority?: number }) => void;
182
97
  }
@@ -202,146 +117,6 @@ interface AnyAgentTool {
202
117
  execute: (_id: string, params: unknown) => Promise<unknown>;
203
118
  }
204
119
 
205
- class Mem9MemorySearchManager implements MemorySearchManager {
206
- constructor(
207
- private backend: MemoryBackend,
208
- private apiUrl: string,
209
- ) {}
210
-
211
- async search(
212
- query: string,
213
- opts?: { maxResults?: number; minScore?: number; sessionKey?: string },
214
- ): Promise<MemorySearchResult[]> {
215
- void opts?.sessionKey;
216
- const result = await this.backend.search({
217
- q: query,
218
- limit: opts?.maxResults,
219
- });
220
- return (result.data ?? [])
221
- .map((memory, index) => {
222
- const startLine = 1;
223
- const endLine = countLines(memory.content);
224
- const score =
225
- typeof memory.score === "number"
226
- ? memory.score
227
- : Math.max(0, 1 - index / Math.max(result.data.length, 1));
228
- return {
229
- path: buildMemoryPath(memory.id),
230
- startLine,
231
- endLine,
232
- score,
233
- snippet: normalizeSnippet(memory.content),
234
- source: "memory" as const,
235
- citation: `${buildMemoryPath(memory.id)}#L${startLine}`,
236
- };
237
- })
238
- .filter((entry) => opts?.minScore == null || entry.score >= opts.minScore);
239
- }
240
-
241
- async readFile(params: {
242
- relPath: string;
243
- from?: number;
244
- lines?: number;
245
- }): Promise<{ text: string; path: string }> {
246
- const lookup = normalizeMemoryLookup(params.relPath);
247
- const memory = await this.backend.get(lookup);
248
- if (!memory) {
249
- return { text: "", path: params.relPath };
250
- }
251
-
252
- const contentLines = memory.content.split(/\r?\n/);
253
- const fromLine = Math.max(1, params.from ?? 1);
254
- const lineCount = params.lines == null ? contentLines.length : Math.max(0, params.lines);
255
- const sliced =
256
- params.lines == null
257
- ? contentLines.slice(fromLine - 1)
258
- : contentLines.slice(fromLine - 1, fromLine - 1 + lineCount);
259
-
260
- return {
261
- text: sliced.join("\n"),
262
- path: buildMemoryPath(memory.id),
263
- };
264
- }
265
-
266
- status(): MemoryProviderStatus {
267
- return {
268
- backend: "builtin",
269
- provider: MEM9_MEMORY_PROVIDER,
270
- requestedProvider: MEM9_MEMORY_PROVIDER,
271
- custom: {
272
- mode: "remote",
273
- apiUrl: this.apiUrl,
274
- },
275
- };
276
- }
277
-
278
- async sync(): Promise<void> {}
279
-
280
- async probeEmbeddingAvailability(): Promise<{ ok: boolean; error?: string }> {
281
- try {
282
- await this.backend.search({ q: "mem9-healthcheck", limit: 1 });
283
- return { ok: true };
284
- } catch (err) {
285
- return {
286
- ok: false,
287
- error: err instanceof Error ? err.message : String(err),
288
- };
289
- }
290
- }
291
-
292
- async probeVectorAvailability(): Promise<boolean> {
293
- const probe = await this.probeEmbeddingAvailability();
294
- return probe.ok;
295
- }
296
-
297
- async close(): Promise<void> {}
298
- }
299
-
300
- function createMemoryRuntime(params: {
301
- apiUrl: string;
302
- fallbackAgentId: string;
303
- createBackend: (agentId: string) => MemoryBackend;
304
- }): MemoryRuntime {
305
- const managers = new Map<string, Mem9MemorySearchManager>();
306
-
307
- const getOrCreateManager = (agentId: string): Mem9MemorySearchManager => {
308
- const resolvedAgentId = agentId.trim() || params.fallbackAgentId;
309
- const existing = managers.get(resolvedAgentId);
310
- if (existing) {
311
- return existing;
312
- }
313
- const next = new Mem9MemorySearchManager(
314
- params.createBackend(resolvedAgentId),
315
- params.apiUrl,
316
- );
317
- managers.set(resolvedAgentId, next);
318
- return next;
319
- };
320
-
321
- return {
322
- async getMemorySearchManager({ agentId }) {
323
- return { manager: getOrCreateManager(agentId) };
324
- },
325
- resolveMemoryBackendConfig() {
326
- return {
327
- backend: "builtin",
328
- provider: MEM9_MEMORY_PROVIDER,
329
- requestedProvider: MEM9_MEMORY_PROVIDER,
330
- custom: {
331
- mode: "remote",
332
- apiUrl: params.apiUrl,
333
- },
334
- };
335
- },
336
- async closeAllMemorySearchManagers() {
337
- for (const manager of managers.values()) {
338
- await manager.close?.();
339
- }
340
- managers.clear();
341
- },
342
- };
343
- }
344
-
345
120
  function buildTools(backend: MemoryBackend): AnyAgentTool[] {
346
121
  return [
347
122
  {
@@ -395,10 +170,6 @@ function buildTools(backend: MemoryBackend): AnyAgentTool[] {
395
170
  type: "object",
396
171
  properties: {
397
172
  q: { type: "string", description: "Search query" },
398
- query: {
399
- type: "string",
400
- description: "Search query alias for hosts that expect `query` instead of `q`",
401
- },
402
173
  tags: {
403
174
  type: "string",
404
175
  description: "Comma-separated tags to filter by (AND)",
@@ -418,19 +189,9 @@ function buildTools(backend: MemoryBackend): AnyAgentTool[] {
418
189
  },
419
190
  async execute(_id: string, params: unknown) {
420
191
  try {
421
- const input = { ...((params ?? {}) as SearchInput) };
422
- if (!input.q && typeof (params as { query?: unknown } | null)?.query === "string") {
423
- input.q = (params as { query: string }).query;
424
- }
192
+ const input = (params ?? {}) as SearchInput;
425
193
  const result = await backend.search(input);
426
- return jsonResult({
427
- ok: true,
428
- ...result,
429
- data: (result.data ?? []).map((memory) => ({
430
- ...memory,
431
- path: buildMemoryPath(memory.id),
432
- })),
433
- });
194
+ return jsonResult({ ok: true, ...result });
434
195
  } catch (err) {
435
196
  return jsonResult({
436
197
  ok: false,
@@ -448,31 +209,16 @@ function buildTools(backend: MemoryBackend): AnyAgentTool[] {
448
209
  type: "object",
449
210
  properties: {
450
211
  id: { type: "string", description: "Memory id (UUID)" },
451
- path: {
452
- type: "string",
453
- description: "Memory lookup path alias, e.g. mem9/<id>",
454
- },
455
212
  },
456
- required: [],
213
+ required: ["id"],
457
214
  },
458
215
  async execute(_id: string, params: unknown) {
459
216
  try {
460
- const raw = params as { id?: string; path?: string };
461
- const lookup = typeof raw.id === "string" ? raw.id : raw.path;
462
- if (!lookup) {
463
- return jsonResult({ ok: false, error: "memory id or path is required" });
464
- }
465
- const id = normalizeMemoryLookup(lookup);
217
+ const { id } = params as { id: string };
466
218
  const result = await backend.get(id);
467
219
  if (!result)
468
220
  return jsonResult({ ok: false, error: "memory not found" });
469
- return jsonResult({
470
- ok: true,
471
- data: {
472
- ...result,
473
- path: buildMemoryPath(result.id),
474
- },
475
- });
221
+ return jsonResult({ ok: true, data: result });
476
222
  } catch (err) {
477
223
  return jsonResult({
478
224
  ok: false,
@@ -552,12 +298,11 @@ const mnemoPlugin = {
552
298
  name: "Mnemo Memory",
553
299
  description:
554
300
  "AI agent memory — server mode (mnemo-server) with hybrid vector + keyword search.",
555
- // Static hint for hosts that inspect entry metadata before runtime registration.
556
- capabilities: ["memory"],
557
301
 
558
302
  register(api: OpenClawPluginApi) {
559
303
  const cfg = (api.pluginConfig ?? {}) as PluginConfig;
560
304
  const effectiveApiUrl = cfg.apiUrl ?? DEFAULT_API_URL;
305
+ const timeoutConfig = resolveTimeouts(cfg, api.logger);
561
306
  if (!cfg.apiUrl) {
562
307
  api.logger.info(`[mem9] apiUrl not configured, using default ${DEFAULT_API_URL}`);
563
308
  }
@@ -569,7 +314,15 @@ const mnemoPlugin = {
569
314
  api.logger.info("[mem9] tenantID is deprecated; treating it as apiKey for v1alpha2");
570
315
  }
571
316
  const registerTenant = async (agentName: string): Promise<string> => {
572
- const backend = new ServerBackend(effectiveApiUrl, "", agentName);
317
+ const backend = new ServerBackend(
318
+ effectiveApiUrl,
319
+ "",
320
+ agentName,
321
+ {
322
+ timeouts: timeoutConfig,
323
+ provisionQueryParams: cfg.provisionQueryParams ?? {},
324
+ },
325
+ );
573
326
  const result = await backend.register();
574
327
  api.logger.info(
575
328
  `[mem9] *** Auto-provisioned apiKey=${result.id} *** Save this to your config as apiKey`
@@ -589,52 +342,32 @@ const mnemoPlugin = {
589
342
 
590
343
  const hookAgentId = cfg.agentName ?? "agent";
591
344
 
592
- const createBackend = (agentId: string): MemoryBackend =>
593
- new LazyServerBackend(
345
+ const factory: ToolFactory = (ctx: ToolContext) => {
346
+ const agentId = ctx.agentId ?? cfg.agentName ?? "agent";
347
+ const backend = new LazyServerBackend(
594
348
  effectiveApiUrl,
595
349
  () => resolveAPIKey(agentId),
596
350
  agentId,
351
+ timeoutConfig,
597
352
  );
598
-
599
- const factory: ToolFactory = (ctx: ToolContext) => {
600
- const agentId = ctx.agentId ?? cfg.agentName ?? "agent";
601
- const backend = createBackend(agentId);
602
353
  return buildTools(backend);
603
354
  };
604
355
 
605
356
  api.registerTool(factory, { names: toolNames });
606
357
 
607
358
  // Shared lazy backend for hooks and capability registration.
608
- const hookBackend = createBackend(hookAgentId);
609
- const memoryRuntime = createMemoryRuntime({
610
- apiUrl: effectiveApiUrl,
611
- fallbackAgentId: hookAgentId,
612
- createBackend,
613
- });
614
-
615
- // OpenClaw's memory slot API has evolved across versions:
616
- // - current hosts prefer registerMemoryCapability({ promptBuilder, runtime })
617
- // - 2026.4.2 uses registerMemoryPromptSection/registerMemoryRuntime
618
- // - older compatibility shims may still expose registerCapability("memory", ...)
619
- if (typeof api.registerMemoryCapability === "function") {
620
- api.registerMemoryCapability({
621
- promptBuilder: buildPromptSection,
622
- runtime: memoryRuntime,
623
- });
624
- } else {
625
- if (typeof api.registerMemoryPromptSection === "function") {
626
- api.registerMemoryPromptSection(buildPromptSection);
627
- }
628
- if (typeof api.registerMemoryRuntime === "function") {
629
- api.registerMemoryRuntime(memoryRuntime);
630
- }
631
- }
359
+ const hookBackend = new LazyServerBackend(
360
+ effectiveApiUrl,
361
+ () => resolveAPIKey(hookAgentId),
362
+ hookAgentId,
363
+ timeoutConfig,
364
+ );
632
365
 
633
- if (
634
- typeof api.registerMemoryCapability !== "function" &&
635
- typeof api.registerMemoryRuntime !== "function" &&
636
- typeof api.registerCapability === "function"
637
- ) {
366
+ // Register memory capability so OpenClaw 2026.4.2+ binds this plugin to
367
+ // the memory slot. Without this, the plugin is treated as a legacy
368
+ // hook-only plugin and automatic context injection won't work.
369
+ // Guard with typeof check for backward compatibility with older hosts.
370
+ if (typeof api.registerCapability === "function") {
638
371
  api.registerCapability("memory", {
639
372
  search: async (query, opts) => {
640
373
  const result = await hookBackend.search({ q: query, limit: opts?.limit });
@@ -672,6 +405,7 @@ class LazyServerBackend implements MemoryBackend {
672
405
  private apiUrl: string,
673
406
  private apiKeyProvider: () => Promise<string>,
674
407
  private agentId: string,
408
+ private timeouts: BackendTimeouts,
675
409
  ) {}
676
410
 
677
411
  private async resolve(): Promise<ServerBackend> {
@@ -680,7 +414,9 @@ class LazyServerBackend implements MemoryBackend {
680
414
 
681
415
  this.resolving = this.apiKeyProvider().then((apiKey) =>
682
416
  Promise.resolve().then(() => {
683
- this.resolved = new ServerBackend(this.apiUrl, apiKey, this.agentId);
417
+ this.resolved = new ServerBackend(this.apiUrl, apiKey, this.agentId, {
418
+ timeouts: this.timeouts,
419
+ });
684
420
  return this.resolved;
685
421
  })
686
422
  ).catch((err) => {
@@ -14,6 +14,23 @@
14
14
  "type": "string",
15
15
  "description": "mem9 API key (secret — do not share)"
16
16
  },
17
+ "provisionQueryParams": {
18
+ "type": "object",
19
+ "description": "Optional utm_* params forwarded only during first-time auto-provisioning",
20
+ "additionalProperties": {
21
+ "type": "string"
22
+ }
23
+ },
24
+ "defaultTimeoutMs": {
25
+ "type": "number",
26
+ "minimum": 1,
27
+ "description": "Default timeout in milliseconds for non-search mem9 API requests (default 8000)"
28
+ },
29
+ "searchTimeoutMs": {
30
+ "type": "number",
31
+ "minimum": 1,
32
+ "description": "Timeout in milliseconds for memory search and automatic recall search (default 15000)"
33
+ },
17
34
  "tenantID": {
18
35
  "type": "string",
19
36
  "description": "Deprecated: use apiKey"
@@ -30,6 +47,14 @@
30
47
  "placeholder": "key...",
31
48
  "sensitive": true
32
49
  },
50
+ "defaultTimeoutMs": {
51
+ "label": "Default Timeout (ms)",
52
+ "placeholder": "8000"
53
+ },
54
+ "searchTimeoutMs": {
55
+ "label": "Search Timeout (ms)",
56
+ "placeholder": "15000"
57
+ },
33
58
  "tenantID": {
34
59
  "label": "Tenant ID (legacy)",
35
60
  "placeholder": "uuid...",
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@mem9/mem9",
3
- "version": "0.3.7",
3
+ "version": "0.4.0-rc.0",
4
4
  "description": "OpenClaw shared memory plugin — cloud-persistent memory with hybrid vector + keyword search via mnemo-server",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "author": "mem9-ai",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "https://github.com/mem9-ai/mem9.git",
10
+ "url": "git+https://github.com/mem9-ai/mem9.git",
11
11
  "directory": "openclaw-plugin"
12
12
  },
13
13
  "homepage": "https://github.com/mem9-ai/mem9/tree/main/openclaw-plugin#readme",
@@ -34,6 +34,7 @@
34
34
  "access": "public"
35
35
  },
36
36
  "scripts": {
37
+ "test": "node --experimental-strip-types --test ./server-backend.test.ts",
37
38
  "typecheck": "tsc --noEmit",
38
39
  "prepublishOnly": "npm run typecheck"
39
40
  },
@@ -42,6 +43,7 @@
42
43
  },
43
44
  "dependencies": {},
44
45
  "devDependencies": {
46
+ "@types/node": "^22.15.30",
45
47
  "typescript": "^5.5.0"
46
48
  },
47
49
  "openclaw": {
@@ -0,0 +1,82 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { ServerBackend } from "./server-backend.ts";
5
+
6
+ test("register forwards only utm_* params during auto-provision", async () => {
7
+ const originalFetch = globalThis.fetch;
8
+ let requestedURL = "";
9
+
10
+ globalThis.fetch = async (input, init) => {
11
+ requestedURL = String(input);
12
+ assert.equal(init?.method, "POST");
13
+
14
+ return new Response(JSON.stringify({ id: "space-1" }), {
15
+ status: 201,
16
+ headers: {
17
+ "Content-Type": "application/json",
18
+ },
19
+ });
20
+ };
21
+
22
+ try {
23
+ const backend = new ServerBackend("https://api.mem9.ai", "", "agent-1", {
24
+ provisionQueryParams: {
25
+ utm_source: "bosn",
26
+ foo: "bar",
27
+ utm_campaign: "spring",
28
+ utm_medium: "",
29
+ },
30
+ });
31
+
32
+ const result = await backend.register();
33
+ assert.equal(result.id, "space-1");
34
+
35
+ const url = new URL(requestedURL);
36
+ assert.equal(url.origin + url.pathname, "https://api.mem9.ai/v1alpha1/mem9s");
37
+ assert.equal(url.searchParams.get("utm_source"), "bosn");
38
+ assert.equal(url.searchParams.get("utm_campaign"), "spring");
39
+ assert.equal(url.searchParams.has("foo"), false);
40
+ assert.equal(url.searchParams.has("utm_medium"), false);
41
+ } finally {
42
+ globalThis.fetch = originalFetch;
43
+ }
44
+ });
45
+
46
+ test("normal memory requests do not append provision query params", async () => {
47
+ const originalFetch = globalThis.fetch;
48
+ let requestedURL = "";
49
+
50
+ globalThis.fetch = async (input) => {
51
+ requestedURL = String(input);
52
+
53
+ return new Response(
54
+ JSON.stringify({
55
+ id: "mem-1",
56
+ content: "remember this",
57
+ created_at: "2026-04-05T00:00:00Z",
58
+ updated_at: "2026-04-05T00:00:00Z",
59
+ }),
60
+ {
61
+ status: 200,
62
+ headers: {
63
+ "Content-Type": "application/json",
64
+ },
65
+ },
66
+ );
67
+ };
68
+
69
+ try {
70
+ const backend = new ServerBackend("https://api.mem9.ai", "space-key", "agent-1", {
71
+ provisionQueryParams: {
72
+ utm_source: "bosn",
73
+ },
74
+ });
75
+
76
+ await backend.store({ content: "remember this" });
77
+
78
+ assert.equal(requestedURL, "https://api.mem9.ai/v1alpha2/mem9s/memories");
79
+ } finally {
80
+ globalThis.fetch = originalFetch;
81
+ }
82
+ });
package/server-backend.ts CHANGED
@@ -14,25 +14,60 @@ type ProvisionMem9sResponse = {
14
14
  id: string;
15
15
  };
16
16
 
17
+ export const DEFAULT_TIMEOUT_MS = 8_000;
18
+ export const DEFAULT_SEARCH_TIMEOUT_MS = 15_000;
19
+
20
+ export interface BackendTimeouts {
21
+ defaultTimeoutMs?: number;
22
+ searchTimeoutMs?: number;
23
+ }
24
+
25
+ interface ServerBackendOptions {
26
+ timeouts?: BackendTimeouts;
27
+ provisionQueryParams?: Record<string, string>;
28
+ }
29
+
30
+ interface RequestOptions {
31
+ timeoutMs?: number;
32
+ }
33
+
17
34
  export class ServerBackend implements MemoryBackend {
18
35
  private baseUrl: string;
19
36
  private apiKey: string;
20
37
  private agentName: string;
38
+ private provisionQueryParams: Record<string, string>;
39
+ private timeouts: Required<BackendTimeouts>;
21
40
 
22
41
  constructor(
23
42
  apiUrl: string,
24
43
  apiKey: string,
25
44
  agentName: string,
45
+ options: ServerBackendOptions = {},
26
46
  ) {
27
47
  this.baseUrl = apiUrl.replace(/\/+$/, "");
28
48
  this.apiKey = apiKey;
29
49
  this.agentName = agentName;
50
+ this.provisionQueryParams = options.provisionQueryParams ?? {};
51
+ this.timeouts = {
52
+ defaultTimeoutMs: options.timeouts?.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS,
53
+ searchTimeoutMs: options.timeouts?.searchTimeoutMs ?? DEFAULT_SEARCH_TIMEOUT_MS,
54
+ };
30
55
  }
31
56
 
32
57
  async register(): Promise<ProvisionMem9sResponse> {
33
- const resp = await fetch(this.baseUrl + "/v1alpha1/mem9s", {
58
+ const query = new URLSearchParams();
59
+ for (const [key, value] of Object.entries(this.provisionQueryParams)) {
60
+ if (!key.startsWith("utm_") || typeof value !== "string" || value === "") {
61
+ continue;
62
+ }
63
+
64
+ query.set(key, value);
65
+ }
66
+
67
+ const qs = query.toString();
68
+ const resp = await fetch(this.baseUrl + "/v1alpha1/mem9s" + (qs ? `?${qs}` : ""), {
34
69
  method: "POST",
35
- signal: AbortSignal.timeout(8_000),
70
+ signal: AbortSignal.timeout(this.timeouts.defaultTimeoutMs),
36
71
  });
37
72
 
38
73
  if (!resp.ok) {
@@ -75,7 +110,12 @@ export class ServerBackend implements MemoryBackend {
75
110
  total: number;
76
111
  limit: number;
77
112
  offset: number;
78
- }>("GET", `${this.memoryPath("/memories")}${qs ? "?" + qs : ""}`);
113
+ }>(
114
+ "GET",
115
+ `${this.memoryPath("/memories")}${qs ? "?" + qs : ""}`,
116
+ undefined,
117
+ { timeoutMs: this.timeouts.searchTimeoutMs },
118
+ );
79
119
  return {
80
120
  data: raw.memories ?? [],
81
121
  total: raw.total,
@@ -116,7 +156,8 @@ export class ServerBackend implements MemoryBackend {
116
156
  private async requestRaw(
117
157
  method: string,
118
158
  path: string,
119
- body?: unknown
159
+ body?: unknown,
160
+ options?: RequestOptions,
120
161
  ): Promise<Response> {
121
162
  const url = this.baseUrl + path;
122
163
  const headers: Record<string, string> = {
@@ -128,16 +169,17 @@ export class ServerBackend implements MemoryBackend {
128
169
  method,
129
170
  headers,
130
171
  body: body != null ? JSON.stringify(body) : undefined,
131
- signal: AbortSignal.timeout(8_000),
172
+ signal: AbortSignal.timeout(options?.timeoutMs ?? this.timeouts.defaultTimeoutMs),
132
173
  });
133
174
  }
134
175
 
135
176
  private async request<T>(
136
177
  method: string,
137
178
  path: string,
138
- body?: unknown
179
+ body?: unknown,
180
+ options?: RequestOptions,
139
181
  ): Promise<T> {
140
- const resp = await this.requestRaw(method, path, body);
182
+ const resp = await this.requestRaw(method, path, body, options);
141
183
 
142
184
  if (resp.status === 204) {
143
185
  return undefined as T;
package/types.ts CHANGED
@@ -2,7 +2,10 @@ export interface PluginConfig {
2
2
  // Server mode (apiUrl present → server)
3
3
  apiUrl?: string;
4
4
  apiKey?: string;
5
+ provisionQueryParams?: Record<string, string>;
5
6
  tenantID?: string;
7
+ defaultTimeoutMs?: number;
8
+ searchTimeoutMs?: number;
6
9
 
7
10
  tenantName?: string;
8
11
 
@@ -25,6 +28,7 @@ export interface Memory {
25
28
  created_at: string;
26
29
  updated_at: string;
27
30
  score?: number;
31
+ confidence?: number;
28
32
 
29
33
  // Smart memory pipeline (server mode)
30
34
  memory_type?: string;