@mem9/mem9 0.4.4 → 0.4.6

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 an explicit `apiKey` is configured.
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, the first post-restart user message triggers exactly one create-new provision through the normal hook path. The plugin coordinates that call across concurrent OpenClaw plugin registrations on the same machine and reuses the generated key locally for future restarts tied to the same `provisionToken`.
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 first-message create-new provision runs only once and is reused on this machine until an explicit `apiKey` is configured |
180
+ | `provisionQueryParams` | object | Optional `utm_*` map forwarded only to the initial `POST /v1alpha1/mem9s` request made during create-new 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 provision result across concurrent local registrations and reuses the persisted result for the same `provisionToken`, so repeated reloads or repeated setup retries do not create multiple keys.
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 retriggered create-new provisioning before the first result was reused, or an older plugin still auto-provisions on startup | Upgrade to `@mem9/mem9@0.4.7+`; newer builds provision only from the first post-restart user message and reuse 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,20 @@ import type {
9
9
  IngestResult,
10
10
  } from "./types.js";
11
11
 
12
+ export class PendingProvisionError extends Error {
13
+ constructor(
14
+ message = "mem9 create-new setup is waiting for the first post-restart message to finish provisioning",
15
+ ) {
16
+ super(message);
17
+ this.name = "PendingProvisionError";
18
+ }
19
+ }
20
+
21
+ export function isPendingProvisionError(err: unknown): err is PendingProvisionError {
22
+ return err instanceof PendingProvisionError
23
+ || (err instanceof Error && err.name === "PendingProvisionError");
24
+ }
25
+
12
26
  /**
13
27
  * MemoryBackend — the abstraction that tools and hooks call through.
14
28
  */
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
  // ---------------------------------------------------------------------------
@@ -169,7 +169,11 @@ export function registerHooks(
169
169
  api: HookApi,
170
170
  backend: MemoryBackend,
171
171
  logger: Logger,
172
- options?: { maxIngestBytes?: number; fallbackAgentId?: string },
172
+ options?: {
173
+ maxIngestBytes?: number;
174
+ fallbackAgentId?: string;
175
+ provisionForCreateNew?: () => Promise<string>;
176
+ },
173
177
  ): void {
174
178
  const maxIngestBytes = options?.maxIngestBytes ?? DEFAULT_MAX_INGEST_BYTES;
175
179
 
@@ -182,6 +186,9 @@ export function registerHooks(
182
186
  try {
183
187
  const evt = event as { prompt?: string };
184
188
  const prompt = evt?.prompt;
189
+ if (options?.provisionForCreateNew) {
190
+ await options.provisionForCreateNew();
191
+ }
185
192
  if (!prompt || prompt.length < MIN_PROMPT_LEN) return;
186
193
 
187
194
  const result = await backend.search({ q: prompt, limit: MAX_INJECT });
@@ -195,6 +202,9 @@ export function registerHooks(
195
202
  prependContext: formatMemoriesBlock(memories),
196
203
  };
197
204
  } catch (err) {
205
+ if (isPendingProvisionError(err)) {
206
+ return;
207
+ }
198
208
  // Graceful degradation — never block the LLM call
199
209
  logger.error(`[mem9] before_prompt_build failed: ${String(err)}`);
200
210
  }
@@ -245,6 +255,9 @@ export function registerHooks(
245
255
 
246
256
  logger.info("[mem9] Session context saved before reset");
247
257
  } catch (err) {
258
+ if (isPendingProvisionError(err)) {
259
+ return;
260
+ }
248
261
  // Best-effort — never block /reset
249
262
  logger.error(`[mem9] before_reset save failed: ${String(err)}`);
250
263
  }
@@ -346,7 +359,10 @@ export function registerHooks(
346
359
  `[mem9] Ingested session: memories_changed=${result.memories_changed}, status=${result.status}`
347
360
  );
348
361
  }
349
- } catch {
362
+ } catch (err) {
363
+ if (isPendingProvisionError(err)) {
364
+ return;
365
+ }
350
366
  // Best-effort — never fail the agent end phase
351
367
  }
352
368
  });
