@mem9/mem9 0.4.4 → 0.4.5

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,8 +1,8 @@
1
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`. Optional `provisionQueryParams` can forward `utm_*` attribution only during first-time auto-provisioning.
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 `provisionToken` and `provisionQueryParams` are used only during first-time create-new setup before `apiKey` is written back.
4
4
 
5
- When `apiKey` is absent during create-new onboarding, the plugin coordinates auto-provision across concurrent OpenClaw plugin registrations on the same machine and briefly reuses the generated key until config write-back completes. This avoids duplicate mem9 spaces when the host reloads the plugin multiple times around a restart.
5
+ When `apiKey` is absent during create-new onboarding, the plugin does not auto-provision on startup. Instead, setup must call the explicit `mem9_provision_api_key` tool once after the first restart. The plugin coordinates that call across concurrent OpenClaw plugin registrations on the same machine and briefly reuses the generated key until config write-back completes.
6
6
 
7
7
  ## 🚀 Quick Start (Server Mode)
8
8
 
@@ -176,12 +176,13 @@ Defined in `openclaw.plugin.json`:
176
176
  |---|---|---|
177
177
  | `apiUrl` | string | mnemo-server URL |
178
178
  | `apiKey` | string | Preferred key. Uses `/v1alpha2/mem9s/...` with `X-API-Key` header |
179
- | `provisionQueryParams` | object | Optional `utm_*` map forwarded only to the initial `POST /v1alpha1/mem9s` auto-provision request when `apiKey` is absent |
179
+ | `provisionToken` | string | Optional one-time create-new token used locally to ensure the explicit `mem9_provision_api_key` step runs only once before `apiKey` is persisted |
180
+ | `provisionQueryParams` | object | Optional `utm_*` map forwarded only to the initial `POST /v1alpha1/mem9s` request made by `mem9_provision_api_key` when `apiKey` is absent |
180
181
  | `defaultTimeoutMs` | number | Default timeout for non-search mem9 API requests in milliseconds. Default: `8000` |
181
182
  | `searchTimeoutMs` | number | Timeout for `memory_search` and automatic recall search in milliseconds. Default: `15000` |
182
183
  | `tenantID` | string | Legacy alias for `apiKey`. The plugin still uses `/v1alpha2/mem9s/...` with `X-API-Key`. |
183
184
 
184
- > **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. During create-new onboarding, the plugin also shares one in-flight auto-provision result across concurrent local registrations so repeated plugin reloads do not create multiple keys before config write-back.
185
+ > **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. `provisionToken` and `provisionQueryParams` are ignored after an `apiKey` is already configured, and non-`utm_*` keys are dropped before the provision request is sent. During create-new onboarding, the plugin shares one in-flight explicit provision result across concurrent local registrations so repeated reloads or repeated setup retries do not create multiple keys before config write-back.
185
186
 
186
187
  ## Timeout Behavior
187
188
 
@@ -230,6 +231,6 @@ openclaw-plugin/
230
231
  |---|---|---|
231
232
  | `No mode configured` | Missing config | Add `apiUrl` and `apiKey` (or legacy `tenantID`) to plugin config |
232
233
  | `Server mode requires...` | Missing key | Add `apiKey` (or legacy `tenantID`) to config |
233
- | Multiple auto-provisioned keys appear during create-new | Host reloaded the plugin before config write-back | Upgrade to `@mem9/mem9@0.4.4+`; the plugin now shares one local auto-provision result across concurrent registrations |
234
+ | Multiple auto-provisioned keys appear during create-new | Setup called provision more than once before apiKey write-back, or an older plugin still auto-provisions on startup | Upgrade to `@mem9/mem9@0.4.6+`; newer builds only provision through `mem9_provision_api_key` and share one local result across duplicate setup retries |
234
235
  | Search requests time out | Hybrid/vector search exceeds plugin timeout | Increase `searchTimeoutMs` in plugin config |
235
236
  | Plugin not loading | Not in memory slot | Set `"slots": {"memory": "mem9"}` in openclaw.json |
package/backend.ts CHANGED
@@ -9,6 +9,18 @@ import type {
9
9
  IngestResult,
10
10
  } from "./types.js";
11
11
 
12
+ export class PendingProvisionError extends Error {
13
+ constructor(message = "mem9 create-new setup is waiting for an explicit mem9_provision_api_key call") {
14
+ super(message);
15
+ this.name = "PendingProvisionError";
16
+ }
17
+ }
18
+
19
+ export function isPendingProvisionError(err: unknown): err is PendingProvisionError {
20
+ return err instanceof PendingProvisionError
21
+ || (err instanceof Error && err.name === "PendingProvisionError");
22
+ }
23
+
12
24
  /**
13
25
  * MemoryBackend — the abstraction that tools and hooks call through.
14
26
  */
package/hooks.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * Reference: OpenClaw's built-in memory-lancedb extension uses the same pattern.
12
12
  */
13
13
 
14
- import type { MemoryBackend } from "./backend.js";
14
+ import { isPendingProvisionError, type MemoryBackend } from "./backend.js";
15
15
  import type { Memory, IngestMessage } from "./types.js";
16
16
 
17
17
  // ---------------------------------------------------------------------------
@@ -195,6 +195,9 @@ export function registerHooks(
195
195
  prependContext: formatMemoriesBlock(memories),
196
196
  };
197
197
  } catch (err) {
198
+ if (isPendingProvisionError(err)) {
199
+ return;
200
+ }
198
201
  // Graceful degradation — never block the LLM call
199
202
  logger.error(`[mem9] before_prompt_build failed: ${String(err)}`);
200
203
  }
@@ -245,6 +248,9 @@ export function registerHooks(
245
248
 
246
249
  logger.info("[mem9] Session context saved before reset");
247
250
  } catch (err) {
251
+ if (isPendingProvisionError(err)) {
252
+ return;
253
+ }
248
254
  // Best-effort — never block /reset
249
255
  logger.error(`[mem9] before_reset save failed: ${String(err)}`);
250
256
  }
@@ -346,7 +352,10 @@ export function registerHooks(
346
352
  `[mem9] Ingested session: memories_changed=${result.memories_changed}, status=${result.status}`
347
353
  );
348
354
  }
349
- } catch {
355
+ } catch (err) {
356
+ if (isPendingProvisionError(err)) {
357
+ return;
358
+ }
350
359
  // Best-effort — never fail the agent end phase
351
360
  }
352
361
  });
package/index.test.ts CHANGED
@@ -3,19 +3,25 @@ import test from "node:test";
3
3
 
4
4
  import mnemoPlugin from "./index.js";
5
5
 
6
+ interface RegisteredTool {
7
+ name: string;
8
+ execute: (_id: string, params: unknown) => Promise<unknown>;
9
+ }
10
+
11
+ interface SearchCapability {
12
+ search: (query: string, opts?: { limit?: number }) => Promise<{ data: unknown[]; total: number }>;
13
+ }
14
+
6
15
  interface StubApi {
7
16
  pluginConfig?: unknown;
8
17
  logger: {
9
18
  info: (...args: unknown[]) => void;
10
19
  error: (...args: unknown[]) => void;
11
20
  };
12
- registerTool: () => void;
21
+ registerTool: (factory: unknown, _opts?: unknown) => void;
13
22
  registerCapability?: (slot: string, capability: unknown) => void;
14
- on: () => void;
15
- }
16
-
17
- interface SearchCapability {
18
- search: (query: string, opts?: { limit?: number }) => Promise<{ data: unknown[]; total: number }>;
23
+ on: (...args: unknown[]) => void;
24
+ getTools: (ctx?: { agentId?: string }) => RegisteredTool[];
19
25
  }
20
26
 
21
27
  function createStubApi(
@@ -28,6 +34,9 @@ function createStubApi(
28
34
  ): StubApi {
29
35
  const infoLogs = options?.infoLogs ?? [];
30
36
  const errorLogs = options?.errorLogs ?? [];
37
+ let toolFactory:
38
+ | ((ctx?: { agentId?: string }) => RegisteredTool[] | RegisteredTool | null | undefined)
39
+ | null = null;
31
40
 
32
41
  return {
33
42
  pluginConfig,
@@ -39,9 +48,21 @@ function createStubApi(
39
48
  errorLogs.push(args.map(String).join(" "));
40
49
  },
41
50
  },
42
- registerTool: () => {},
51
+ registerTool: (factory: unknown) => {
52
+ toolFactory = factory as typeof toolFactory;
53
+ },
43
54
  registerCapability: options?.onRegisterCapability,
44
55
  on: () => {},
56
+ getTools: (ctx = {}) => {
57
+ if (!toolFactory) {
58
+ return [];
59
+ }
60
+ const tools = toolFactory(ctx);
61
+ if (!tools) {
62
+ return [];
63
+ }
64
+ return Array.isArray(tools) ? tools : [tools];
65
+ },
45
66
  };
46
67
  }
47
68
 
@@ -64,22 +85,31 @@ function uniqueApiUrl(name: string): string {
64
85
  return `https://api.mem9.ai/${name}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
