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