package/index.test.ts CHANGED
@@ -3,19 +3,28 @@ 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
+
15
+ type HookHandler = (...args: unknown[]) => unknown;
16
+
6
17
  interface StubApi {
7
18
  pluginConfig?: unknown;
8
19
  logger: {
9
20
  info: (...args: unknown[]) => void;
10
21
  error: (...args: unknown[]) => void;
11
22
  };
12
- registerTool: () => void;
23
+ registerTool: (factory: unknown, _opts?: unknown) => void;
13
24
  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 }>;
25
+ on: (...args: unknown[]) => void;
26
+ getTools: (ctx?: { agentId?: string }) => RegisteredTool[];
27
+ getHook: (name: string) => HookHandler;
19
28
  }
20
29
 
21
30
  function createStubApi(
@@ -28,6 +37,10 @@ function createStubApi(
28
37
  ): StubApi {
29
38
  const infoLogs = options?.infoLogs ?? [];
30
39
  const errorLogs = options?.errorLogs ?? [];
40
+ const hooks = new Map<string, HookHandler>();
41
+ let toolFactory:
42
+ | ((ctx?: { agentId?: string }) => RegisteredTool[] | RegisteredTool | null | undefined)
43
+ | null = null;
31
44
 
32
45
  return {
33
46
  pluginConfig,
@@ -39,9 +52,32 @@ function createStubApi(
39
52
  errorLogs.push(args.map(String).join(" "));
40
53
  },
41
54
  },
42
- registerTool: () => {},
55
+ registerTool: (factory: unknown) => {
56
+ toolFactory = factory as typeof toolFactory;
57
+ },
43
58
  registerCapability: options?.onRegisterCapability,
44
- on: () => {},
59
+ on: (hookName: unknown, handler: unknown) => {
60
+ if (typeof hookName === "string" && typeof handler === "function") {
61
+ hooks.set(hookName, handler as HookHandler);
62
+ }
63
+ },
64
+ getTools: (ctx = {}) => {
65
+ if (!toolFactory) {
66
+ return [];
67
+ }
68
+ const tools = toolFactory(ctx);
69
+ if (!tools) {
70
+ return [];
71
+ }
72
+ return Array.isArray(tools) ? tools : [tools];
73
+ },
74
+ getHook: (name: string) => {
75
+ const hook = hooks.get(name);
76
+ if (!hook) {
77
+ throw new Error(`missing hook: ${name}`);
78
+ }
79
+ return hook;
80
+ },
45
81
  };
46
82
  }
47
83
 