65
86
  }
66
87
 
67
- test("register eagerly auto-provisions when apiKey is absent", async () => {
88
+ function findTool(api: StubApi, name: string): RegisteredTool {
89
+ const tool = api.getTools({ agentId: "agent-test" }).find((item) => item.name === name);
90
+ if (!tool) {
91
+ throw new Error(`missing tool: ${name}`);
92
+ }
93
+ return tool;
94
+ }
95
+
96
+ function parseToolResult(result: unknown): unknown {
97
+ if (typeof result !== "string") {
98
+ return result;
99
+ }
100
+ return JSON.parse(result);
101
+ }
102
+
103
+ test("register does not auto-provision on startup during create-new", async () => {
68
104
  const originalFetch = globalThis.fetch;
69
- const apiUrl = uniqueApiUrl("eager");
105
+ const apiUrl = uniqueApiUrl("no-startup-provision");
70
106
  const requests: string[] = [];
71
107
  const infoLogs: string[] = [];
72
108
  const errorLogs: string[] = [];
73
109
 
74
110
  globalThis.fetch = async (input) => {
75
111
  requests.push(String(input));
76
-
77
- return new Response(JSON.stringify({ id: "space-1" }), {
78
- status: 201,
79
- headers: {
80
- "Content-Type": "application/json",
81
- },
82
- });
112
+ throw new Error("unexpected fetch");
83
113
  };
84
114
 
85
115
  try {
@@ -87,6 +117,7 @@ test("register eagerly auto-provisions when apiKey is absent", async () => {
87
117
  createStubApi(
88
118
  {
89
119
  apiUrl,
120
+ provisionToken: "token-startup",
90
121
  provisionQueryParams: {
91
122
  utm_source: "bosn",
92
123
  },
@@ -95,20 +126,12 @@ test("register eagerly auto-provisions when apiKey is absent", async () => {
95
126
  ),
96
127
  );
97
128
 
98
- await waitFor(() => requests.length === 1);
129
+ await flushAsyncWork();
99
130
 
131
+ assert.deepEqual(requests, []);
100
132
  assert.deepEqual(errorLogs, []);
101
- assert.equal(requests.length, 1);
102
- assert.equal(
103
- requests[0],
104
- `${apiUrl}/v1alpha1/mem9s?utm_source=bosn`,
105
- );
106
- assert.equal(
107
- infoLogs.includes("[mem9] apiKey not configured; starting auto-provision"),
108
- true,
109
- );
110
133
  assert.equal(
111
- infoLogs.some((msg) => msg.includes("*** Auto-provisioned apiKey=space-1 ***")),
134
+ infoLogs.includes("[mem9] apiKey not configured; waiting for explicit create-new provision"),
112
135
  true,
113
136
  );
114
137
  } finally {
@@ -116,130 +139,56 @@ test("register eagerly auto-provisions when apiKey is absent", async () => {
116
139
  }
117
140
  });
118
141
 
119
- test("register shares an in-flight auto-provision across concurrent plugin registrations", async () => {
142
+ test("memory capability stays idle until explicit provision runs", async () => {
120
143
  const originalFetch = globalThis.fetch;
121
- const apiUrl = uniqueApiUrl("shared-pending");
122
- const infoLogsA: string[] = [];
123
- const infoLogsB: string[] = [];
124
- const errorLogs: string[] = [];
125
- let capabilityA: SearchCapability | null = null;
126
- let capabilityB: SearchCapability | null = null;
127
- let provisionRequests = 0;
128
- let searchRequests = 0;
129
- const provisionControl: { release: () => void } = {
130
- release: () => {},
131
- };
132
- const provisionGate = new Promise<void>((resolve) => {
133
- provisionControl.release = resolve;
134
- });
135
-
136
- globalThis.fetch = async (input, init) => {
137
- const url = String(input);
138
-
139
- if (url === `${apiUrl}/v1alpha1/mem9s`) {
140
- provisionRequests += 1;
141
- await provisionGate;
142
- return new Response(JSON.stringify({ id: "space-shared-pending" }), {
143
- status: 201,
144
- headers: {
145
- "Content-Type": "application/json",
146
- },
147
- });
148
- }
149
-
150
- if (url.includes("/v1alpha2/mem9s/memories")) {
151
- searchRequests += 1;
152
- const headers = init?.headers as Record<string, string> | undefined;
153
- assert.equal(headers?.["X-API-Key"], "space-shared-pending");
154
-
155
- return new Response(
156
- JSON.stringify({
157
- memories: [],
158
- total: 0,
159
- limit: 20,
160
- offset: 0,
161
- }),
162
- {
163
- status: 200,
164
- headers: {
165
- "Content-Type": "application/json",
166
- },
167
- },
168
- );
169
- }
144
+ const apiUrl = uniqueApiUrl("pending-capability");
145
+ let capability: SearchCapability | null = null;
146
+ const requests: string[] = [];
170
147
 
171
- throw new Error(`unexpected fetch: ${url}`);
148
+ globalThis.fetch = async (input) => {
149
+ requests.push(String(input));
150
+ throw new Error("unexpected fetch");
172
151
  };
173
152
 
174
153
  try {
175
154
  mnemoPlugin.register(
176
155
  createStubApi(
177
- { apiUrl },
178
156
  {
179
- infoLogs: infoLogsA,
180
- errorLogs,
181
- onRegisterCapability: (_slot, registeredCapability) => {
182
- capabilityA = registeredCapability as typeof capabilityA;
183
- },
157
+ apiUrl,
158
+ provisionToken: "token-capability",
184
159
  },
185
- ),
186
- );
187
- mnemoPlugin.register(
188
- createStubApi(
189
- { apiUrl },
190
160
  {
191
- infoLogs: infoLogsB,
192
- errorLogs,
193
161
  onRegisterCapability: (_slot, registeredCapability) => {
194
- capabilityB = registeredCapability as typeof capabilityB;
162
+ capability = registeredCapability as SearchCapability;
195
163
  },
196
164
  },
197
165
  ),
198
166
  );
199
167
 
200
- await waitFor(() => provisionRequests === 1);
201
-
202
- assert.equal(provisionRequests, 1);
203
- provisionControl.release();
204
-
205
- await waitFor(() => searchRequests === 0);
206
-
207
- assert.deepEqual(errorLogs, []);
208
- if (!capabilityA || !capabilityB) {
209
- throw new Error("capability registration did not complete");
210
- }
211
-
212
- const readyCapabilityA: SearchCapability = capabilityA;
213
- const readyCapabilityB: SearchCapability = capabilityB;
168
+ assert.notEqual(capability, null);
214
169
 
215
- await readyCapabilityA.search("hello");
216
- await readyCapabilityB.search("hello");
170
+ const result = await capability!.search("hello");
217
171
 
218
- assert.equal(provisionRequests, 1);
219
- assert.equal(searchRequests, 2);
172
+ assert.deepEqual(result, { data: [], total: 0 });
173
+ assert.deepEqual(requests, []);
220
174
  } finally {
221
- provisionControl.release();
222
175
  globalThis.fetch = originalFetch;
223
176
  }
224
177
  });
225
178
 
226
- test("register reuses a shared auto-provisioned key before config write-back", async () => {
179
+ test("mem9_provision_api_key provisions once and unlocks memory access", async () => {
227
180
  const originalFetch = globalThis.fetch;
228
- const apiUrl = uniqueApiUrl("shared-done");
229
- const infoLogsA: string[] = [];
230
- const infoLogsB: string[] = [];
231
- const errorLogs: string[] = [];
232
- let capabilityA: SearchCapability | null = null;
233
- let capabilityB: SearchCapability | null = null;
181
+ const apiUrl = uniqueApiUrl("explicit-provision");
182
+ let capability: SearchCapability | null = null;
234
183
  let provisionRequests = 0;
235
184
  let searchRequests = 0;
236
185
 
237
186
  globalThis.fetch = async (input, init) => {
238
187
  const url = String(input);
239
188
 
240
- if (url === `${apiUrl}/v1alpha1/mem9s`) {
189
+ if (url === `${apiUrl}/v1alpha1/mem9s?utm_source=bosn`) {
241
190
  provisionRequests += 1;
242
- return new Response(JSON.stringify({ id: "space-shared-done" }), {
191
+ return new Response(JSON.stringify({ id: "space-explicit" }), {
243
192
  status: 201,
244
193
  headers: {
245
194
  "Content-Type": "application/json",
@@ -250,7 +199,7 @@ test("register reuses a shared auto-provisioned key before config write-back", a
250
199
  if (url.includes("/v1alpha2/mem9s/memories")) {
251
200
  searchRequests += 1;
252
201
  const headers = init?.headers as Record<string, string> | undefined;
253
- assert.equal(headers?.["X-API-Key"], "space-shared-done");
202
+ assert.equal(headers?.["X-API-Key"], "space-explicit");
254
203
 
255
204
  return new Response(
256
205
  JSON.stringify({
@@ -272,125 +221,118 @@ test("register reuses a shared auto-provisioned key before config write-back", a
272
221
  };
273
222
 
274
223
  try {
275
- mnemoPlugin.register(
276
- createStubApi(
277
- { apiUrl },
278
- {
279
- infoLogs: infoLogsA,
280
- errorLogs,
281
- onRegisterCapability: (_slot, registeredCapability) => {
282
- capabilityA = registeredCapability as typeof capabilityA;
283
- },
224
+ const api = createStubApi(
225
+ {
226
+ apiUrl,
227
+ provisionToken: "token-explicit",
228
+ provisionQueryParams: {
229
+ utm_source: "bosn",
284
230
  },
285
- ),
286
- );
287
-
288
- await waitFor(() =>
289
- infoLogsA.some((msg) => msg.includes("*** Auto-provisioned apiKey=space-shared-done ***")),
290
- );
291
-
292
- mnemoPlugin.register(
293
- createStubApi(
294
- { apiUrl },
295
- {
296
- infoLogs: infoLogsB,
297
- errorLogs,
298
- onRegisterCapability: (_slot, registeredCapability) => {
299
- capabilityB = registeredCapability as typeof capabilityB;
300
- },
231
+ },
232
+ {
233
+ onRegisterCapability: (_slot, registeredCapability) => {
234
+ capability = registeredCapability as SearchCapability;
301
235
  },
302
- ),
236
+ },
303
237
  );
238
+ mnemoPlugin.register(api);
304
239
 
305
- await waitFor(() =>
306
- infoLogsB.includes("[mem9] reusing shared auto-provisioned apiKey pending config write-back"),
307
- );
240
+ const tool = findTool(api, "mem9_provision_api_key");
241
+ const provisionResult = parseToolResult(await tool.execute("tool-1", {})) as {
242
+ ok: boolean;
243
+ data?: { apiKey?: string };
244
+ };
308
245
 
309
- assert.deepEqual(errorLogs, []);
246
+ assert.equal(provisionResult.ok, true);
247
+ assert.equal(provisionResult.data?.apiKey, "space-explicit");
310
248
  assert.equal(provisionRequests, 1);
311
- if (!capabilityA || !capabilityB) {
312
- throw new Error("capability registration did not complete");
313
- }
314
249
 
315
- const readyCapabilityA: SearchCapability = capabilityA;
316
- const readyCapabilityB: SearchCapability = capabilityB;
317
-
318
- await readyCapabilityA.search("hello");
319
- await readyCapabilityB.search("hello");
250
+ assert.notEqual(capability, null);
251
+ const searchResult = await capability!.search("hello");
320
252
 
321
- assert.equal(searchRequests, 2);
322
- assert.equal(
323
- infoLogsB.includes("[mem9] reusing shared auto-provisioned apiKey pending config write-back"),
324
- true,
325
- );
253
+ assert.deepEqual(searchResult, { data: [], total: 0 });
254
+ assert.equal(provisionRequests, 1);
255
+ assert.equal(searchRequests, 1);
326
256
  } finally {
327
257
  globalThis.fetch = originalFetch;
328
258
  }
329
259
  });
330
260
 
331
- test("register does not auto-provision on empty config", async () => {
261
+ test("concurrent explicit provision calls share one server request", async () => {
332
262
  const originalFetch = globalThis.fetch;
333
- const requests: string[] = [];
334
- const infoLogs: string[] = [];
335
- const errorLogs: string[] = [];
263
+ const apiUrl = uniqueApiUrl("shared-explicit");
264
+ let provisionRequests = 0;
265
+ const provisionControl: { release: () => void } = {
266
+ release: () => {},
267
+ };
268
+ const provisionGate = new Promise<void>((resolve) => {
269
+ provisionControl.release = resolve;
270
+ });
336
271
 
337
272
  globalThis.fetch = async (input) => {
338
- requests.push(String(input));
273
+ const url = String(input);
339
274
 
340
- return new Response(JSON.stringify({ id: "space-unexpected" }), {
341
- status: 201,
342
- headers: {
343
- "Content-Type": "application/json",
344
- },
345
- });
275
+ if (url === `${apiUrl}/v1alpha1/mem9s`) {
276
+ provisionRequests += 1;
277
+ await provisionGate;
278
+ return new Response(JSON.stringify({ id: "space-shared-explicit" }), {
279
+ status: 201,
280
+ headers: {
281
+ "Content-Type": "application/json",
282
+ },
283
+ });
284
+ }
285
+
286
+ throw new Error(`unexpected fetch: ${url}`);
346
287
  };
347
288
 
348
289
  try {
349
- mnemoPlugin.register(
350
- createStubApi(
351
- {},
352
- { infoLogs, errorLogs },
353
- ),
354
- );
290
+ const pluginConfig = {
291
+ apiUrl,
292
+ provisionToken: "token-shared-explicit",
293
+ };
294
+ const apiA = createStubApi(pluginConfig);
295
+ const apiB = createStubApi(pluginConfig);
355
296
 
356
- await flushAsyncWork();
297
+ mnemoPlugin.register(apiA);
298
+ mnemoPlugin.register(apiB);
357
299
 
358
- assert.deepEqual(requests, []);
359
- assert.deepEqual(errorLogs, []);
360
- assert.equal(
361
- infoLogs.includes("[mem9] apiKey not configured; starting auto-provision"),
362
- false,
363
- );
300
+ const toolA = findTool(apiA, "mem9_provision_api_key");
301
+ const toolB = findTool(apiB, "mem9_provision_api_key");
302
+
303
+ const promiseA = toolA.execute("tool-a", {});
304
+ const promiseB = toolB.execute("tool-b", {});
305
+
306
+ await waitFor(() => provisionRequests === 1);
307
+ provisionControl.release();
308
+
309
+ const resultA = parseToolResult(await promiseA) as { ok: boolean; data?: { apiKey?: string } };
310
+ const resultB = parseToolResult(await promiseB) as { ok: boolean; data?: { apiKey?: string } };
311
+
312
+ assert.equal(provisionRequests, 1);
313
+ assert.equal(resultA.ok, true);
314
+ assert.equal(resultB.ok, true);
315
+ assert.equal(resultA.data?.apiKey, "space-shared-explicit");
316
+ assert.equal(resultB.data?.apiKey, "space-shared-explicit");
364
317
  } finally {
318
+ provisionControl.release();
365
319
  globalThis.fetch = originalFetch;
366
320
  }
367
321
  });
368
322
 
369
- test("search retries auto-provision after an eager startup failure", async () => {
323
+ test("a second setup retry reuses the shared provisioned key before config write-back", async () => {
370
324
  const originalFetch = globalThis.fetch;
371
- const apiUrl = uniqueApiUrl("retry");
372
- const infoLogs: string[] = [];
373
- const errorLogs: string[] = [];
374
- let provisionAttempts = 0;
375
- let searchRequests = 0;
376
- let capability: SearchCapability | null = null;
325
+ const apiUrl = uniqueApiUrl("shared-retry");
326
+ const infoLogsA: string[] = [];
327
+ const infoLogsB: string[] = [];
328
+ let provisionRequests = 0;
377
329
 
378
- globalThis.fetch = async (input, init) => {
330
+ globalThis.fetch = async (input) => {
379
331
  const url = String(input);
380
332
 
381
- if (url.includes("/v1alpha1/mem9s")) {
382
- provisionAttempts += 1;
383
-
384
- if (provisionAttempts === 1) {
385
- return new Response(JSON.stringify({ error: "boom" }), {
386
- status: 500,
387
- headers: {
388
- "Content-Type": "application/json",
389
- },
390
- });
391
- }
392
-
393
- return new Response(JSON.stringify({ id: "space-2" }), {
333
+ if (url === `${apiUrl}/v1alpha1/mem9s`) {
334
+ provisionRequests += 1;
335
+ return new Response(JSON.stringify({ id: "space-shared-retry" }), {
394
336
  status: 201,
395
337
  headers: {
396
338
  "Content-Type": "application/json",
@@ -398,60 +340,39 @@ test("search retries auto-provision after an eager startup failure", async () =>
398
340
  });
399
341
  }
400
342
 
401
- if (url.includes("/v1alpha2/mem9s/memories")) {
402
- searchRequests += 1;
403
- const headers = init?.headers as Record<string, string> | undefined;
404
- assert.equal(headers?.["X-API-Key"], "space-2");
405
-
406
- return new Response(
407
- JSON.stringify({
408
- memories: [],
409
- total: 0,
410
- limit: 20,
411
- offset: 0,
412
- }),
413
- {
414
- status: 200,
415
- headers: {
416
- "Content-Type": "application/json",
417
- },
418
- },
419
- );
420
- }
421
-
422
343
  throw new Error(`unexpected fetch: ${url}`);
423
344
  };
424
345
 
425
346
  try {
426
- mnemoPlugin.register(
427
- createStubApi(
428
- { apiUrl },
429
- {
430
- infoLogs,
431
- errorLogs,
432
- onRegisterCapability: (_slot, registeredCapability) => {
433
- capability = registeredCapability as typeof capability;
434
- },
435
- },
436
- ),
437
- );
438
-
439
- await waitFor(() => provisionAttempts === 1);
440
-
441
- assert.equal(provisionAttempts, 1);
442
- assert.equal(
443
- errorLogs.some((msg) => msg.includes("auto-provision failed: mem9s provision failed (500):")),
444
- true,
445
- );
446
- assert.notEqual(capability, null);
447
-
448
- const result = await capability!.search("hello");
347
+ const pluginConfig = {
348
+ apiUrl,
349
+ provisionToken: "token-shared-retry",
350
+ };
351
+
352
+ const apiA = createStubApi(pluginConfig, { infoLogs: infoLogsA });
353
+ mnemoPlugin.register(apiA);
354
+ const firstTool = findTool(apiA, "mem9_provision_api_key");
355
+ const firstResult = parseToolResult(await firstTool.execute("tool-a", {})) as {
356
+ ok: boolean;
357
+ data?: { apiKey?: string };
358
+ };
359
+
360
+ assert.equal(firstResult.ok, true);
361
+ assert.equal(firstResult.data?.apiKey, "space-shared-retry");
362
+
363
+ const apiB = createStubApi(pluginConfig, { infoLogs: infoLogsB });
364
+ mnemoPlugin.register(apiB);
365
+ const secondTool = findTool(apiB, "mem9_provision_api_key");
366
+ const secondResult = parseToolResult(await secondTool.execute("tool-b", {})) as {
367
+ ok: boolean;
368
+ data?: { apiKey?: string };
369
+ };
449
370
 
450
- assert.deepEqual(result, { data: [], total: 0 });
451
- assert.equal(provisionAttempts, 2);
452
- assert.equal(searchRequests, 1);
371
+ assert.equal(provisionRequests, 1);
372
+ assert.equal(secondResult.ok, true);
373
+ assert.equal(secondResult.data?.apiKey, "space-shared-retry");
453
374
  assert.equal(
454
- infoLogs.some((msg) => msg.includes("*** Auto-provisioned apiKey=space-2 ***")),
375
+ infoLogsB.includes("[mem9] reusing shared explicit-provision result pending config write-back"),
455
376
  true,
456
377
  );
457
378
  } finally {
package/index.ts CHANGED
@@ -3,7 +3,7 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
 
6
- import type { MemoryBackend } from "./backend.js";
6
+ import { PendingProvisionError, isPendingProvisionError, type MemoryBackend } from "./backend.js";
7
7
  import {
8
8
  DEFAULT_SEARCH_TIMEOUT_MS,
9
9
  DEFAULT_TIMEOUT_MS,
@@ -23,7 +23,7 @@ import type {
23
23
 
24
24
  const DEFAULT_API_URL = "https://api.mem9.ai";
25
25
  const TIMEOUT_FIELDS = ["defaultTimeoutMs", "searchTimeoutMs"] as const;
26
- const SHARED_PROVISION_DIR = path.join(os.tmpdir(), "mem9-openclaw");
26
+ const SHARED_PROVISION_DIR = path.join(os.homedir(), ".openclaw", "mem9", "provision");
27
27
  const SHARED_PROVISION_POLL_INTERVAL_MS = 250;
28
28
  const SHARED_PROVISION_RESULT_TTL_MS = 2 * 60 * 1000;
29
29
  const sharedProvisionPromises = new Map<string, Promise<string>>();
@@ -107,10 +107,6 @@ function errorMessage(err: unknown): string {
107
107
  return err instanceof Error ? err.message : String(err);
108
108
  }
109
109
 
110
- function shouldEagerAutoProvision(cfg: PluginConfig): boolean {
111
- return cfg.apiUrl != null || cfg.provisionQueryParams != null;
112
- }
113
-
114
110
  function sleep(ms: number): Promise<void> {
115
111
  return new Promise((resolve) => setTimeout(resolve, ms));
116
112
  }
@@ -118,6 +114,7 @@ function sleep(ms: number): Promise<void> {
118
114
  function sharedProvisionKey(
119
115
  apiUrl: string,
120
116
  provisionQueryParams: Record<string, string>,
117
+ provisionToken: string,
121
118
  ): string {
122
119
  const normalizedProvisionQueryParams = Object.fromEntries(
123
120
  Object.entries(provisionQueryParams).sort(([left], [right]) => left.localeCompare(right)),
@@ -127,6 +124,7 @@ function sharedProvisionKey(
127
124
  .update(
128
125
  JSON.stringify({
129
126
  apiUrl,
127
+ provisionToken,
130
128
  provisionQueryParams: normalizedProvisionQueryParams,
131
129
  }),
132
130
  )
@@ -257,11 +255,12 @@ async function waitForSharedProvisionResult(
257
255
  async function resolveSharedProvisionedAPIKey(
258
256
  apiUrl: string,
259
257
  provisionQueryParams: Record<string, string>,
258
+ provisionToken: string,
260
259
  timeouts: Required<BackendTimeouts>,
261
260
  logger: OpenClawPluginApi["logger"],
262
261
  registerTenant: () => Promise<string>,
263
262
  ): Promise<string> {
264
- const key = sharedProvisionKey(apiUrl, provisionQueryParams);
263
+ const key = sharedProvisionKey(apiUrl, provisionQueryParams, provisionToken);
265
264
  const existingPromise = sharedProvisionPromises.get(key);
266
265
  if (existingPromise) {
267
266
  return existingPromise;
@@ -276,7 +275,7 @@ async function resolveSharedProvisionedAPIKey(
276
275
 
277
276
  if (state?.status === "done") {
278
277
  if (now - state.finishedAt <= SHARED_PROVISION_RESULT_TTL_MS) {
279
- logger.info("[mem9] reusing shared auto-provisioned apiKey pending config write-back");
278
+ logger.info("[mem9] reusing shared explicit-provision result pending config write-back");
280
279
  return state.apiKey;
281
280
  }
282
281
  await removeSharedProvisionState(filePath);
@@ -290,7 +289,7 @@ async function resolveSharedProvisionedAPIKey(
290
289
  await removeSharedProvisionState(filePath);
291
290
  continue;
292
291
  }
293
- logger.info("[mem9] auto-provision already in progress in another mem9 instance; waiting");
292
+ logger.info("[mem9] explicit provision already in progress in another mem9 instance; waiting");
294
293
  const sharedApiKey = await waitForSharedProvisionResult(filePath, waitTimeoutMs);
295
294
  if (sharedApiKey) {
296
295
  return sharedApiKey;
@@ -373,7 +372,10 @@ interface AnyAgentTool {
373
372
  execute: (_id: string, params: unknown) => Promise<unknown>;
374
373
  }
375
374
 
376
- function buildTools(backend: MemoryBackend): AnyAgentTool[] {
375
+ function buildTools(
376
+ backend: MemoryBackend,
377
+ provisionAPIKey: (() => Promise<string>) | null,
378
+ ): AnyAgentTool[] {
377
379
  return [
378
380
  {
379
381
  name: "memory_store",
@@ -546,6 +548,41 @@ function buildTools(backend: MemoryBackend): AnyAgentTool[] {
546
548
  }
547
549
  },
548
550
  },
551
+
552
+ {
553
+ name: "mem9_provision_api_key",
554
+ label: "Provision mem9 API Key",
555
+ description:
556
+ "Create exactly one mem9 API key during create-new setup after the first restart. Use only when apiKey is absent.",
557
+ parameters: {
558
+ type: "object",
559
+ properties: {},
560
+ required: [],
561
+ },
562
+ async execute(_id: string, _params: unknown) {
563
+ if (!provisionAPIKey) {
564
+ return jsonResult({
565
+ ok: false,
566
+ error: "mem9_provision_api_key is only available during create-new setup before apiKey is persisted",
567
+ });
568
+ }
569
+
570
+ try {
571
+ const apiKey = await provisionAPIKey();
572
+ return jsonResult({
573
+ ok: true,
574
+ data: {
575
+ apiKey,
576
+ },
577
+ });
578
+ } catch (err) {
579
+ return jsonResult({
580
+ ok: false,
581
+ error: err instanceof Error ? err.message : String(err),
582
+ });
583
+ }
584
+ },
585
+ },
549
586
  ];
550
587
  }
551
588
 
@@ -558,6 +595,10 @@ const mnemoPlugin = {
558
595
  register(api: OpenClawPluginApi) {
559
596
  const cfg = (api.pluginConfig ?? {}) as PluginConfig;
560
597
  const effectiveApiUrl = cfg.apiUrl ?? DEFAULT_API_URL;
598
+ const configuredProvisionToken =
599
+ typeof cfg.provisionToken === "string" && cfg.provisionToken.trim() !== ""
600
+ ? cfg.provisionToken.trim()
601
+ : null;
561
602
  const provisionQueryParams = cfg.provisionQueryParams ?? {};
562
603
  const timeoutConfig = resolveTimeouts(cfg, api.logger);
563
604
  const hookAgentId = cfg.agentName ?? "agent";
@@ -587,42 +628,70 @@ const mnemoPlugin = {
587
628
  );
588
629
  return result.id;
589
630
  };
631
+ let runtimeProvisionedAPIKey: string | null = null;
590
632
  let registrationPromise: Promise<string> | null = null;
591
- const resolveAPIKey = (agentName: string): Promise<string> => {
592
- if (configuredApiKey) return Promise.resolve(configuredApiKey);
633
+ const provisionAPIKey = (agentName: string): Promise<string> => {
634
+ if (configuredApiKey) {
635
+ return Promise.reject(
636
+ new Error(
637
+ "mem9_provision_api_key is only available during create-new setup before apiKey is persisted",
638
+ ),
639
+ );
640
+ }
641
+ if (runtimeProvisionedAPIKey) {
642
+ return Promise.resolve(runtimeProvisionedAPIKey);
643
+ }
644
+ if (!configuredProvisionToken) {
645
+ return Promise.reject(
646
+ new Error("mem9 create-new setup cannot provision because provisionToken is missing"),
647
+ );
648
+ }
593
649
  if (!registrationPromise) {
594
650
  registrationPromise = resolveSharedProvisionedAPIKey(
595
651
  effectiveApiUrl,
596
652
  provisionQueryParams,
653
+ configuredProvisionToken,
597
654
  timeoutConfig,
598
655
  api.logger,
599
656
  () => registerTenant(agentName),
600
- ).catch((err) => {
601
- registrationPromise = null;
602
- throw err;
603
- });
657
+ )
658
+ .then((apiKey) => {
659
+ runtimeProvisionedAPIKey = apiKey;
660
+ return apiKey;
661
+ })
662
+ .catch((err) => {
663
+ registrationPromise = null;
664
+ throw err;
665
+ });
604
666
  }
605
667
  return registrationPromise;
606
668
  };
669
+ const resolveAPIKey = (): Promise<string> => {
670
+ if (configuredApiKey) return Promise.resolve(configuredApiKey);
671
+ if (runtimeProvisionedAPIKey) {
672
+ return Promise.resolve(runtimeProvisionedAPIKey);
673
+ }
674
+ return Promise.reject(new PendingProvisionError());
675
+ };
607
676
 
608
677
  api.logger.info("[mem9] Server mode (v1alpha2)");
609
-
610
- if (!configuredApiKey && shouldEagerAutoProvision(cfg)) {
611
- api.logger.info("[mem9] apiKey not configured; starting auto-provision");
612
- void resolveAPIKey(hookAgentId).catch((err) => {
613
- api.logger.error(`[mem9] auto-provision failed: ${errorMessage(err)}`);
614
- });
678
+ if (!configuredApiKey) {
679
+ if (configuredProvisionToken) {
680
+ api.logger.info("[mem9] apiKey not configured; waiting for explicit create-new provision");
681
+ } else {
682
+ api.logger.info("[mem9] apiKey not configured; mem9 will stay idle until apiKey is configured");
683
+ }
615
684
  }
616
685
 
617
686
  const factory: ToolFactory = (ctx: ToolContext) => {
618
687
  const agentId = ctx.agentId ?? cfg.agentName ?? "agent";
619
688
  const backend = new LazyServerBackend(
620
689
  effectiveApiUrl,
621
- () => resolveAPIKey(agentId),
690
+ resolveAPIKey,
622
691
  agentId,
623
692
  timeoutConfig,
624
693
  );
625
- return buildTools(backend);
694
+ return buildTools(backend, configuredApiKey ? null : () => provisionAPIKey(agentId));
626
695
  };
627
696
 
628
697
  api.registerTool(factory, { names: toolNames });
@@ -630,11 +699,25 @@ const mnemoPlugin = {
630
699
  // Shared lazy backend for hooks and capability registration.
631
700
  const hookBackend = new LazyServerBackend(
632
701
  effectiveApiUrl,
633
- () => resolveAPIKey(hookAgentId),
702
+ resolveAPIKey,
634
703
  hookAgentId,
635
704
  timeoutConfig,
636
705
  );
637
706
 
707
+ const withPendingProvisionFallback = async <T>(
708
+ action: () => Promise<T>,
709
+ fallback: T,
710
+ ): Promise<T> => {
711
+ try {
712
+ return await action();
713
+ } catch (err) {
714
+ if (isPendingProvisionError(err)) {
715
+ return fallback;
716
+ }
717
+ throw err;
718
+ }
719
+ };
720
+
638
721
  // Register memory capability so OpenClaw 2026.4.2+ binds this plugin to
639
722
  // the memory slot. Without this, the plugin is treated as a legacy
640
723
  // hook-only plugin and automatic context injection won't work.
@@ -642,14 +725,24 @@ const mnemoPlugin = {
642
725
  if (typeof api.registerCapability === "function") {
643
726
  api.registerCapability("memory", {
644
727
  search: async (query, opts) => {
645
- const result = await hookBackend.search({ q: query, limit: opts?.limit });
728
+ const result = await withPendingProvisionFallback(
729
+ () => hookBackend.search({ q: query, limit: opts?.limit }),
730
+ { data: [], total: 0, limit: opts?.limit ?? 20, offset: 0 },
731
+ );
646
732
  return { data: result.data, total: result.total };
647
733
  },
648
734
  store: async (content, opts) => {
649
- return hookBackend.store({ content, tags: opts?.tags, source: opts?.source });
735
+ try {
736
+ return await hookBackend.store({ content, tags: opts?.tags, source: opts?.source });
737
+ } catch (err) {
738
+ if (isPendingProvisionError(err)) {
739
+ return null;
740
+ }
741
+ throw err;
742
+ }
650
743
  },
651
- get: (id) => hookBackend.get(id),
652
- remove: (id) => hookBackend.remove(id),
744
+ get: (id) => withPendingProvisionFallback(() => hookBackend.get(id), null),
745
+ remove: (id) => withPendingProvisionFallback(() => hookBackend.remove(id), false),
653
746
  });
654
747
  }
655
748
 
@@ -667,6 +760,7 @@ const toolNames = [
667
760
  "memory_get",
668
761
  "memory_update",
669
762
  "memory_delete",
763
+ "mem9_provision_api_key",
670
764
  ];
671
765
 
672
766
  class LazyServerBackend implements MemoryBackend {
@@ -14,9 +14,13 @@
14
14
  "type": "string",
15
15
  "description": "mem9 API key (secret — do not share)"
16
16
  },
17
+ "provisionToken": {
18
+ "type": "string",
19
+ "description": "One-time create-new token used locally to ensure explicit API-key provisioning runs only once before apiKey is persisted"
20
+ },
17
21
  "provisionQueryParams": {
18
22
  "type": "object",
19
- "description": "Optional utm_* params forwarded only during first-time auto-provisioning",
23
+ "description": "Optional utm_* params forwarded only by the explicit first-time create-new provision step",
20
24
  "additionalProperties": {
21
25
  "type": "string"
22
26
  }
@@ -47,6 +51,10 @@
47
51
  "placeholder": "key...",
48
52
  "sensitive": true
49
53
  },
54
+ "provisionToken": {
55
+ "label": "Provision Token",
56
+ "placeholder": "create-new only"
57
+ },
50
58
  "defaultTimeoutMs": {
51
59
  "label": "Default Timeout (ms)",
52
60
  "placeholder": "8000"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mem9/mem9",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
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",
@@ -3,7 +3,7 @@ import test from "node:test";
3
3
 
4
4
  import { ServerBackend } from "./server-backend.js";
5
5
 
6
- test("register forwards only utm_* params during auto-provision", async () => {
6
+ test("register forwards only utm_* params during create-new provision", async () => {
7
7
  const originalFetch = globalThis.fetch;
8
8
  let requestedURL = "";
9
9
 
package/types.ts CHANGED
@@ -2,6 +2,7 @@ export interface PluginConfig {
2
2
  // Server mode (apiUrl present → server)
3
3
  apiUrl?: string;
4
4
  apiKey?: string;
5
+ provisionToken?: string;
5
6
  provisionQueryParams?: Record<string, string>;
6
7
  tenantID?: string;
7
8
  defaultTimeoutMs?: number;