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