@mem9/mem9 0.4.3 → 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 +7 -3
- package/backend.ts +12 -0
- package/hooks.ts +11 -2
- package/index.test.ts +252 -64
- package/index.ts +374 -21
- package/openclaw.plugin.json +9 -1
- package/package.json +1 -1
- package/server-backend.test.ts +1 -1
- package/types.ts +1 -0
package/README.md
CHANGED
|
@@ -1,6 +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 `
|
|
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
|
+
|
|
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.
|
|
4
6
|
|
|
5
7
|
## 🚀 Quick Start (Server Mode)
|
|
6
8
|
|
|
@@ -174,12 +176,13 @@ Defined in `openclaw.plugin.json`:
|
|
|
174
176
|
|---|---|---|
|
|
175
177
|
| `apiUrl` | string | mnemo-server URL |
|
|
176
178
|
| `apiKey` | string | Preferred key. Uses `/v1alpha2/mem9s/...` with `X-API-Key` header |
|
|
177
|
-
| `
|
|
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 |
|
|
178
181
|
| `defaultTimeoutMs` | number | Default timeout for non-search mem9 API requests in milliseconds. Default: `8000` |
|
|
179
182
|
| `searchTimeoutMs` | number | Timeout for `memory_search` and automatic recall search in milliseconds. Default: `15000` |
|
|
180
183
|
| `tenantID` | string | Legacy alias for `apiKey`. The plugin still uses `/v1alpha2/mem9s/...` with `X-API-Key`. |
|
|
181
184
|
|
|
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`
|
|
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.
|
|
183
186
|
|
|
184
187
|
## Timeout Behavior
|
|
185
188
|
|
|
@@ -228,5 +231,6 @@ openclaw-plugin/
|
|
|
228
231
|
|---|---|---|
|
|
229
232
|
| `No mode configured` | Missing config | Add `apiUrl` and `apiKey` (or legacy `tenantID`) to plugin config |
|
|
230
233
|
| `Server mode requires...` | Missing key | Add `apiKey` (or legacy `tenantID`) to config |
|
|
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 |
|
|
231
235
|
| Search requests time out | Hybrid/vector search exceeds plugin timeout | Increase `searchTimeoutMs` in plugin config |
|
|
232
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
|
|
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,15 +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;
|
|
23
|
+
on: (...args: unknown[]) => void;
|
|
24
|
+
getTools: (ctx?: { agentId?: string }) => RegisteredTool[];
|
|
15
25
|
}
|
|
16
26
|
|
|
17
27
|
function createStubApi(
|
|
@@ -24,6 +34,9 @@ function createStubApi(
|
|
|
24
34
|
): StubApi {
|
|
25
35
|
const infoLogs = options?.infoLogs ?? [];
|
|
26
36
|
const errorLogs = options?.errorLogs ?? [];
|
|
37
|
+
let toolFactory:
|
|
38
|
+
| ((ctx?: { agentId?: string }) => RegisteredTool[] | RegisteredTool | null | undefined)
|
|
39
|
+
| null = null;
|
|
27
40
|
|
|
28
41
|
return {
|
|
29
42
|
pluginConfig,
|
|
@@ -35,9 +48,21 @@ function createStubApi(
|
|
|
35
48
|
errorLogs.push(args.map(String).join(" "));
|
|
36
49
|
},
|
|
37
50
|
},
|
|
38
|
-
registerTool: () => {
|
|
51
|
+
registerTool: (factory: unknown) => {
|
|
52
|
+
toolFactory = factory as typeof toolFactory;
|
|
53
|
+
},
|
|
39
54
|
registerCapability: options?.onRegisterCapability,
|
|
40
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
|
+
},
|
|
41
66
|
};
|
|
42
67
|
}
|
|
43
68
|
|
|
@@ -45,28 +70,54 @@ async function flushAsyncWork(): Promise<void> {
|
|
|
45
70
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
46
71
|
}
|
|
47
72
|
|
|
48
|
-
|
|
73
|
+
async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise<void> {
|
|
74
|
+
const startedAt = Date.now();
|
|
75
|
+
|
|
76
|
+
while (!predicate()) {
|
|
77
|
+
if (Date.now() - startedAt > timeoutMs) {
|
|
78
|
+
throw new Error("timed out waiting for condition");
|
|
79
|
+
}
|
|
80
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function uniqueApiUrl(name: string): string {
|
|
85
|
+
return `https://api.mem9.ai/${name}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
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 () => {
|
|
49
104
|
const originalFetch = globalThis.fetch;
|
|
105
|
+
const apiUrl = uniqueApiUrl("no-startup-provision");
|
|
50
106
|
const requests: string[] = [];
|
|
51
107
|
const infoLogs: string[] = [];
|
|
52
108
|
const errorLogs: string[] = [];
|
|
53
109
|
|
|
54
110
|
globalThis.fetch = async (input) => {
|
|
55
111
|
requests.push(String(input));
|
|
56
|
-
|
|
57
|
-
return new Response(JSON.stringify({ id: "space-1" }), {
|
|
58
|
-
status: 201,
|
|
59
|
-
headers: {
|
|
60
|
-
"Content-Type": "application/json",
|
|
61
|
-
},
|
|
62
|
-
});
|
|
112
|
+
throw new Error("unexpected fetch");
|
|
63
113
|
};
|
|
64
114
|
|
|
65
115
|
try {
|
|
66
116
|
mnemoPlugin.register(
|
|
67
117
|
createStubApi(
|
|
68
118
|
{
|
|
69
|
-
apiUrl
|
|
119
|
+
apiUrl,
|
|
120
|
+
provisionToken: "token-startup",
|
|
70
121
|
provisionQueryParams: {
|
|
71
122
|
utm_source: "bosn",
|
|
72
123
|
},
|
|
@@ -77,51 +128,67 @@ test("register eagerly auto-provisions when apiKey is absent", async () => {
|
|
|
77
128
|
|
|
78
129
|
await flushAsyncWork();
|
|
79
130
|
|
|
131
|
+
assert.deepEqual(requests, []);
|
|
80
132
|
assert.deepEqual(errorLogs, []);
|
|
81
|
-
assert.equal(requests.length, 1);
|
|
82
|
-
assert.equal(
|
|
83
|
-
requests[0],
|
|
84
|
-
"https://api.mem9.ai/v1alpha1/mem9s?utm_source=bosn",
|
|
85
|
-
);
|
|
86
133
|
assert.equal(
|
|
87
|
-
infoLogs.includes("[mem9] apiKey not configured;
|
|
134
|
+
infoLogs.includes("[mem9] apiKey not configured; waiting for explicit create-new provision"),
|
|
88
135
|
true,
|
|
89
136
|
);
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
137
|
+
} finally {
|
|
138
|
+
globalThis.fetch = originalFetch;
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("memory capability stays idle until explicit provision runs", async () => {
|
|
143
|
+
const originalFetch = globalThis.fetch;
|
|
144
|
+
const apiUrl = uniqueApiUrl("pending-capability");
|
|
145
|
+
let capability: SearchCapability | null = null;
|
|
146
|
+
const requests: string[] = [];
|
|
147
|
+
|
|
148
|
+
globalThis.fetch = async (input) => {
|
|
149
|
+
requests.push(String(input));
|
|
150
|
+
throw new Error("unexpected fetch");
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
mnemoPlugin.register(
|
|
155
|
+
createStubApi(
|
|
156
|
+
{
|
|
157
|
+
apiUrl,
|
|
158
|
+
provisionToken: "token-capability",
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
onRegisterCapability: (_slot, registeredCapability) => {
|
|
162
|
+
capability = registeredCapability as SearchCapability;
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
),
|
|
93
166
|
);
|
|
167
|
+
|
|
168
|
+
assert.notEqual(capability, null);
|
|
169
|
+
|
|
170
|
+
const result = await capability!.search("hello");
|
|
171
|
+
|
|
172
|
+
assert.deepEqual(result, { data: [], total: 0 });
|
|
173
|
+
assert.deepEqual(requests, []);
|
|
94
174
|
} finally {
|
|
95
175
|
globalThis.fetch = originalFetch;
|
|
96
176
|
}
|
|
97
177
|
});
|
|
98
178
|
|
|
99
|
-
test("
|
|
179
|
+
test("mem9_provision_api_key provisions once and unlocks memory access", async () => {
|
|
100
180
|
const originalFetch = globalThis.fetch;
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
let
|
|
181
|
+
const apiUrl = uniqueApiUrl("explicit-provision");
|
|
182
|
+
let capability: SearchCapability | null = null;
|
|
183
|
+
let provisionRequests = 0;
|
|
104
184
|
let searchRequests = 0;
|
|
105
|
-
let capability: {
|
|
106
|
-
search: (query: string, opts?: { limit?: number }) => Promise<{ data: unknown[]; total: number }>;
|
|
107
|
-
} | null = null;
|
|
108
185
|
|
|
109
186
|
globalThis.fetch = async (input, init) => {
|
|
110
187
|
const url = String(input);
|
|
111
188
|
|
|
112
|
-
if (url
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (provisionAttempts === 1) {
|
|
116
|
-
return new Response(JSON.stringify({ error: "boom" }), {
|
|
117
|
-
status: 500,
|
|
118
|
-
headers: {
|
|
119
|
-
"Content-Type": "application/json",
|
|
120
|
-
},
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
return new Response(JSON.stringify({ id: "space-2" }), {
|
|
189
|
+
if (url === `${apiUrl}/v1alpha1/mem9s?utm_source=bosn`) {
|
|
190
|
+
provisionRequests += 1;
|
|
191
|
+
return new Response(JSON.stringify({ id: "space-explicit" }), {
|
|
125
192
|
status: 201,
|
|
126
193
|
headers: {
|
|
127
194
|
"Content-Type": "application/json",
|
|
@@ -132,7 +199,7 @@ test("search retries auto-provision after an eager startup failure", async () =>
|
|
|
132
199
|
if (url.includes("/v1alpha2/mem9s/memories")) {
|
|
133
200
|
searchRequests += 1;
|
|
134
201
|
const headers = init?.headers as Record<string, string> | undefined;
|
|
135
|
-
assert.equal(headers?.["X-API-Key"], "space-
|
|
202
|
+
assert.equal(headers?.["X-API-Key"], "space-explicit");
|
|
136
203
|
|
|
137
204
|
return new Response(
|
|
138
205
|
JSON.stringify({
|
|
@@ -154,37 +221,158 @@ test("search retries auto-provision after an eager startup failure", async () =>
|
|
|
154
221
|
};
|
|
155
222
|
|
|
156
223
|
try {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
224
|
+
const api = createStubApi(
|
|
225
|
+
{
|
|
226
|
+
apiUrl,
|
|
227
|
+
provisionToken: "token-explicit",
|
|
228
|
+
provisionQueryParams: {
|
|
229
|
+
utm_source: "bosn",
|
|
161
230
|
},
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
capability = registeredCapability as typeof capability;
|
|
167
|
-
},
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
onRegisterCapability: (_slot, registeredCapability) => {
|
|
234
|
+
capability = registeredCapability as SearchCapability;
|
|
168
235
|
},
|
|
169
|
-
|
|
236
|
+
},
|
|
170
237
|
);
|
|
238
|
+
mnemoPlugin.register(api);
|
|
171
239
|
|
|
172
|
-
|
|
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
|
+
};
|
|
173
245
|
|
|
174
|
-
assert.equal(
|
|
175
|
-
assert.equal(
|
|
176
|
-
|
|
177
|
-
true,
|
|
178
|
-
);
|
|
179
|
-
assert.notEqual(capability, null);
|
|
246
|
+
assert.equal(provisionResult.ok, true);
|
|
247
|
+
assert.equal(provisionResult.data?.apiKey, "space-explicit");
|
|
248
|
+
assert.equal(provisionRequests, 1);
|
|
180
249
|
|
|
181
|
-
|
|
250
|
+
assert.notEqual(capability, null);
|
|
251
|
+
const searchResult = await capability!.search("hello");
|
|
182
252
|
|
|
183
|
-
assert.deepEqual(
|
|
184
|
-
assert.equal(
|
|
253
|
+
assert.deepEqual(searchResult, { data: [], total: 0 });
|
|
254
|
+
assert.equal(provisionRequests, 1);
|
|
185
255
|
assert.equal(searchRequests, 1);
|
|
256
|
+
} finally {
|
|
257
|
+
globalThis.fetch = originalFetch;
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("concurrent explicit provision calls share one server request", async () => {
|
|
262
|
+
const originalFetch = globalThis.fetch;
|
|
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
|
+
});
|
|
271
|
+
|
|
272
|
+
globalThis.fetch = async (input) => {
|
|
273
|
+
const url = String(input);
|
|
274
|
+
|
|
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}`);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
const pluginConfig = {
|
|
291
|
+
apiUrl,
|
|
292
|
+
provisionToken: "token-shared-explicit",
|
|
293
|
+
};
|
|
294
|
+
const apiA = createStubApi(pluginConfig);
|
|
295
|
+
const apiB = createStubApi(pluginConfig);
|
|
296
|
+
|
|
297
|
+
mnemoPlugin.register(apiA);
|
|
298
|
+
mnemoPlugin.register(apiB);
|
|
299
|
+
|
|
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");
|
|
317
|
+
} finally {
|
|
318
|
+
provisionControl.release();
|
|
319
|
+
globalThis.fetch = originalFetch;
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("a second setup retry reuses the shared provisioned key before config write-back", async () => {
|
|
324
|
+
const originalFetch = globalThis.fetch;
|
|
325
|
+
const apiUrl = uniqueApiUrl("shared-retry");
|
|
326
|
+
const infoLogsA: string[] = [];
|
|
327
|
+
const infoLogsB: string[] = [];
|
|
328
|
+
let provisionRequests = 0;
|
|
329
|
+
|
|
330
|
+
globalThis.fetch = async (input) => {
|
|
331
|
+
const url = String(input);
|
|
332
|
+
|
|
333
|
+
if (url === `${apiUrl}/v1alpha1/mem9s`) {
|
|
334
|
+
provisionRequests += 1;
|
|
335
|
+
return new Response(JSON.stringify({ id: "space-shared-retry" }), {
|
|
336
|
+
status: 201,
|
|
337
|
+
headers: {
|
|
338
|
+
"Content-Type": "application/json",
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
throw new Error(`unexpected fetch: ${url}`);
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
try {
|
|
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
|
+
};
|
|
370
|
+
|
|
371
|
+
assert.equal(provisionRequests, 1);
|
|
372
|
+
assert.equal(secondResult.ok, true);
|
|
373
|
+
assert.equal(secondResult.data?.apiKey, "space-shared-retry");
|
|
186
374
|
assert.equal(
|
|
187
|
-
|
|
375
|
+
infoLogsB.includes("[mem9] reusing shared explicit-provision result pending config write-back"),
|
|
188
376
|
true,
|
|
189
377
|
);
|
|
190
378
|
} finally {
|
package/index.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import
|
|
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
|
+
|
|
6
|
+
import { PendingProvisionError, isPendingProvisionError, type MemoryBackend } from "./backend.js";
|
|
2
7
|
import {
|
|
3
8
|
DEFAULT_SEARCH_TIMEOUT_MS,
|
|
4
9
|
DEFAULT_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.homedir(), ".openclaw", "mem9", "provision");
|
|
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,229 @@ function errorMessage(err: unknown): string {
|
|
|
79
107
|
return err instanceof Error ? err.message : String(err);
|
|
80
108
|
}
|
|
81
109
|
|
|
110
|
+
function sleep(ms: number): Promise<void> {
|
|
111
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function sharedProvisionKey(
|
|
115
|
+
apiUrl: string,
|
|
116
|
+
provisionQueryParams: Record<string, string>,
|
|
117
|
+
provisionToken: string,
|
|
118
|
+
): string {
|
|
119
|
+
const normalizedProvisionQueryParams = Object.fromEntries(
|
|
120
|
+
Object.entries(provisionQueryParams).sort(([left], [right]) => left.localeCompare(right)),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
return createHash("sha256")
|
|
124
|
+
.update(
|
|
125
|
+
JSON.stringify({
|
|
126
|
+
apiUrl,
|
|
127
|
+
provisionToken,
|
|
128
|
+
provisionQueryParams: normalizedProvisionQueryParams,
|
|
129
|
+
}),
|
|
130
|
+
)
|
|
131
|
+
.digest("hex");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function sharedProvisionStatePath(sharedKey: string): string {
|
|
135
|
+
return path.join(SHARED_PROVISION_DIR, `${sharedKey}.json`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function readSharedProvisionState(filePath: string): Promise<SharedProvisionState | null> {
|
|
139
|
+
try {
|
|
140
|
+
const raw = await readFile(filePath, "utf8");
|
|
141
|
+
const parsed = JSON.parse(raw) as Partial<SharedProvisionState>;
|
|
142
|
+
if (parsed.status === "pending" && typeof parsed.startedAt === "number") {
|
|
143
|
+
return {
|
|
144
|
+
status: "pending",
|
|
145
|
+
startedAt: parsed.startedAt,
|
|
146
|
+
pid: typeof parsed.pid === "number" ? parsed.pid : 0,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (
|
|
150
|
+
parsed.status === "done"
|
|
151
|
+
&& typeof parsed.startedAt === "number"
|
|
152
|
+
&& typeof parsed.finishedAt === "number"
|
|
153
|
+
&& typeof parsed.apiKey === "string"
|
|
154
|
+
) {
|
|
155
|
+
return {
|
|
156
|
+
status: "done",
|
|
157
|
+
startedAt: parsed.startedAt,
|
|
158
|
+
finishedAt: parsed.finishedAt,
|
|
159
|
+
apiKey: parsed.apiKey,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
if (
|
|
163
|
+
parsed.status === "error"
|
|
164
|
+
&& typeof parsed.startedAt === "number"
|
|
165
|
+
&& typeof parsed.finishedAt === "number"
|
|
166
|
+
&& typeof parsed.error === "string"
|
|
167
|
+
) {
|
|
168
|
+
return {
|
|
169
|
+
status: "error",
|
|
170
|
+
startedAt: parsed.startedAt,
|
|
171
|
+
finishedAt: parsed.finishedAt,
|
|
172
|
+
error: parsed.error,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
} catch (err) {
|
|
176
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function writeSharedProvisionState(
|
|
184
|
+
filePath: string,
|
|
185
|
+
state: SharedProvisionState,
|
|
186
|
+
): Promise<void> {
|
|
187
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
188
|
+
await writeFile(filePath, JSON.stringify(state), "utf8");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function createSharedProvisionPendingState(
|
|
192
|
+
filePath: string,
|
|
193
|
+
startedAt: number,
|
|
194
|
+
): Promise<boolean> {
|
|
195
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
196
|
+
try {
|
|
197
|
+
await writeFile(
|
|
198
|
+
filePath,
|
|
199
|
+
JSON.stringify({
|
|
200
|
+
status: "pending",
|
|
201
|
+
startedAt,
|
|
202
|
+
pid: process.pid,
|
|
203
|
+
} satisfies SharedProvisionState),
|
|
204
|
+
{ encoding: "utf8", flag: "wx" },
|
|
205
|
+
);
|
|
206
|
+
return true;
|
|
207
|
+
} catch (err) {
|
|
208
|
+
if ((err as NodeJS.ErrnoException).code === "EEXIST") {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
throw err;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function removeSharedProvisionState(filePath: string): Promise<void> {
|
|
216
|
+
await rm(filePath, { force: true });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function waitForSharedProvisionResult(
|
|
220
|
+
filePath: string,
|
|
221
|
+
waitTimeoutMs: number,
|
|
222
|
+
): Promise<string | null> {
|
|
223
|
+
const deadline = Date.now() + waitTimeoutMs;
|
|
224
|
+
|
|
225
|
+
while (true) {
|
|
226
|
+
const state = await readSharedProvisionState(filePath);
|
|
227
|
+
const now = Date.now();
|
|
228
|
+
|
|
229
|
+
if (!state) {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (state.status === "done") {
|
|
234
|
+
if (now - state.finishedAt <= SHARED_PROVISION_RESULT_TTL_MS) {
|
|
235
|
+
return state.apiKey;
|
|
236
|
+
}
|
|
237
|
+
await removeSharedProvisionState(filePath);
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (state.status === "error") {
|
|
242
|
+
await removeSharedProvisionState(filePath);
|
|
243
|
+
throw new Error(state.error);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (now - state.startedAt > waitTimeoutMs || now >= deadline) {
|
|
247
|
+
await removeSharedProvisionState(filePath);
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
await sleep(SHARED_PROVISION_POLL_INTERVAL_MS);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function resolveSharedProvisionedAPIKey(
|
|
256
|
+
apiUrl: string,
|
|
257
|
+
provisionQueryParams: Record<string, string>,
|
|
258
|
+
provisionToken: string,
|
|
259
|
+
timeouts: Required<BackendTimeouts>,
|
|
260
|
+
logger: OpenClawPluginApi["logger"],
|
|
261
|
+
registerTenant: () => Promise<string>,
|
|
262
|
+
): Promise<string> {
|
|
263
|
+
const key = sharedProvisionKey(apiUrl, provisionQueryParams, provisionToken);
|
|
264
|
+
const existingPromise = sharedProvisionPromises.get(key);
|
|
265
|
+
if (existingPromise) {
|
|
266
|
+
return existingPromise;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const waitTimeoutMs = Math.max(timeouts.defaultTimeoutMs + 5_000, 30_000);
|
|
270
|
+
const filePath = sharedProvisionStatePath(key);
|
|
271
|
+
const sharedPromise = (async () => {
|
|
272
|
+
while (true) {
|
|
273
|
+
const state = await readSharedProvisionState(filePath);
|
|
274
|
+
const now = Date.now();
|
|
275
|
+
|
|
276
|
+
if (state?.status === "done") {
|
|
277
|
+
if (now - state.finishedAt <= SHARED_PROVISION_RESULT_TTL_MS) {
|
|
278
|
+
logger.info("[mem9] reusing shared explicit-provision result pending config write-back");
|
|
279
|
+
return state.apiKey;
|
|
280
|
+
}
|
|
281
|
+
await removeSharedProvisionState(filePath);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (state?.status === "error") {
|
|
286
|
+
await removeSharedProvisionState(filePath);
|
|
287
|
+
} else if (state?.status === "pending") {
|
|
288
|
+
if (now - state.startedAt > waitTimeoutMs) {
|
|
289
|
+
await removeSharedProvisionState(filePath);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
logger.info("[mem9] explicit provision already in progress in another mem9 instance; waiting");
|
|
293
|
+
const sharedApiKey = await waitForSharedProvisionResult(filePath, waitTimeoutMs);
|
|
294
|
+
if (sharedApiKey) {
|
|
295
|
+
return sharedApiKey;
|
|
296
|
+
}
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const startedAt = Date.now();
|
|
301
|
+
const acquired = await createSharedProvisionPendingState(filePath, startedAt);
|
|
302
|
+
if (!acquired) {
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
const apiKey = await registerTenant();
|
|
308
|
+
await writeSharedProvisionState(filePath, {
|
|
309
|
+
status: "done",
|
|
310
|
+
startedAt,
|
|
311
|
+
finishedAt: Date.now(),
|
|
312
|
+
apiKey,
|
|
313
|
+
});
|
|
314
|
+
return apiKey;
|
|
315
|
+
} catch (err) {
|
|
316
|
+
await writeSharedProvisionState(filePath, {
|
|
317
|
+
status: "error",
|
|
318
|
+
startedAt,
|
|
319
|
+
finishedAt: Date.now(),
|
|
320
|
+
error: errorMessage(err),
|
|
321
|
+
});
|
|
322
|
+
throw err;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
})().finally(() => {
|
|
326
|
+
sharedProvisionPromises.delete(key);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
sharedProvisionPromises.set(key, sharedPromise);
|
|
330
|
+
return sharedPromise;
|
|
331
|
+
}
|
|
332
|
+
|
|
82
333
|
interface MemoryCapability {
|
|
83
334
|
search: (query: string, opts?: { limit?: number }) => Promise<{ data: Memory[]; total: number }>;
|
|
84
335
|
store: (content: string, opts?: { tags?: string[]; source?: string }) => Promise<unknown>;
|
|
@@ -121,7 +372,10 @@ interface AnyAgentTool {
|
|
|
121
372
|
execute: (_id: string, params: unknown) => Promise<unknown>;
|
|
122
373
|
}
|
|
123
374
|
|
|
124
|
-
function buildTools(
|
|
375
|
+
function buildTools(
|
|
376
|
+
backend: MemoryBackend,
|
|
377
|
+
provisionAPIKey: (() => Promise<string>) | null,
|
|
378
|
+
): AnyAgentTool[] {
|
|
125
379
|
return [
|
|
126
380
|
{
|
|
127
381
|
name: "memory_store",
|
|
@@ -294,6 +548,41 @@ function buildTools(backend: MemoryBackend): AnyAgentTool[] {
|
|
|
294
548
|
}
|
|
295
549
|
},
|
|
296
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
|
+
},
|
|
297
586
|
];
|
|
298
587
|
}
|
|
299
588
|
|
|
@@ -306,6 +595,11 @@ const mnemoPlugin = {
|
|
|
306
595
|
register(api: OpenClawPluginApi) {
|
|
307
596
|
const cfg = (api.pluginConfig ?? {}) as PluginConfig;
|
|
308
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;
|
|
602
|
+
const provisionQueryParams = cfg.provisionQueryParams ?? {};
|
|
309
603
|
const timeoutConfig = resolveTimeouts(cfg, api.logger);
|
|
310
604
|
const hookAgentId = cfg.agentName ?? "agent";
|
|
311
605
|
if (!cfg.apiUrl) {
|
|
@@ -325,7 +619,7 @@ const mnemoPlugin = {
|
|
|
325
619
|
agentName,
|
|
326
620
|
{
|
|
327
621
|
timeouts: timeoutConfig,
|
|
328
|
-
provisionQueryParams
|
|
622
|
+
provisionQueryParams,
|
|
329
623
|
},
|
|
330
624
|
);
|
|
331
625
|
const result = await backend.register();
|
|
@@ -334,36 +628,70 @@ const mnemoPlugin = {
|
|
|
334
628
|
);
|
|
335
629
|
return result.id;
|
|
336
630
|
};
|
|
631
|
+
let runtimeProvisionedAPIKey: string | null = null;
|
|
337
632
|
let registrationPromise: Promise<string> | null = null;
|
|
338
|
-
const
|
|
339
|
-
if (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
|
+
}
|
|
340
649
|
if (!registrationPromise) {
|
|
341
|
-
registrationPromise =
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
650
|
+
registrationPromise = resolveSharedProvisionedAPIKey(
|
|
651
|
+
effectiveApiUrl,
|
|
652
|
+
provisionQueryParams,
|
|
653
|
+
configuredProvisionToken,
|
|
654
|
+
timeoutConfig,
|
|
655
|
+
api.logger,
|
|
656
|
+
() => registerTenant(agentName),
|
|
657
|
+
)
|
|
658
|
+
.then((apiKey) => {
|
|
659
|
+
runtimeProvisionedAPIKey = apiKey;
|
|
660
|
+
return apiKey;
|
|
661
|
+
})
|
|
662
|
+
.catch((err) => {
|
|
663
|
+
registrationPromise = null;
|
|
664
|
+
throw err;
|
|
665
|
+
});
|
|
345
666
|
}
|
|
346
667
|
return registrationPromise;
|
|
347
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
|
+
};
|
|
348
676
|
|
|
349
677
|
api.logger.info("[mem9] Server mode (v1alpha2)");
|
|
350
|
-
|
|
351
678
|
if (!configuredApiKey) {
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
+
}
|
|
356
684
|
}
|
|
357
685
|
|
|
358
686
|
const factory: ToolFactory = (ctx: ToolContext) => {
|
|
359
687
|
const agentId = ctx.agentId ?? cfg.agentName ?? "agent";
|
|
360
688
|
const backend = new LazyServerBackend(
|
|
361
689
|
effectiveApiUrl,
|
|
362
|
-
|
|
690
|
+
resolveAPIKey,
|
|
363
691
|
agentId,
|
|
364
692
|
timeoutConfig,
|
|
365
693
|
);
|
|
366
|
-
return buildTools(backend);
|
|
694
|
+
return buildTools(backend, configuredApiKey ? null : () => provisionAPIKey(agentId));
|
|
367
695
|
};
|
|
368
696
|
|
|
369
697
|
api.registerTool(factory, { names: toolNames });
|
|
@@ -371,11 +699,25 @@ const mnemoPlugin = {
|
|
|
371
699
|
// Shared lazy backend for hooks and capability registration.
|
|
372
700
|
const hookBackend = new LazyServerBackend(
|
|
373
701
|
effectiveApiUrl,
|
|
374
|
-
|
|
702
|
+
resolveAPIKey,
|
|
375
703
|
hookAgentId,
|
|
376
704
|
timeoutConfig,
|
|
377
705
|
);
|
|
378
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
|
+
|
|
379
721
|
// Register memory capability so OpenClaw 2026.4.2+ binds this plugin to
|
|
380
722
|
// the memory slot. Without this, the plugin is treated as a legacy
|
|
381
723
|
// hook-only plugin and automatic context injection won't work.
|
|
@@ -383,14 +725,24 @@ const mnemoPlugin = {
|
|
|
383
725
|
if (typeof api.registerCapability === "function") {
|
|
384
726
|
api.registerCapability("memory", {
|
|
385
727
|
search: async (query, opts) => {
|
|
386
|
-
const result = await
|
|
728
|
+
const result = await withPendingProvisionFallback(
|
|
729
|
+
() => hookBackend.search({ q: query, limit: opts?.limit }),
|
|
730
|
+
{ data: [], total: 0, limit: opts?.limit ?? 20, offset: 0 },
|
|
731
|
+
);
|
|
387
732
|
return { data: result.data, total: result.total };
|
|
388
733
|
},
|
|
389
734
|
store: async (content, opts) => {
|
|
390
|
-
|
|
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
|
+
}
|
|
391
743
|
},
|
|
392
|
-
get: (id) => hookBackend.get(id),
|
|
393
|
-
remove: (id) => hookBackend.remove(id),
|
|
744
|
+
get: (id) => withPendingProvisionFallback(() => hookBackend.get(id), null),
|
|
745
|
+
remove: (id) => withPendingProvisionFallback(() => hookBackend.remove(id), false),
|
|
394
746
|
});
|
|
395
747
|
}
|
|
396
748
|
|
|
@@ -408,6 +760,7 @@ const toolNames = [
|
|
|
408
760
|
"memory_get",
|
|
409
761
|
"memory_update",
|
|
410
762
|
"memory_delete",
|
|
763
|
+
"mem9_provision_api_key",
|
|
411
764
|
];
|
|
412
765
|
|
|
413
766
|
class LazyServerBackend implements MemoryBackend {
|
package/openclaw.plugin.json
CHANGED
|
@@ -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
|
|
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
package/server-backend.test.ts
CHANGED
|
@@ -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
|
|
6
|
+
test("register forwards only utm_* params during create-new provision", async () => {
|
|
7
7
|
const originalFetch = globalThis.fetch;
|
|
8
8
|
let requestedURL = "";
|
|
9
9
|
|