@@ -64,22 +100,16 @@ function uniqueApiUrl(name: string): string {
64
100
  return `https://api.mem9.ai/${name}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
65
101
  }
66
102
 
67
- test("register eagerly auto-provisions when apiKey is absent", async () => {
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,14 @@ 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(
135
+ "[mem9] apiKey not configured; waiting for the first post-restart message to finish create-new provision",
136
+ ),
112
137
  true,
113
138
  );
114
139
  } finally {
@@ -116,130 +141,56 @@ test("register eagerly auto-provisions when apiKey is absent", async () => {
116
141
  }
117
142
  });
118
143
 
119
- test("register shares an in-flight auto-provision across concurrent plugin registrations", async () => {
144
+ test("memory capability stays idle until explicit provision runs", async () => {
120
145
  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
- }
146
+ const apiUrl = uniqueApiUrl("pending-capability");
147
+ let capability: SearchCapability | null = null;
148
+ const requests: string[] = [];
170
149
 
171
- throw new Error(`unexpected fetch: ${url}`);
150
+ globalThis.fetch = async (input) => {
151
+ requests.push(String(input));
152
+ throw new Error("unexpected fetch");
172
153
  };
173
154
 
174
155
  try {
175
156
  mnemoPlugin.register(
176
157
  createStubApi(
177
- { apiUrl },
178
158
  {
179
- infoLogs: infoLogsA,
180
- errorLogs,
181
- onRegisterCapability: (_slot, registeredCapability) => {
182
- capabilityA = registeredCapability as typeof capabilityA;
183
- },
159
+ apiUrl,
160
+ provisionToken: "token-capability",
184
161
  },
185
- ),
186
- );
187
- mnemoPlugin.register(
188
- createStubApi(
189
- { apiUrl },
190
162
  {
191
- infoLogs: infoLogsB,
192
- errorLogs,
193
163
  onRegisterCapability: (_slot, registeredCapability) => {
194
- capabilityB = registeredCapability as typeof capabilityB;
164
+ capability = registeredCapability as SearchCapability;
195
165
  },
196
166
  },
197
167
  ),
198
168
  );
199
169
 
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;
170
+ assert.notEqual(capability, null);
214
171
 
215
- await readyCapabilityA.search("hello");
216
- await readyCapabilityB.search("hello");
172
+ const result = await capability!.search("hello");
217
173
 
218
- assert.equal(provisionRequests, 1);
219
- assert.equal(searchRequests, 2);
174
+ assert.deepEqual(result, { data: [], total: 0 });
175
+ assert.deepEqual(requests, []);
220
176
  } finally {
221
- provisionControl.release();
222
177
  globalThis.fetch = originalFetch;
223
178
  }
224
179
  });
225
180
 
226
- test("register reuses a shared auto-provisioned key before config write-back", async () => {
181
+ test("first post-restart prompt provisions once and unlocks memory access", async () => {
227
182
  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;
183
+ const apiUrl = uniqueApiUrl("explicit-provision");
184
+ let capability: SearchCapability | null = null;
234
185
  let provisionRequests = 0;
235
186
  let searchRequests = 0;
236
187
 
237
188
  globalThis.fetch = async (input, init) => {
238
189
  const url = String(input);
239
190
 
240
- if (url === `${apiUrl}/v1alpha1/mem9s`) {
191
+ if (url === `${apiUrl}/v1alpha1/mem9s?utm_source=bosn`) {
241
192
  provisionRequests += 1;
242
- return new Response(JSON.stringify({ id: "space-shared-done" }), {
193
+ return new Response(JSON.stringify({ id: "space-explicit" }), {
243
194
  status: 201,
244
195
  headers: {
245
196
  "Content-Type": "application/json",
@@ -250,7 +201,7 @@ test("register reuses a shared auto-provisioned key before config write-back", a
250
201
  if (url.includes("/v1alpha2/mem9s/memories")) {
251
202
  searchRequests += 1;
252
203
  const headers = init?.headers as Record<string, string> | undefined;
253
- assert.equal(headers?.["X-API-Key"], "space-shared-done");
204
+ assert.equal(headers?.["X-API-Key"], "space-explicit");
254
205
 
255
206
  return new Response(
256
207
  JSON.stringify({
@@ -271,126 +222,112 @@ test("register reuses a shared auto-provisioned key before config write-back", a
271
222
  throw new Error(`unexpected fetch: ${url}`);
272
223
  };
273
224
 
274
- 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
- },
225
+ try {
226
+ const api = createStubApi(
227
+ {
228
+ apiUrl,
229
+ provisionToken: "token-explicit",
230
+ provisionQueryParams: {
231
+ utm_source: "bosn",
284
232
  },
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
- },
233
+ },
234
+ {
235
+ onRegisterCapability: (_slot, registeredCapability) => {
236
+ capability = registeredCapability as SearchCapability;
301
237
  },
302
- ),
238
+ },
303
239
  );
240
+ mnemoPlugin.register(api);
304
241
 
305
- await waitFor(() =>
306
- infoLogsB.includes("[mem9] reusing shared auto-provisioned apiKey pending config write-back"),
307
- );
242
+ const beforePromptBuild = api.getHook("before_prompt_build");
243
+ const hookResult = await beforePromptBuild({ prompt: "hi" });
308
244
 
309
- assert.deepEqual(errorLogs, []);
245
+ assert.equal(hookResult, undefined);
310
246
  assert.equal(provisionRequests, 1);
311
- if (!capabilityA || !capabilityB) {
312
- throw new Error("capability registration did not complete");
313
- }
314
247
 
315
- const readyCapabilityA: SearchCapability = capabilityA;
316
- const readyCapabilityB: SearchCapability = capabilityB;
317
-
318
- await readyCapabilityA.search("hello");
319
- await readyCapabilityB.search("hello");
248
+ assert.notEqual(capability, null);
249
+ const searchResult = await capability!.search("hello");
320
250
 
321
- assert.equal(searchRequests, 2);
322
- assert.equal(
323
- infoLogsB.includes("[mem9] reusing shared auto-provisioned apiKey pending config write-back"),
324
- true,
325
- );
251
+ assert.deepEqual(searchResult, { data: [], total: 0 });
252
+ assert.equal(provisionRequests, 1);
253
+ assert.equal(searchRequests, 1);
326
254
  } finally {
327
255
  globalThis.fetch = originalFetch;
328
256
  }
329
257
  });
330
258
 
331
- test("register does not auto-provision on empty config", async () => {
259
+ test("concurrent first-post-restart prompts share one server request", async () => {
332
260
  const originalFetch = globalThis.fetch;
333
- const requests: string[] = [];
334
- const infoLogs: string[] = [];
335
- const errorLogs: string[] = [];
261
+ const apiUrl = uniqueApiUrl("shared-explicit");
262
+ let provisionRequests = 0;
263
+ const provisionControl: { release: () => void } = {
264
+ release: () => {},
265
+ };
266
+ const provisionGate = new Promise<void>((resolve) => {
267
+ provisionControl.release = resolve;
268
+ });
336
269
 
337
270
  globalThis.fetch = async (input) => {
338
- requests.push(String(input));
271
+ const url = String(input);
339
272
 
340
- return new Response(JSON.stringify({ id: "space-unexpected" }), {
341
- status: 201,
342
- headers: {
343
- "Content-Type": "application/json",
344
- },
345
- });
273
+ if (url === `${apiUrl}/v1alpha1/mem9s`) {
274
+ provisionRequests += 1;
275
+ await provisionGate;
276
+ return new Response(JSON.stringify({ id: "space-shared-explicit" }), {
277
+ status: 201,
278
+ headers: {
279
+ "Content-Type": "application/json",
280
+ },
281
+ });
282
+ }
283
+
284
+ throw new Error(`unexpected fetch: ${url}`);
346
285
  };
347
286
 
348
287
  try {
349
- mnemoPlugin.register(
350
- createStubApi(
351
- {},
352
- { infoLogs, errorLogs },
353
- ),
354
- );
288
+ const pluginConfig = {
289
+ apiUrl,
290
+ provisionToken: "token-shared-explicit",
291
+ };
292
+ const apiA = createStubApi(pluginConfig);
293
+ const apiB = createStubApi(pluginConfig);
355
294
 
356
- await flushAsyncWork();
295
+ mnemoPlugin.register(apiA);
296
+ mnemoPlugin.register(apiB);
357
297
 
358
- assert.deepEqual(requests, []);
359
- assert.deepEqual(errorLogs, []);
360
- assert.equal(
361
- infoLogs.includes("[mem9] apiKey not configured; starting auto-provision"),
362
- false,
363
- );
298
+ const hookA = apiA.getHook("before_prompt_build");
299
+ const hookB = apiB.getHook("before_prompt_build");
300
+
301
+ const promiseA = hookA({ prompt: "hi" });
302
+ const promiseB = hookB({ prompt: "hi" });
303
+
304
+ await waitFor(() => provisionRequests === 1);
305
+ provisionControl.release();
306
+
307
+ await promiseA;
308
+ await promiseB;
309
+
310
+ assert.equal(provisionRequests, 1);
364
311
  } finally {
312
+ provisionControl.release();
365
313
  globalThis.fetch = originalFetch;
366
314
  }
367
315
  });
368
316
 
369
- test("search retries auto-provision after an eager startup failure", async () => {
317
+ test("a second setup retry reuses the locally persisted provisioned key before config write-back", async () => {
370
318
  const originalFetch = globalThis.fetch;
371
- const apiUrl = uniqueApiUrl("retry");
372
- const infoLogs: string[] = [];
373
- const errorLogs: string[] = [];
374
- let provisionAttempts = 0;
319
+ const apiUrl = uniqueApiUrl("shared-retry");
320
+ const infoLogsA: string[] = [];
321
+ const infoLogsB: string[] = [];
322
+ let provisionRequests = 0;
375
323
  let searchRequests = 0;
376
- let capability: SearchCapability | null = null;
377
324
 
378
325
  globalThis.fetch = async (input, init) => {
379
326
  const url = String(input);
380
327
 
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" }), {
328
+ if (url === `${apiUrl}/v1alpha1/mem9s`) {
329
+ provisionRequests += 1;
330
+ return new Response(JSON.stringify({ id: "space-shared-retry" }), {
394
331
  status: 201,
395
332
  headers: {
396
333
  "Content-Type": "application/json",
@@ -401,8 +338,7 @@ test("search retries auto-provision after an eager startup failure", async () =>
401
338
  if (url.includes("/v1alpha2/mem9s/memories")) {
402
339
  searchRequests += 1;
403
340
  const headers = init?.headers as Record<string, string> | undefined;
404
- assert.equal(headers?.["X-API-Key"], "space-2");
405
-
341
+ assert.equal(headers?.["X-API-Key"], "space-shared-retry");
406
342
  return new Response(
407
343
  JSON.stringify({
408
344
  memories: [],
@@ -423,35 +359,32 @@ test("search retries auto-provision after an eager startup failure", async () =>
423
359
  };
424
360
 
425
361
  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");
362
+ const pluginConfig = {
363
+ apiUrl,
364
+ provisionToken: "token-shared-retry",
365
+ };
366
+
367
+ const apiA = createStubApi(pluginConfig, { infoLogs: infoLogsA });
368
+ mnemoPlugin.register(apiA);
369
+ const firstHook = apiA.getHook("before_prompt_build");
370
+ await firstHook({ prompt: "hi" });
371
+
372
+ let capabilityB: SearchCapability | null = null;
373
+ const apiB = createStubApi(pluginConfig, {
374
+ infoLogs: infoLogsB,
375
+ onRegisterCapability: (_slot, registeredCapability) => {
376
+ capabilityB = registeredCapability as SearchCapability;
377
+ },
378
+ });
379
+ mnemoPlugin.register(apiB);
380
+ assert.notEqual(capabilityB, null);
381
+ const secondResult = await capabilityB!.search("hello");
449
382
 
450
- assert.deepEqual(result, { data: [], total: 0 });
451
- assert.equal(provisionAttempts, 2);
383
+ assert.equal(provisionRequests, 1);
384
+ assert.deepEqual(secondResult, { data: [], total: 0 });
452
385
  assert.equal(searchRequests, 1);
453
386
  assert.equal(
454
- infoLogs.some((msg) => msg.includes("*** Auto-provisioned apiKey=space-2 ***")),
387
+ infoLogsB.includes("[mem9] reusing locally persisted create-new API key for this provisionToken"),
455
388
  true,
456
389
  );
457
390
  } 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,9 +23,8 @@ 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
- const SHARED_PROVISION_RESULT_TTL_MS = 2 * 60 * 1000;
29
28
  const sharedProvisionPromises = new Map<string, Promise<string>>();
30
29
 
31
30
  type SharedProvisionState =
@@ -107,10 +106,6 @@ function errorMessage(err: unknown): string {
107
106
  return err instanceof Error ? err.message : String(err);
108
107
  }
109
108
 
110
- function shouldEagerAutoProvision(cfg: PluginConfig): boolean {
111
- return cfg.apiUrl != null || cfg.provisionQueryParams != null;
112
- }
113
-
114
109
  function sleep(ms: number): Promise<void> {
115
110
  return new Promise((resolve) => setTimeout(resolve, ms));
116
111
  }
@@ -118,6 +113,7 @@ function sleep(ms: number): Promise<void> {
118
113
  function sharedProvisionKey(
119
114
  apiUrl: string,
120
115
  provisionQueryParams: Record<string, string>,
116
+ provisionToken: string,
121
117
  ): string {
122
118
  const normalizedProvisionQueryParams = Object.fromEntries(
123
119
  Object.entries(provisionQueryParams).sort(([left], [right]) => left.localeCompare(right)),
@@ -127,6 +123,7 @@ function sharedProvisionKey(
127
123
  .update(
128
124
  JSON.stringify({
129
125
  apiUrl,
126
+ provisionToken,
130
127
  provisionQueryParams: normalizedProvisionQueryParams,
131
128
  }),
132
129
  )
@@ -233,11 +230,7 @@ async function waitForSharedProvisionResult(
233
230
  }
234
231
 
235
232
  if (state.status === "done") {
236
- if (now - state.finishedAt <= SHARED_PROVISION_RESULT_TTL_MS) {
237
- return state.apiKey;
238
- }
239
- await removeSharedProvisionState(filePath);
240
- return null;
233
+ return state.apiKey;
241
234
  }
242
235
 
243
236
  if (state.status === "error") {
@@ -257,11 +250,12 @@ async function waitForSharedProvisionResult(
257
250
  async function resolveSharedProvisionedAPIKey(
258
251
  apiUrl: string,
259
252
  provisionQueryParams: Record<string, string>,
253
+ provisionToken: string,
260
254
  timeouts: Required<BackendTimeouts>,
261
255
  logger: OpenClawPluginApi["logger"],
262
256
  registerTenant: () => Promise<string>,
263
257
  ): Promise<string> {
264
- const key = sharedProvisionKey(apiUrl, provisionQueryParams);
258
+ const key = sharedProvisionKey(apiUrl, provisionQueryParams, provisionToken);
265
259
  const existingPromise = sharedProvisionPromises.get(key);
266
260
  if (existingPromise) {
267
261
  return existingPromise;
@@ -272,25 +266,21 @@ async function resolveSharedProvisionedAPIKey(
272
266
  const sharedPromise = (async () => {
273
267
  while (true) {
274
268
  const state = await readSharedProvisionState(filePath);
275
- const now = Date.now();
276
269
 
277
270
  if (state?.status === "done") {
278
- if (now - state.finishedAt <= SHARED_PROVISION_RESULT_TTL_MS) {
279
- logger.info("[mem9] reusing shared auto-provisioned apiKey pending config write-back");
280
- return state.apiKey;
281
- }
282
- await removeSharedProvisionState(filePath);
283
- continue;
271
+ logger.info("[mem9] reusing locally persisted create-new API key for this provisionToken");
272
+ return state.apiKey;
284
273
  }
285
274
 
286
275
  if (state?.status === "error") {
287
276
  await removeSharedProvisionState(filePath);
288
277
  } else if (state?.status === "pending") {
278
+ const now = Date.now();
289
279
  if (now - state.startedAt > waitTimeoutMs) {
290
280
  await removeSharedProvisionState(filePath);
291
281
  continue;
292
282
  }
293
- logger.info("[mem9] auto-provision already in progress in another mem9 instance; waiting");
283
+ logger.info("[mem9] create-new provision already in progress in another mem9 instance; waiting");
294
284
  const sharedApiKey = await waitForSharedProvisionResult(filePath, waitTimeoutMs);
295
285
  if (sharedApiKey) {
296
286
  return sharedApiKey;
@@ -373,7 +363,9 @@ interface AnyAgentTool {
373
363
  execute: (_id: string, params: unknown) => Promise<unknown>;
374
364
  }
375
365
 
376
- function buildTools(backend: MemoryBackend): AnyAgentTool[] {
366
+ function buildTools(
367
+ backend: MemoryBackend,
368
+ ): AnyAgentTool[] {
377
369
  return [
378
370
  {
379
371
  name: "memory_store",
@@ -558,6 +550,10 @@ const mnemoPlugin = {
558
550
  register(api: OpenClawPluginApi) {
559
551
  const cfg = (api.pluginConfig ?? {}) as PluginConfig;
560
552
  const effectiveApiUrl = cfg.apiUrl ?? DEFAULT_API_URL;
553
+ const configuredProvisionToken =
554
+ typeof cfg.provisionToken === "string" && cfg.provisionToken.trim() !== ""
555
+ ? cfg.provisionToken.trim()
556
+ : null;
561
557
  const provisionQueryParams = cfg.provisionQueryParams ?? {};
562
558
  const timeoutConfig = resolveTimeouts(cfg, api.logger);
563
559
  const hookAgentId = cfg.agentName ?? "agent";
@@ -566,6 +562,7 @@ const mnemoPlugin = {
566
562
  }
567
563
 
568
564
  const configuredApiKey = cfg.apiKey ?? cfg.tenantID;
565
+ const provisionWaitTimeoutMs = Math.max(timeoutConfig.defaultTimeoutMs + 5_000, 30_000);
569
566
  if (cfg.apiKey && cfg.tenantID) {
570
567
  api.logger.info("[mem9] both apiKey and tenantID set; using apiKey");
571
568
  } else if (cfg.tenantID) {
@@ -583,42 +580,92 @@ const mnemoPlugin = {
583
580
  );
584
581
  const result = await backend.register();
585
582
  api.logger.info(
586
- `[mem9] *** Auto-provisioned apiKey=${result.id} *** Save this to your config as apiKey`
583
+ `[mem9] *** Auto-provisioned apiKey=${result.id} *** Save this for recovery or reconnect as apiKey`
587
584
  );
588
585
  return result.id;
589
586
  };
587
+ let runtimeProvisionedAPIKey: string | null = null;
588
+ let loggedPersistedProvisionedAPIKeyReuse = false;
590
589
  let registrationPromise: Promise<string> | null = null;
591
- const resolveAPIKey = (agentName: string): Promise<string> => {
592
- if (configuredApiKey) return Promise.resolve(configuredApiKey);
590
+ const provisionAPIKey = (agentName: string): Promise<string> => {
591
+ if (configuredApiKey) {
592
+ return Promise.reject(
593
+ new Error(
594
+ "mem9 create-new auto-provision is only available before apiKey is configured",
595
+ ),
596
+ );
597
+ }
598
+ if (runtimeProvisionedAPIKey) {
599
+ return Promise.resolve(runtimeProvisionedAPIKey);
600
+ }
601
+ if (!configuredProvisionToken) {
602
+ return Promise.reject(
603
+ new Error("mem9 create-new setup cannot provision because provisionToken is missing"),
604
+ );
605
+ }
593
606
  if (!registrationPromise) {
594
607
  registrationPromise = resolveSharedProvisionedAPIKey(
595
608
  effectiveApiUrl,
596
609
  provisionQueryParams,
610
+ configuredProvisionToken,
597
611
  timeoutConfig,
598
612
  api.logger,
599
613
  () => registerTenant(agentName),
600
- ).catch((err) => {
601
- registrationPromise = null;
602
- throw err;
603
- });
614
+ )
615
+ .then((apiKey) => {
616
+ runtimeProvisionedAPIKey = apiKey;
617
+ return apiKey;
618
+ })
619
+ .catch((err) => {
620
+ registrationPromise = null;
621
+ throw err;
622
+ });
604
623
  }
605
624
  return registrationPromise;
606
625
  };
626
+ const resolveAPIKey = async (): Promise<string> => {
627
+ if (configuredApiKey) return Promise.resolve(configuredApiKey);
628
+ if (runtimeProvisionedAPIKey) {
629
+ return runtimeProvisionedAPIKey;
630
+ }
631
+ if (configuredProvisionToken) {
632
+ const sharedKey = sharedProvisionKey(
633
+ effectiveApiUrl,
634
+ provisionQueryParams,
635
+ configuredProvisionToken,
636
+ );
637
+ const sharedApiKey = await waitForSharedProvisionResult(
638
+ sharedProvisionStatePath(sharedKey),
639
+ provisionWaitTimeoutMs,
640
+ );
641
+ if (sharedApiKey) {
642
+ runtimeProvisionedAPIKey = sharedApiKey;
643
+ if (!loggedPersistedProvisionedAPIKeyReuse) {
644
+ api.logger.info("[mem9] reusing locally persisted create-new API key for this provisionToken");
645
+ loggedPersistedProvisionedAPIKeyReuse = true;
646
+ }
647
+ return sharedApiKey;
648
+ }
649
+ }
650
+ throw new PendingProvisionError();
651
+ };
607
652
 
608
653
  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
- });
654
+ if (!configuredApiKey) {
655
+ if (configuredProvisionToken) {
656
+ api.logger.info(
657
+ "[mem9] apiKey not configured; waiting for the first post-restart message to finish create-new provision",
658
+ );
659
+ } else {
660
+ api.logger.info("[mem9] apiKey not configured; mem9 will stay idle until apiKey is configured");
661
+ }
615
662
  }
616
663
 
617
664
  const factory: ToolFactory = (ctx: ToolContext) => {
618
665
  const agentId = ctx.agentId ?? cfg.agentName ?? "agent";
619
666
  const backend = new LazyServerBackend(
620
667
  effectiveApiUrl,
621
- () => resolveAPIKey(agentId),
668
+ resolveAPIKey,
622
669
  agentId,
623
670
  timeoutConfig,
624
671
  );
@@ -630,11 +677,25 @@ const mnemoPlugin = {
630
677
  // Shared lazy backend for hooks and capability registration.
631
678
  const hookBackend = new LazyServerBackend(
632
679
  effectiveApiUrl,
633
- () => resolveAPIKey(hookAgentId),
680
+ resolveAPIKey,
634
681
  hookAgentId,
635
682
  timeoutConfig,
636
683
  );
637
684
 
685
+ const withPendingProvisionFallback = async <T>(
686
+ action: () => Promise<T>,
687
+ fallback: T,
688
+ ): Promise<T> => {
689
+ try {
690
+ return await action();
691
+ } catch (err) {
692
+ if (isPendingProvisionError(err)) {
693
+ return fallback;
694
+ }
695
+ throw err;
696
+ }
697
+ };
698
+
638
699
  // Register memory capability so OpenClaw 2026.4.2+ binds this plugin to
639
700
  // the memory slot. Without this, the plugin is treated as a legacy
640
701
  // hook-only plugin and automatic context injection won't work.
@@ -642,14 +703,24 @@ const mnemoPlugin = {
642
703
  if (typeof api.registerCapability === "function") {
643
704
  api.registerCapability("memory", {
644
705
  search: async (query, opts) => {
645
- const result = await hookBackend.search({ q: query, limit: opts?.limit });
706
+ const result = await withPendingProvisionFallback(
707
+ () => hookBackend.search({ q: query, limit: opts?.limit }),
708
+ { data: [], total: 0, limit: opts?.limit ?? 20, offset: 0 },
709
+ );
646
710
  return { data: result.data, total: result.total };
647
711
  },
648
712
  store: async (content, opts) => {
649
- return hookBackend.store({ content, tags: opts?.tags, source: opts?.source });
713
+ try {
714
+ return await hookBackend.store({ content, tags: opts?.tags, source: opts?.source });
715
+ } catch (err) {
716
+ if (isPendingProvisionError(err)) {
717
+ return null;
718
+ }
719
+ throw err;
720
+ }
650
721
  },
651
- get: (id) => hookBackend.get(id),
652
- remove: (id) => hookBackend.remove(id),
722
+ get: (id) => withPendingProvisionFallback(() => hookBackend.get(id), null),
723
+ remove: (id) => withPendingProvisionFallback(() => hookBackend.remove(id), false),
653
724
  });
654
725
  }
655
726
 
@@ -657,6 +728,9 @@ const mnemoPlugin = {
657
728
  registerHooks(api, hookBackend, api.logger, {
658
729
  maxIngestBytes: cfg.maxIngestBytes,
659
730
  fallbackAgentId: hookAgentId,
731
+ provisionForCreateNew: configuredApiKey || !configuredProvisionToken
732
+ ? undefined
733
+ : () => provisionAPIKey(hookAgentId),
660
734
  });
661
735
  },
662
736
  };
@@ -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 first-message API-key provisioning runs only once and can be reused on this machine before apiKey is configured explicitly"
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 first create-new provision request triggered after the initial restart",
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.6",
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;