@mem9/mem9 0.4.3 → 0.4.4
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 +4 -1
- package/index.test.ts +277 -10
- package/index.ts +262 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
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
|
+
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.
|
|
6
|
+
|
|
5
7
|
## 🚀 Quick Start (Server Mode)
|
|
6
8
|
|
|
7
9
|
**You need a running `mnemo-server` instance.**
|
|
@@ -179,7 +181,7 @@ Defined in `openclaw.plugin.json`:
|
|
|
179
181
|
| `searchTimeoutMs` | number | Timeout for `memory_search` and automatic recall search in milliseconds. Default: `15000` |
|
|
180
182
|
| `tenantID` | string | Legacy alias for `apiKey`. The plugin still uses `/v1alpha2/mem9s/...` with `X-API-Key`. |
|
|
181
183
|
|
|
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.
|
|
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.
|
|
183
185
|
|
|
184
186
|
## Timeout Behavior
|
|
185
187
|
|
|
@@ -228,5 +230,6 @@ openclaw-plugin/
|
|
|
228
230
|
|---|---|---|
|
|
229
231
|
| `No mode configured` | Missing config | Add `apiUrl` and `apiKey` (or legacy `tenantID`) to plugin config |
|
|
230
232
|
| `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 |
|
|
231
234
|
| Search requests time out | Hybrid/vector search exceeds plugin timeout | Increase `searchTimeoutMs` in plugin config |
|
|
232
235
|
| Plugin not loading | Not in memory slot | Set `"slots": {"memory": "mem9"}` in openclaw.json |
|
package/index.test.ts
CHANGED
|
@@ -14,6 +14,10 @@ interface StubApi {
|
|
|
14
14
|
on: () => void;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
interface SearchCapability {
|
|
18
|
+
search: (query: string, opts?: { limit?: number }) => Promise<{ data: unknown[]; total: number }>;
|
|
19
|
+
}
|
|
20
|
+
|
|
17
21
|
function createStubApi(
|
|
18
22
|
pluginConfig: unknown,
|
|
19
23
|
options?: {
|
|
@@ -45,8 +49,24 @@ async function flushAsyncWork(): Promise<void> {
|
|
|
45
49
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
46
50
|
}
|
|
47
51
|
|
|
52
|
+
async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise<void> {
|
|
53
|
+
const startedAt = Date.now();
|
|
54
|
+
|
|
55
|
+
while (!predicate()) {
|
|
56
|
+
if (Date.now() - startedAt > timeoutMs) {
|
|
57
|
+
throw new Error("timed out waiting for condition");
|
|
58
|
+
}
|
|
59
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function uniqueApiUrl(name: string): string {
|
|
64
|
+
return `https://api.mem9.ai/${name}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
48
67
|
test("register eagerly auto-provisions when apiKey is absent", async () => {
|
|
49
68
|
const originalFetch = globalThis.fetch;
|
|
69
|
+
const apiUrl = uniqueApiUrl("eager");
|
|
50
70
|
const requests: string[] = [];
|
|
51
71
|
const infoLogs: string[] = [];
|
|
52
72
|
const errorLogs: string[] = [];
|
|
@@ -66,7 +86,7 @@ test("register eagerly auto-provisions when apiKey is absent", async () => {
|
|
|
66
86
|
mnemoPlugin.register(
|
|
67
87
|
createStubApi(
|
|
68
88
|
{
|
|
69
|
-
apiUrl
|
|
89
|
+
apiUrl,
|
|
70
90
|
provisionQueryParams: {
|
|
71
91
|
utm_source: "bosn",
|
|
72
92
|
},
|
|
@@ -75,13 +95,13 @@ test("register eagerly auto-provisions when apiKey is absent", async () => {
|
|
|
75
95
|
),
|
|
76
96
|
);
|
|
77
97
|
|
|
78
|
-
await
|
|
98
|
+
await waitFor(() => requests.length === 1);
|
|
79
99
|
|
|
80
100
|
assert.deepEqual(errorLogs, []);
|
|
81
101
|
assert.equal(requests.length, 1);
|
|
82
102
|
assert.equal(
|
|
83
103
|
requests[0],
|
|
84
|
-
|
|
104
|
+
`${apiUrl}/v1alpha1/mem9s?utm_source=bosn`,
|
|
85
105
|
);
|
|
86
106
|
assert.equal(
|
|
87
107
|
infoLogs.includes("[mem9] apiKey not configured; starting auto-provision"),
|
|
@@ -96,15 +116,264 @@ test("register eagerly auto-provisions when apiKey is absent", async () => {
|
|
|
96
116
|
}
|
|
97
117
|
});
|
|
98
118
|
|
|
119
|
+
test("register shares an in-flight auto-provision across concurrent plugin registrations", async () => {
|
|
120
|
+
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
|
+
}
|
|
170
|
+
|
|
171
|
+
throw new Error(`unexpected fetch: ${url}`);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
mnemoPlugin.register(
|
|
176
|
+
createStubApi(
|
|
177
|
+
{ apiUrl },
|
|
178
|
+
{
|
|
179
|
+
infoLogs: infoLogsA,
|
|
180
|
+
errorLogs,
|
|
181
|
+
onRegisterCapability: (_slot, registeredCapability) => {
|
|
182
|
+
capabilityA = registeredCapability as typeof capabilityA;
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
),
|
|
186
|
+
);
|
|
187
|
+
mnemoPlugin.register(
|
|
188
|
+
createStubApi(
|
|
189
|
+
{ apiUrl },
|
|
190
|
+
{
|
|
191
|
+
infoLogs: infoLogsB,
|
|
192
|
+
errorLogs,
|
|
193
|
+
onRegisterCapability: (_slot, registeredCapability) => {
|
|
194
|
+
capabilityB = registeredCapability as typeof capabilityB;
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
),
|
|
198
|
+
);
|
|
199
|
+
|
|
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;
|
|
214
|
+
|
|
215
|
+
await readyCapabilityA.search("hello");
|
|
216
|
+
await readyCapabilityB.search("hello");
|
|
217
|
+
|
|
218
|
+
assert.equal(provisionRequests, 1);
|
|
219
|
+
assert.equal(searchRequests, 2);
|
|
220
|
+
} finally {
|
|
221
|
+
provisionControl.release();
|
|
222
|
+
globalThis.fetch = originalFetch;
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("register reuses a shared auto-provisioned key before config write-back", async () => {
|
|
227
|
+
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;
|
|
234
|
+
let provisionRequests = 0;
|
|
235
|
+
let searchRequests = 0;
|
|
236
|
+
|
|
237
|
+
globalThis.fetch = async (input, init) => {
|
|
238
|
+
const url = String(input);
|
|
239
|
+
|
|
240
|
+
if (url === `${apiUrl}/v1alpha1/mem9s`) {
|
|
241
|
+
provisionRequests += 1;
|
|
242
|
+
return new Response(JSON.stringify({ id: "space-shared-done" }), {
|
|
243
|
+
status: 201,
|
|
244
|
+
headers: {
|
|
245
|
+
"Content-Type": "application/json",
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (url.includes("/v1alpha2/mem9s/memories")) {
|
|
251
|
+
searchRequests += 1;
|
|
252
|
+
const headers = init?.headers as Record<string, string> | undefined;
|
|
253
|
+
assert.equal(headers?.["X-API-Key"], "space-shared-done");
|
|
254
|
+
|
|
255
|
+
return new Response(
|
|
256
|
+
JSON.stringify({
|
|
257
|
+
memories: [],
|
|
258
|
+
total: 0,
|
|
259
|
+
limit: 20,
|
|
260
|
+
offset: 0,
|
|
261
|
+
}),
|
|
262
|
+
{
|
|
263
|
+
status: 200,
|
|
264
|
+
headers: {
|
|
265
|
+
"Content-Type": "application/json",
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
throw new Error(`unexpected fetch: ${url}`);
|
|
272
|
+
};
|
|
273
|
+
|
|
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
|
+
},
|
|
284
|
+
},
|
|
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
|
+
},
|
|
301
|
+
},
|
|
302
|
+
),
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
await waitFor(() =>
|
|
306
|
+
infoLogsB.includes("[mem9] reusing shared auto-provisioned apiKey pending config write-back"),
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
assert.deepEqual(errorLogs, []);
|
|
310
|
+
assert.equal(provisionRequests, 1);
|
|
311
|
+
if (!capabilityA || !capabilityB) {
|
|
312
|
+
throw new Error("capability registration did not complete");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const readyCapabilityA: SearchCapability = capabilityA;
|
|
316
|
+
const readyCapabilityB: SearchCapability = capabilityB;
|
|
317
|
+
|
|
318
|
+
await readyCapabilityA.search("hello");
|
|
319
|
+
await readyCapabilityB.search("hello");
|
|
320
|
+
|
|
321
|
+
assert.equal(searchRequests, 2);
|
|
322
|
+
assert.equal(
|
|
323
|
+
infoLogsB.includes("[mem9] reusing shared auto-provisioned apiKey pending config write-back"),
|
|
324
|
+
true,
|
|
325
|
+
);
|
|
326
|
+
} finally {
|
|
327
|
+
globalThis.fetch = originalFetch;
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("register does not auto-provision on empty config", async () => {
|
|
332
|
+
const originalFetch = globalThis.fetch;
|
|
333
|
+
const requests: string[] = [];
|
|
334
|
+
const infoLogs: string[] = [];
|
|
335
|
+
const errorLogs: string[] = [];
|
|
336
|
+
|
|
337
|
+
globalThis.fetch = async (input) => {
|
|
338
|
+
requests.push(String(input));
|
|
339
|
+
|
|
340
|
+
return new Response(JSON.stringify({ id: "space-unexpected" }), {
|
|
341
|
+
status: 201,
|
|
342
|
+
headers: {
|
|
343
|
+
"Content-Type": "application/json",
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
mnemoPlugin.register(
|
|
350
|
+
createStubApi(
|
|
351
|
+
{},
|
|
352
|
+
{ infoLogs, errorLogs },
|
|
353
|
+
),
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
await flushAsyncWork();
|
|
357
|
+
|
|
358
|
+
assert.deepEqual(requests, []);
|
|
359
|
+
assert.deepEqual(errorLogs, []);
|
|
360
|
+
assert.equal(
|
|
361
|
+
infoLogs.includes("[mem9] apiKey not configured; starting auto-provision"),
|
|
362
|
+
false,
|
|
363
|
+
);
|
|
364
|
+
} finally {
|
|
365
|
+
globalThis.fetch = originalFetch;
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
99
369
|
test("search retries auto-provision after an eager startup failure", async () => {
|
|
100
370
|
const originalFetch = globalThis.fetch;
|
|
371
|
+
const apiUrl = uniqueApiUrl("retry");
|
|
101
372
|
const infoLogs: string[] = [];
|
|
102
373
|
const errorLogs: string[] = [];
|
|
103
374
|
let provisionAttempts = 0;
|
|
104
375
|
let searchRequests = 0;
|
|
105
|
-
let capability:
|
|
106
|
-
search: (query: string, opts?: { limit?: number }) => Promise<{ data: unknown[]; total: number }>;
|
|
107
|
-
} | null = null;
|
|
376
|
+
let capability: SearchCapability | null = null;
|
|
108
377
|
|
|
109
378
|
globalThis.fetch = async (input, init) => {
|
|
110
379
|
const url = String(input);
|
|
@@ -156,9 +425,7 @@ test("search retries auto-provision after an eager startup failure", async () =>
|
|
|
156
425
|
try {
|
|
157
426
|
mnemoPlugin.register(
|
|
158
427
|
createStubApi(
|
|
159
|
-
{
|
|
160
|
-
apiUrl: "https://api.mem9.ai",
|
|
161
|
-
},
|
|
428
|
+
{ apiUrl },
|
|
162
429
|
{
|
|
163
430
|
infoLogs,
|
|
164
431
|
errorLogs,
|
|
@@ -169,7 +436,7 @@ test("search retries auto-provision after an eager startup failure", async () =>
|
|
|
169
436
|
),
|
|
170
437
|
);
|
|
171
438
|
|
|
172
|
-
await
|
|
439
|
+
await waitFor(() => provisionAttempts === 1);
|
|
173
440
|
|
|
174
441
|
assert.equal(provisionAttempts, 1);
|
|
175
442
|
assert.equal(
|
package/index.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
1
6
|
import type { MemoryBackend } from "./backend.js";
|
|
2
7
|
import {
|
|
3
8
|
DEFAULT_SEARCH_TIMEOUT_MS,
|
|
@@ -18,6 +23,29 @@ import type {
|
|
|
18
23
|
|
|
19
24
|
const DEFAULT_API_URL = "https://api.mem9.ai";
|
|
20
25
|
const TIMEOUT_FIELDS = ["defaultTimeoutMs", "searchTimeoutMs"] as const;
|
|
26
|
+
const SHARED_PROVISION_DIR = path.join(os.tmpdir(), "mem9-openclaw");
|
|
27
|
+
const SHARED_PROVISION_POLL_INTERVAL_MS = 250;
|
|
28
|
+
const SHARED_PROVISION_RESULT_TTL_MS = 2 * 60 * 1000;
|
|
29
|
+
const sharedProvisionPromises = new Map<string, Promise<string>>();
|
|
30
|
+
|
|
31
|
+
type SharedProvisionState =
|
|
32
|
+
| {
|
|
33
|
+
status: "pending";
|
|
34
|
+
startedAt: number;
|
|
35
|
+
pid: number;
|
|
36
|
+
}
|
|
37
|
+
| {
|
|
38
|
+
status: "done";
|
|
39
|
+
startedAt: number;
|
|
40
|
+
finishedAt: number;
|
|
41
|
+
apiKey: string;
|
|
42
|
+
}
|
|
43
|
+
| {
|
|
44
|
+
status: "error";
|
|
45
|
+
startedAt: number;
|
|
46
|
+
finishedAt: number;
|
|
47
|
+
error: string;
|
|
48
|
+
};
|
|
21
49
|
|
|
22
50
|
function normalizeTimeoutMs(
|
|
23
51
|
value: unknown,
|
|
@@ -79,6 +107,230 @@ function errorMessage(err: unknown): string {
|
|
|
79
107
|
return err instanceof Error ? err.message : String(err);
|
|
80
108
|
}
|
|
81
109
|
|
|
110
|
+
function shouldEagerAutoProvision(cfg: PluginConfig): boolean {
|
|
111
|
+
return cfg.apiUrl != null || cfg.provisionQueryParams != null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function sleep(ms: number): Promise<void> {
|
|
115
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function sharedProvisionKey(
|
|
119
|
+
apiUrl: string,
|
|
120
|
+
provisionQueryParams: Record<string, string>,
|
|
121
|
+
): string {
|
|
122
|
+
const normalizedProvisionQueryParams = Object.fromEntries(
|
|
123
|
+
Object.entries(provisionQueryParams).sort(([left], [right]) => left.localeCompare(right)),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
return createHash("sha256")
|
|
127
|
+
.update(
|
|
128
|
+
JSON.stringify({
|
|
129
|
+
apiUrl,
|
|
130
|
+
provisionQueryParams: normalizedProvisionQueryParams,
|
|
131
|
+
}),
|
|
132
|
+
)
|
|
133
|
+
.digest("hex");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function sharedProvisionStatePath(sharedKey: string): string {
|
|
137
|
+
return path.join(SHARED_PROVISION_DIR, `${sharedKey}.json`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function readSharedProvisionState(filePath: string): Promise<SharedProvisionState | null> {
|
|
141
|
+
try {
|
|
142
|
+
const raw = await readFile(filePath, "utf8");
|
|
143
|
+
const parsed = JSON.parse(raw) as Partial<SharedProvisionState>;
|
|
144
|
+
if (parsed.status === "pending" && typeof parsed.startedAt === "number") {
|
|
145
|
+
return {
|
|
146
|
+
status: "pending",
|
|
147
|
+
startedAt: parsed.startedAt,
|
|
148
|
+
pid: typeof parsed.pid === "number" ? parsed.pid : 0,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (
|
|
152
|
+
parsed.status === "done"
|
|
153
|
+
&& typeof parsed.startedAt === "number"
|
|
154
|
+
&& typeof parsed.finishedAt === "number"
|
|
155
|
+
&& typeof parsed.apiKey === "string"
|
|
156
|
+
) {
|
|
157
|
+
return {
|
|
158
|
+
status: "done",
|
|
159
|
+
startedAt: parsed.startedAt,
|
|
160
|
+
finishedAt: parsed.finishedAt,
|
|
161
|
+
apiKey: parsed.apiKey,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (
|
|
165
|
+
parsed.status === "error"
|
|
166
|
+
&& typeof parsed.startedAt === "number"
|
|
167
|
+
&& typeof parsed.finishedAt === "number"
|
|
168
|
+
&& typeof parsed.error === "string"
|
|
169
|
+
) {
|
|
170
|
+
return {
|
|
171
|
+
status: "error",
|
|
172
|
+
startedAt: parsed.startedAt,
|
|
173
|
+
finishedAt: parsed.finishedAt,
|
|
174
|
+
error: parsed.error,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
} catch (err) {
|
|
178
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function writeSharedProvisionState(
|
|
186
|
+
filePath: string,
|
|
187
|
+
state: SharedProvisionState,
|
|
188
|
+
): Promise<void> {
|
|
189
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
190
|
+
await writeFile(filePath, JSON.stringify(state), "utf8");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function createSharedProvisionPendingState(
|
|
194
|
+
filePath: string,
|
|
195
|
+
startedAt: number,
|
|
196
|
+
): Promise<boolean> {
|
|
197
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
198
|
+
try {
|
|
199
|
+
await writeFile(
|
|
200
|
+
filePath,
|
|
201
|
+
JSON.stringify({
|
|
202
|
+
status: "pending",
|
|
203
|
+
startedAt,
|
|
204
|
+
pid: process.pid,
|
|
205
|
+
} satisfies SharedProvisionState),
|
|
206
|
+
{ encoding: "utf8", flag: "wx" },
|
|
207
|
+
);
|
|
208
|
+
return true;
|
|
209
|
+
} catch (err) {
|
|
210
|
+
if ((err as NodeJS.ErrnoException).code === "EEXIST") {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
throw err;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function removeSharedProvisionState(filePath: string): Promise<void> {
|
|
218
|
+
await rm(filePath, { force: true });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function waitForSharedProvisionResult(
|
|
222
|
+
filePath: string,
|
|
223
|
+
waitTimeoutMs: number,
|
|
224
|
+
): Promise<string | null> {
|
|
225
|
+
const deadline = Date.now() + waitTimeoutMs;
|
|
226
|
+
|
|
227
|
+
while (true) {
|
|
228
|
+
const state = await readSharedProvisionState(filePath);
|
|
229
|
+
const now = Date.now();
|
|
230
|
+
|
|
231
|
+
if (!state) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
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;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (state.status === "error") {
|
|
244
|
+
await removeSharedProvisionState(filePath);
|
|
245
|
+
throw new Error(state.error);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (now - state.startedAt > waitTimeoutMs || now >= deadline) {
|
|
249
|
+
await removeSharedProvisionState(filePath);
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
await sleep(SHARED_PROVISION_POLL_INTERVAL_MS);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function resolveSharedProvisionedAPIKey(
|
|
258
|
+
apiUrl: string,
|
|
259
|
+
provisionQueryParams: Record<string, string>,
|
|
260
|
+
timeouts: Required<BackendTimeouts>,
|
|
261
|
+
logger: OpenClawPluginApi["logger"],
|
|
262
|
+
registerTenant: () => Promise<string>,
|
|
263
|
+
): Promise<string> {
|
|
264
|
+
const key = sharedProvisionKey(apiUrl, provisionQueryParams);
|
|
265
|
+
const existingPromise = sharedProvisionPromises.get(key);
|
|
266
|
+
if (existingPromise) {
|
|
267
|
+
return existingPromise;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const waitTimeoutMs = Math.max(timeouts.defaultTimeoutMs + 5_000, 30_000);
|
|
271
|
+
const filePath = sharedProvisionStatePath(key);
|
|
272
|
+
const sharedPromise = (async () => {
|
|
273
|
+
while (true) {
|
|
274
|
+
const state = await readSharedProvisionState(filePath);
|
|
275
|
+
const now = Date.now();
|
|
276
|
+
|
|
277
|
+
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;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (state?.status === "error") {
|
|
287
|
+
await removeSharedProvisionState(filePath);
|
|
288
|
+
} else if (state?.status === "pending") {
|
|
289
|
+
if (now - state.startedAt > waitTimeoutMs) {
|
|
290
|
+
await removeSharedProvisionState(filePath);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
logger.info("[mem9] auto-provision already in progress in another mem9 instance; waiting");
|
|
294
|
+
const sharedApiKey = await waitForSharedProvisionResult(filePath, waitTimeoutMs);
|
|
295
|
+
if (sharedApiKey) {
|
|
296
|
+
return sharedApiKey;
|
|
297
|
+
}
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const startedAt = Date.now();
|
|
302
|
+
const acquired = await createSharedProvisionPendingState(filePath, startedAt);
|
|
303
|
+
if (!acquired) {
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
const apiKey = await registerTenant();
|
|
309
|
+
await writeSharedProvisionState(filePath, {
|
|
310
|
+
status: "done",
|
|
311
|
+
startedAt,
|
|
312
|
+
finishedAt: Date.now(),
|
|
313
|
+
apiKey,
|
|
314
|
+
});
|
|
315
|
+
return apiKey;
|
|
316
|
+
} catch (err) {
|
|
317
|
+
await writeSharedProvisionState(filePath, {
|
|
318
|
+
status: "error",
|
|
319
|
+
startedAt,
|
|
320
|
+
finishedAt: Date.now(),
|
|
321
|
+
error: errorMessage(err),
|
|
322
|
+
});
|
|
323
|
+
throw err;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
})().finally(() => {
|
|
327
|
+
sharedProvisionPromises.delete(key);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
sharedProvisionPromises.set(key, sharedPromise);
|
|
331
|
+
return sharedPromise;
|
|
332
|
+
}
|
|
333
|
+
|
|
82
334
|
interface MemoryCapability {
|
|
83
335
|
search: (query: string, opts?: { limit?: number }) => Promise<{ data: Memory[]; total: number }>;
|
|
84
336
|
store: (content: string, opts?: { tags?: string[]; source?: string }) => Promise<unknown>;
|
|
@@ -306,6 +558,7 @@ const mnemoPlugin = {
|
|
|
306
558
|
register(api: OpenClawPluginApi) {
|
|
307
559
|
const cfg = (api.pluginConfig ?? {}) as PluginConfig;
|
|
308
560
|
const effectiveApiUrl = cfg.apiUrl ?? DEFAULT_API_URL;
|
|
561
|
+
const provisionQueryParams = cfg.provisionQueryParams ?? {};
|
|
309
562
|
const timeoutConfig = resolveTimeouts(cfg, api.logger);
|
|
310
563
|
const hookAgentId = cfg.agentName ?? "agent";
|
|
311
564
|
if (!cfg.apiUrl) {
|
|
@@ -325,7 +578,7 @@ const mnemoPlugin = {
|
|
|
325
578
|
agentName,
|
|
326
579
|
{
|
|
327
580
|
timeouts: timeoutConfig,
|
|
328
|
-
provisionQueryParams
|
|
581
|
+
provisionQueryParams,
|
|
329
582
|
},
|
|
330
583
|
);
|
|
331
584
|
const result = await backend.register();
|
|
@@ -338,7 +591,13 @@ const mnemoPlugin = {
|
|
|
338
591
|
const resolveAPIKey = (agentName: string): Promise<string> => {
|
|
339
592
|
if (configuredApiKey) return Promise.resolve(configuredApiKey);
|
|
340
593
|
if (!registrationPromise) {
|
|
341
|
-
registrationPromise =
|
|
594
|
+
registrationPromise = resolveSharedProvisionedAPIKey(
|
|
595
|
+
effectiveApiUrl,
|
|
596
|
+
provisionQueryParams,
|
|
597
|
+
timeoutConfig,
|
|
598
|
+
api.logger,
|
|
599
|
+
() => registerTenant(agentName),
|
|
600
|
+
).catch((err) => {
|
|
342
601
|
registrationPromise = null;
|
|
343
602
|
throw err;
|
|
344
603
|
});
|
|
@@ -348,7 +607,7 @@ const mnemoPlugin = {
|
|
|
348
607
|
|
|
349
608
|
api.logger.info("[mem9] Server mode (v1alpha2)");
|
|
350
609
|
|
|
351
|
-
if (!configuredApiKey) {
|
|
610
|
+
if (!configuredApiKey && shouldEagerAutoProvision(cfg)) {
|
|
352
611
|
api.logger.info("[mem9] apiKey not configured; starting auto-provision");
|
|
353
612
|
void resolveAPIKey(hookAgentId).catch((err) => {
|
|
354
613
|
api.logger.error(`[mem9] auto-provision failed: ${errorMessage(err)}`);
|
package/package.json
CHANGED