@mem9/mem9 0.4.7 → 0.4.8
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 +26 -17
- package/backend.ts +14 -0
- package/hooks.ts +78 -6
- package/index.test.ts +694 -0
- package/index.ts +391 -21
- package/openclaw.plugin.json +29 -0
- package/package.json +3 -1
- package/server-backend.test.ts +82 -0
- package/server-backend.ts +21 -4
- package/types.ts +5 -0
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,28 @@ 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 sharedProvisionPromises = new Map<string, Promise<string>>();
|
|
29
|
+
|
|
30
|
+
type SharedProvisionState =
|
|
31
|
+
| {
|
|
32
|
+
status: "pending";
|
|
33
|
+
startedAt: number;
|
|
34
|
+
pid: number;
|
|
35
|
+
}
|
|
36
|
+
| {
|
|
37
|
+
status: "done";
|
|
38
|
+
startedAt: number;
|
|
39
|
+
finishedAt: number;
|
|
40
|
+
apiKey: string;
|
|
41
|
+
}
|
|
42
|
+
| {
|
|
43
|
+
status: "error";
|
|
44
|
+
startedAt: number;
|
|
45
|
+
finishedAt: number;
|
|
46
|
+
error: string;
|
|
47
|
+
};
|
|
21
48
|
|
|
22
49
|
function normalizeTimeoutMs(
|
|
23
50
|
value: unknown,
|
|
@@ -75,6 +102,225 @@ function jsonResult(data: unknown) {
|
|
|
75
102
|
}
|
|
76
103
|
}
|
|
77
104
|
|
|
105
|
+
function errorMessage(err: unknown): string {
|
|
106
|
+
return err instanceof Error ? err.message : String(err);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function sleep(ms: number): Promise<void> {
|
|
110
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function sharedProvisionKey(
|
|
114
|
+
apiUrl: string,
|
|
115
|
+
provisionQueryParams: Record<string, string>,
|
|
116
|
+
provisionToken: string,
|
|
117
|
+
): string {
|
|
118
|
+
const normalizedProvisionQueryParams = Object.fromEntries(
|
|
119
|
+
Object.entries(provisionQueryParams).sort(([left], [right]) => left.localeCompare(right)),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
return createHash("sha256")
|
|
123
|
+
.update(
|
|
124
|
+
JSON.stringify({
|
|
125
|
+
apiUrl,
|
|
126
|
+
provisionToken,
|
|
127
|
+
provisionQueryParams: normalizedProvisionQueryParams,
|
|
128
|
+
}),
|
|
129
|
+
)
|
|
130
|
+
.digest("hex");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function sharedProvisionStatePath(sharedKey: string): string {
|
|
134
|
+
return path.join(SHARED_PROVISION_DIR, `${sharedKey}.json`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function readSharedProvisionState(filePath: string): Promise<SharedProvisionState | null> {
|
|
138
|
+
try {
|
|
139
|
+
const raw = await readFile(filePath, "utf8");
|
|
140
|
+
const parsed = JSON.parse(raw) as Partial<SharedProvisionState>;
|
|
141
|
+
if (parsed.status === "pending" && typeof parsed.startedAt === "number") {
|
|
142
|
+
return {
|
|
143
|
+
status: "pending",
|
|
144
|
+
startedAt: parsed.startedAt,
|
|
145
|
+
pid: typeof parsed.pid === "number" ? parsed.pid : 0,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (
|
|
149
|
+
parsed.status === "done"
|
|
150
|
+
&& typeof parsed.startedAt === "number"
|
|
151
|
+
&& typeof parsed.finishedAt === "number"
|
|
152
|
+
&& typeof parsed.apiKey === "string"
|
|
153
|
+
) {
|
|
154
|
+
return {
|
|
155
|
+
status: "done",
|
|
156
|
+
startedAt: parsed.startedAt,
|
|
157
|
+
finishedAt: parsed.finishedAt,
|
|
158
|
+
apiKey: parsed.apiKey,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (
|
|
162
|
+
parsed.status === "error"
|
|
163
|
+
&& typeof parsed.startedAt === "number"
|
|
164
|
+
&& typeof parsed.finishedAt === "number"
|
|
165
|
+
&& typeof parsed.error === "string"
|
|
166
|
+
) {
|
|
167
|
+
return {
|
|
168
|
+
status: "error",
|
|
169
|
+
startedAt: parsed.startedAt,
|
|
170
|
+
finishedAt: parsed.finishedAt,
|
|
171
|
+
error: parsed.error,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
} catch (err) {
|
|
175
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function writeSharedProvisionState(
|
|
183
|
+
filePath: string,
|
|
184
|
+
state: SharedProvisionState,
|
|
185
|
+
): Promise<void> {
|
|
186
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
187
|
+
await writeFile(filePath, JSON.stringify(state), "utf8");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function createSharedProvisionPendingState(
|
|
191
|
+
filePath: string,
|
|
192
|
+
startedAt: number,
|
|
193
|
+
): Promise<boolean> {
|
|
194
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
195
|
+
try {
|
|
196
|
+
await writeFile(
|
|
197
|
+
filePath,
|
|
198
|
+
JSON.stringify({
|
|
199
|
+
status: "pending",
|
|
200
|
+
startedAt,
|
|
201
|
+
pid: process.pid,
|
|
202
|
+
} satisfies SharedProvisionState),
|
|
203
|
+
{ encoding: "utf8", flag: "wx" },
|
|
204
|
+
);
|
|
205
|
+
return true;
|
|
206
|
+
} catch (err) {
|
|
207
|
+
if ((err as NodeJS.ErrnoException).code === "EEXIST") {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
throw err;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function removeSharedProvisionState(filePath: string): Promise<void> {
|
|
215
|
+
await rm(filePath, { force: true });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function waitForSharedProvisionResult(
|
|
219
|
+
filePath: string,
|
|
220
|
+
waitTimeoutMs: number,
|
|
221
|
+
): Promise<string | null> {
|
|
222
|
+
const deadline = Date.now() + waitTimeoutMs;
|
|
223
|
+
|
|
224
|
+
while (true) {
|
|
225
|
+
const state = await readSharedProvisionState(filePath);
|
|
226
|
+
const now = Date.now();
|
|
227
|
+
|
|
228
|
+
if (!state) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (state.status === "done") {
|
|
233
|
+
return state.apiKey;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (state.status === "error") {
|
|
237
|
+
await removeSharedProvisionState(filePath);
|
|
238
|
+
throw new Error(state.error);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (now - state.startedAt > waitTimeoutMs || now >= deadline) {
|
|
242
|
+
await removeSharedProvisionState(filePath);
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
await sleep(SHARED_PROVISION_POLL_INTERVAL_MS);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function resolveSharedProvisionedAPIKey(
|
|
251
|
+
apiUrl: string,
|
|
252
|
+
provisionQueryParams: Record<string, string>,
|
|
253
|
+
provisionToken: string,
|
|
254
|
+
timeouts: Required<BackendTimeouts>,
|
|
255
|
+
logger: OpenClawPluginApi["logger"],
|
|
256
|
+
registerTenant: () => Promise<string>,
|
|
257
|
+
): Promise<string> {
|
|
258
|
+
const key = sharedProvisionKey(apiUrl, provisionQueryParams, provisionToken);
|
|
259
|
+
const existingPromise = sharedProvisionPromises.get(key);
|
|
260
|
+
if (existingPromise) {
|
|
261
|
+
return existingPromise;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const waitTimeoutMs = Math.max(timeouts.defaultTimeoutMs + 5_000, 30_000);
|
|
265
|
+
const filePath = sharedProvisionStatePath(key);
|
|
266
|
+
const sharedPromise = (async () => {
|
|
267
|
+
while (true) {
|
|
268
|
+
const state = await readSharedProvisionState(filePath);
|
|
269
|
+
|
|
270
|
+
if (state?.status === "done") {
|
|
271
|
+
logger.info("[mem9] reusing locally persisted create-new API key for this provisionToken");
|
|
272
|
+
return state.apiKey;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (state?.status === "error") {
|
|
276
|
+
await removeSharedProvisionState(filePath);
|
|
277
|
+
} else if (state?.status === "pending") {
|
|
278
|
+
const now = Date.now();
|
|
279
|
+
if (now - state.startedAt > waitTimeoutMs) {
|
|
280
|
+
await removeSharedProvisionState(filePath);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
logger.info("[mem9] create-new provision already in progress in another mem9 instance; waiting");
|
|
284
|
+
const sharedApiKey = await waitForSharedProvisionResult(filePath, waitTimeoutMs);
|
|
285
|
+
if (sharedApiKey) {
|
|
286
|
+
return sharedApiKey;
|
|
287
|
+
}
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const startedAt = Date.now();
|
|
292
|
+
const acquired = await createSharedProvisionPendingState(filePath, startedAt);
|
|
293
|
+
if (!acquired) {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
const apiKey = await registerTenant();
|
|
299
|
+
await writeSharedProvisionState(filePath, {
|
|
300
|
+
status: "done",
|
|
301
|
+
startedAt,
|
|
302
|
+
finishedAt: Date.now(),
|
|
303
|
+
apiKey,
|
|
304
|
+
});
|
|
305
|
+
return apiKey;
|
|
306
|
+
} catch (err) {
|
|
307
|
+
await writeSharedProvisionState(filePath, {
|
|
308
|
+
status: "error",
|
|
309
|
+
startedAt,
|
|
310
|
+
finishedAt: Date.now(),
|
|
311
|
+
error: errorMessage(err),
|
|
312
|
+
});
|
|
313
|
+
throw err;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
})().finally(() => {
|
|
317
|
+
sharedProvisionPromises.delete(key);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
sharedProvisionPromises.set(key, sharedPromise);
|
|
321
|
+
return sharedPromise;
|
|
322
|
+
}
|
|
323
|
+
|
|
78
324
|
interface MemoryCapability {
|
|
79
325
|
search: (query: string, opts?: { limit?: number }) => Promise<{ data: Memory[]; total: number }>;
|
|
80
326
|
store: (content: string, opts?: { tags?: string[]; source?: string }) => Promise<unknown>;
|
|
@@ -117,7 +363,9 @@ interface AnyAgentTool {
|
|
|
117
363
|
execute: (_id: string, params: unknown) => Promise<unknown>;
|
|
118
364
|
}
|
|
119
365
|
|
|
120
|
-
function buildTools(
|
|
366
|
+
function buildTools(
|
|
367
|
+
backend: MemoryBackend,
|
|
368
|
+
): AnyAgentTool[] {
|
|
121
369
|
return [
|
|
122
370
|
{
|
|
123
371
|
name: "memory_store",
|
|
@@ -302,43 +550,126 @@ const mnemoPlugin = {
|
|
|
302
550
|
register(api: OpenClawPluginApi) {
|
|
303
551
|
const cfg = (api.pluginConfig ?? {}) as PluginConfig;
|
|
304
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;
|
|
557
|
+
const provisionQueryParams = cfg.provisionQueryParams ?? {};
|
|
305
558
|
const timeoutConfig = resolveTimeouts(cfg, api.logger);
|
|
559
|
+
const hookAgentId = cfg.agentName ?? "agent";
|
|
560
|
+
const debugEnabled = cfg.debug === true || cfg.debugRecall === true;
|
|
306
561
|
if (!cfg.apiUrl) {
|
|
307
562
|
api.logger.info(`[mem9] apiUrl not configured, using default ${DEFAULT_API_URL}`);
|
|
308
563
|
}
|
|
564
|
+
if (cfg.debugRecall === true && cfg.debug !== true) {
|
|
565
|
+
api.logger.info("[mem9] debugRecall is deprecated; use debug instead");
|
|
566
|
+
}
|
|
309
567
|
|
|
310
568
|
const configuredApiKey = cfg.apiKey ?? cfg.tenantID;
|
|
569
|
+
const provisionWaitTimeoutMs = Math.max(timeoutConfig.defaultTimeoutMs + 5_000, 30_000);
|
|
311
570
|
if (cfg.apiKey && cfg.tenantID) {
|
|
312
571
|
api.logger.info("[mem9] both apiKey and tenantID set; using apiKey");
|
|
313
572
|
} else if (cfg.tenantID) {
|
|
314
573
|
api.logger.info("[mem9] tenantID is deprecated; treating it as apiKey for v1alpha2");
|
|
315
574
|
}
|
|
316
575
|
const registerTenant = async (agentName: string): Promise<string> => {
|
|
317
|
-
const backend = new ServerBackend(
|
|
576
|
+
const backend = new ServerBackend(
|
|
577
|
+
effectiveApiUrl,
|
|
578
|
+
"",
|
|
579
|
+
agentName,
|
|
580
|
+
{
|
|
581
|
+
timeouts: timeoutConfig,
|
|
582
|
+
provisionQueryParams,
|
|
583
|
+
},
|
|
584
|
+
);
|
|
318
585
|
const result = await backend.register();
|
|
319
586
|
api.logger.info(
|
|
320
|
-
`[mem9] *** Auto-provisioned apiKey=${result.id} *** Save this
|
|
587
|
+
`[mem9] *** Auto-provisioned apiKey=${result.id} *** Save this for recovery or reconnect as apiKey`
|
|
321
588
|
);
|
|
322
589
|
return result.id;
|
|
323
590
|
};
|
|
591
|
+
let runtimeProvisionedAPIKey: string | null = null;
|
|
592
|
+
let loggedPersistedProvisionedAPIKeyReuse = false;
|
|
324
593
|
let registrationPromise: Promise<string> | null = null;
|
|
325
|
-
const
|
|
326
|
-
if (configuredApiKey)
|
|
594
|
+
const provisionAPIKey = (agentName: string): Promise<string> => {
|
|
595
|
+
if (configuredApiKey) {
|
|
596
|
+
return Promise.reject(
|
|
597
|
+
new Error(
|
|
598
|
+
"mem9 create-new auto-provision is only available before apiKey is configured",
|
|
599
|
+
),
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
if (runtimeProvisionedAPIKey) {
|
|
603
|
+
return Promise.resolve(runtimeProvisionedAPIKey);
|
|
604
|
+
}
|
|
605
|
+
if (!configuredProvisionToken) {
|
|
606
|
+
return Promise.reject(
|
|
607
|
+
new Error("mem9 create-new setup cannot provision because provisionToken is missing"),
|
|
608
|
+
);
|
|
609
|
+
}
|
|
327
610
|
if (!registrationPromise) {
|
|
328
|
-
registrationPromise =
|
|
611
|
+
registrationPromise = resolveSharedProvisionedAPIKey(
|
|
612
|
+
effectiveApiUrl,
|
|
613
|
+
provisionQueryParams,
|
|
614
|
+
configuredProvisionToken,
|
|
615
|
+
timeoutConfig,
|
|
616
|
+
api.logger,
|
|
617
|
+
() => registerTenant(agentName),
|
|
618
|
+
)
|
|
619
|
+
.then((apiKey) => {
|
|
620
|
+
runtimeProvisionedAPIKey = apiKey;
|
|
621
|
+
return apiKey;
|
|
622
|
+
})
|
|
623
|
+
.catch((err) => {
|
|
624
|
+
registrationPromise = null;
|
|
625
|
+
throw err;
|
|
626
|
+
});
|
|
329
627
|
}
|
|
330
628
|
return registrationPromise;
|
|
331
629
|
};
|
|
630
|
+
const resolveAPIKey = async (): Promise<string> => {
|
|
631
|
+
if (configuredApiKey) return Promise.resolve(configuredApiKey);
|
|
632
|
+
if (runtimeProvisionedAPIKey) {
|
|
633
|
+
return runtimeProvisionedAPIKey;
|
|
634
|
+
}
|
|
635
|
+
if (configuredProvisionToken) {
|
|
636
|
+
const sharedKey = sharedProvisionKey(
|
|
637
|
+
effectiveApiUrl,
|
|
638
|
+
provisionQueryParams,
|
|
639
|
+
configuredProvisionToken,
|
|
640
|
+
);
|
|
641
|
+
const sharedApiKey = await waitForSharedProvisionResult(
|
|
642
|
+
sharedProvisionStatePath(sharedKey),
|
|
643
|
+
provisionWaitTimeoutMs,
|
|
644
|
+
);
|
|
645
|
+
if (sharedApiKey) {
|
|
646
|
+
runtimeProvisionedAPIKey = sharedApiKey;
|
|
647
|
+
if (!loggedPersistedProvisionedAPIKeyReuse) {
|
|
648
|
+
api.logger.info("[mem9] reusing locally persisted create-new API key for this provisionToken");
|
|
649
|
+
loggedPersistedProvisionedAPIKeyReuse = true;
|
|
650
|
+
}
|
|
651
|
+
return sharedApiKey;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
throw new PendingProvisionError();
|
|
655
|
+
};
|
|
332
656
|
|
|
333
657
|
api.logger.info("[mem9] Server mode (v1alpha2)");
|
|
334
|
-
|
|
335
|
-
|
|
658
|
+
if (!configuredApiKey) {
|
|
659
|
+
if (configuredProvisionToken) {
|
|
660
|
+
api.logger.info(
|
|
661
|
+
"[mem9] apiKey not configured; waiting for the first post-restart message to finish create-new provision",
|
|
662
|
+
);
|
|
663
|
+
} else {
|
|
664
|
+
api.logger.info("[mem9] apiKey not configured; mem9 will stay idle until apiKey is configured");
|
|
665
|
+
}
|
|
666
|
+
}
|
|
336
667
|
|
|
337
668
|
const factory: ToolFactory = (ctx: ToolContext) => {
|
|
338
669
|
const agentId = ctx.agentId ?? cfg.agentName ?? "agent";
|
|
339
670
|
const backend = new LazyServerBackend(
|
|
340
671
|
effectiveApiUrl,
|
|
341
|
-
|
|
672
|
+
resolveAPIKey,
|
|
342
673
|
agentId,
|
|
343
674
|
timeoutConfig,
|
|
344
675
|
);
|
|
@@ -350,11 +681,25 @@ const mnemoPlugin = {
|
|
|
350
681
|
// Shared lazy backend for hooks and capability registration.
|
|
351
682
|
const hookBackend = new LazyServerBackend(
|
|
352
683
|
effectiveApiUrl,
|
|
353
|
-
|
|
684
|
+
resolveAPIKey,
|
|
354
685
|
hookAgentId,
|
|
355
686
|
timeoutConfig,
|
|
356
687
|
);
|
|
357
688
|
|
|
689
|
+
const withPendingProvisionFallback = async <T>(
|
|
690
|
+
action: () => Promise<T>,
|
|
691
|
+
fallback: T,
|
|
692
|
+
): Promise<T> => {
|
|
693
|
+
try {
|
|
694
|
+
return await action();
|
|
695
|
+
} catch (err) {
|
|
696
|
+
if (isPendingProvisionError(err)) {
|
|
697
|
+
return fallback;
|
|
698
|
+
}
|
|
699
|
+
throw err;
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
|
|
358
703
|
// Register memory capability so OpenClaw 2026.4.2+ binds this plugin to
|
|
359
704
|
// the memory slot. Without this, the plugin is treated as a legacy
|
|
360
705
|
// hook-only plugin and automatic context injection won't work.
|
|
@@ -362,14 +707,24 @@ const mnemoPlugin = {
|
|
|
362
707
|
if (typeof api.registerCapability === "function") {
|
|
363
708
|
api.registerCapability("memory", {
|
|
364
709
|
search: async (query, opts) => {
|
|
365
|
-
const result = await
|
|
710
|
+
const result = await withPendingProvisionFallback(
|
|
711
|
+
() => hookBackend.search({ q: query, limit: opts?.limit }),
|
|
712
|
+
{ data: [], total: 0, limit: opts?.limit ?? 20, offset: 0 },
|
|
713
|
+
);
|
|
366
714
|
return { data: result.data, total: result.total };
|
|
367
715
|
},
|
|
368
716
|
store: async (content, opts) => {
|
|
369
|
-
|
|
717
|
+
try {
|
|
718
|
+
return await hookBackend.store({ content, tags: opts?.tags, source: opts?.source });
|
|
719
|
+
} catch (err) {
|
|
720
|
+
if (isPendingProvisionError(err)) {
|
|
721
|
+
return null;
|
|
722
|
+
}
|
|
723
|
+
throw err;
|
|
724
|
+
}
|
|
370
725
|
},
|
|
371
|
-
get: (id) => hookBackend.get(id),
|
|
372
|
-
remove: (id) => hookBackend.remove(id),
|
|
726
|
+
get: (id) => withPendingProvisionFallback(() => hookBackend.get(id), null),
|
|
727
|
+
remove: (id) => withPendingProvisionFallback(() => hookBackend.remove(id), false),
|
|
373
728
|
});
|
|
374
729
|
}
|
|
375
730
|
|
|
@@ -377,6 +732,10 @@ const mnemoPlugin = {
|
|
|
377
732
|
registerHooks(api, hookBackend, api.logger, {
|
|
378
733
|
maxIngestBytes: cfg.maxIngestBytes,
|
|
379
734
|
fallbackAgentId: hookAgentId,
|
|
735
|
+
debug: debugEnabled,
|
|
736
|
+
provisionForCreateNew: configuredApiKey || !configuredProvisionToken
|
|
737
|
+
? undefined
|
|
738
|
+
: () => provisionAPIKey(hookAgentId),
|
|
380
739
|
});
|
|
381
740
|
},
|
|
382
741
|
};
|
|
@@ -390,15 +749,24 @@ const toolNames = [
|
|
|
390
749
|
];
|
|
391
750
|
|
|
392
751
|
class LazyServerBackend implements MemoryBackend {
|
|
752
|
+
private apiUrl: string;
|
|
753
|
+
private apiKeyProvider: () => Promise<string>;
|
|
754
|
+
private agentId: string;
|
|
755
|
+
private timeouts: BackendTimeouts;
|
|
393
756
|
private resolved: ServerBackend | null = null;
|
|
394
757
|
private resolving: Promise<ServerBackend> | null = null;
|
|
395
758
|
|
|
396
759
|
constructor(
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
) {
|
|
760
|
+
apiUrl: string,
|
|
761
|
+
apiKeyProvider: () => Promise<string>,
|
|
762
|
+
agentId: string,
|
|
763
|
+
timeouts: BackendTimeouts,
|
|
764
|
+
) {
|
|
765
|
+
this.apiUrl = apiUrl;
|
|
766
|
+
this.apiKeyProvider = apiKeyProvider;
|
|
767
|
+
this.agentId = agentId;
|
|
768
|
+
this.timeouts = timeouts;
|
|
769
|
+
}
|
|
402
770
|
|
|
403
771
|
private async resolve(): Promise<ServerBackend> {
|
|
404
772
|
if (this.resolved) return this.resolved;
|
|
@@ -406,7 +774,9 @@ class LazyServerBackend implements MemoryBackend {
|
|
|
406
774
|
|
|
407
775
|
this.resolving = this.apiKeyProvider().then((apiKey) =>
|
|
408
776
|
Promise.resolve().then(() => {
|
|
409
|
-
this.resolved = new ServerBackend(this.apiUrl, apiKey, this.agentId,
|
|
777
|
+
this.resolved = new ServerBackend(this.apiUrl, apiKey, this.agentId, {
|
|
778
|
+
timeouts: this.timeouts,
|
|
779
|
+
});
|
|
410
780
|
return this.resolved;
|
|
411
781
|
})
|
|
412
782
|
).catch((err) => {
|
package/openclaw.plugin.json
CHANGED
|
@@ -14,6 +14,17 @@
|
|
|
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
|
+
},
|
|
21
|
+
"provisionQueryParams": {
|
|
22
|
+
"type": "object",
|
|
23
|
+
"description": "Optional utm_* params forwarded only by the first create-new provision request triggered after the initial restart",
|
|
24
|
+
"additionalProperties": {
|
|
25
|
+
"type": "string"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
17
28
|
"defaultTimeoutMs": {
|
|
18
29
|
"type": "number",
|
|
19
30
|
"minimum": 1,
|
|
@@ -24,6 +35,14 @@
|
|
|
24
35
|
"minimum": 1,
|
|
25
36
|
"description": "Timeout in milliseconds for memory search and automatic recall search (default 15000)"
|
|
26
37
|
},
|
|
38
|
+
"debug": {
|
|
39
|
+
"type": "boolean",
|
|
40
|
+
"description": "When true, emit mem9 debug logs. Current coverage includes before_prompt_build recall diagnostics; future mem9 debug categories reuse the same switch"
|
|
41
|
+
},
|
|
42
|
+
"debugRecall": {
|
|
43
|
+
"type": "boolean",
|
|
44
|
+
"description": "Deprecated alias for debug. When true, enables mem9 debug logs including before_prompt_build recall diagnostics"
|
|
45
|
+
},
|
|
27
46
|
"tenantID": {
|
|
28
47
|
"type": "string",
|
|
29
48
|
"description": "Deprecated: use apiKey"
|
|
@@ -40,6 +59,10 @@
|
|
|
40
59
|
"placeholder": "key...",
|
|
41
60
|
"sensitive": true
|
|
42
61
|
},
|
|
62
|
+
"provisionToken": {
|
|
63
|
+
"label": "Provision Token",
|
|
64
|
+
"placeholder": "create-new only"
|
|
65
|
+
},
|
|
43
66
|
"defaultTimeoutMs": {
|
|
44
67
|
"label": "Default Timeout (ms)",
|
|
45
68
|
"placeholder": "8000"
|
|
@@ -48,6 +71,12 @@
|
|
|
48
71
|
"label": "Search Timeout (ms)",
|
|
49
72
|
"placeholder": "15000"
|
|
50
73
|
},
|
|
74
|
+
"debug": {
|
|
75
|
+
"label": "Debug"
|
|
76
|
+
},
|
|
77
|
+
"debugRecall": {
|
|
78
|
+
"label": "Debug Recall (Deprecated)"
|
|
79
|
+
},
|
|
51
80
|
"tenantID": {
|
|
52
81
|
"label": "Tenant ID (legacy)",
|
|
53
82
|
"placeholder": "uuid...",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mem9/mem9",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
4
4
|
"description": "OpenClaw shared memory plugin — cloud-persistent memory with hybrid vector + keyword search via mnemo-server",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"access": "public"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
|
+
"test": "rm -rf ./dist-test && tsc -p ./tsconfig.test.json && node --test ./dist-test/*.test.js",
|
|
37
38
|
"typecheck": "tsc --noEmit",
|
|
38
39
|
"prepublishOnly": "npm run typecheck"
|
|
39
40
|
},
|
|
@@ -42,6 +43,7 @@
|
|
|
42
43
|
},
|
|
43
44
|
"dependencies": {},
|
|
44
45
|
"devDependencies": {
|
|
46
|
+
"@types/node": "^22.15.30",
|
|
45
47
|
"typescript": "^5.5.0"
|
|
46
48
|
},
|
|
47
49
|
"openclaw": {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { ServerBackend } from "./server-backend.js";
|
|
5
|
+
|
|
6
|
+
test("register forwards only utm_* params during create-new provision", async () => {
|
|
7
|
+
const originalFetch = globalThis.fetch;
|
|
8
|
+
let requestedURL = "";
|
|
9
|
+
|
|
10
|
+
globalThis.fetch = async (input, init) => {
|
|
11
|
+
requestedURL = String(input);
|
|
12
|
+
assert.equal(init?.method, "POST");
|
|
13
|
+
|
|
14
|
+
return new Response(JSON.stringify({ id: "space-1" }), {
|
|
15
|
+
status: 201,
|
|
16
|
+
headers: {
|
|
17
|
+
"Content-Type": "application/json",
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const backend = new ServerBackend("https://api.mem9.ai", "", "agent-1", {
|
|
24
|
+
provisionQueryParams: {
|
|
25
|
+
utm_source: "bosn",
|
|
26
|
+
foo: "bar",
|
|
27
|
+
utm_campaign: "spring",
|
|
28
|
+
utm_medium: "",
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const result = await backend.register();
|
|
33
|
+
assert.equal(result.id, "space-1");
|
|
34
|
+
|
|
35
|
+
const url = new URL(requestedURL);
|
|
36
|
+
assert.equal(url.origin + url.pathname, "https://api.mem9.ai/v1alpha1/mem9s");
|
|
37
|
+
assert.equal(url.searchParams.get("utm_source"), "bosn");
|
|
38
|
+
assert.equal(url.searchParams.get("utm_campaign"), "spring");
|
|
39
|
+
assert.equal(url.searchParams.has("foo"), false);
|
|
40
|
+
assert.equal(url.searchParams.has("utm_medium"), false);
|
|
41
|
+
} finally {
|
|
42
|
+
globalThis.fetch = originalFetch;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("normal memory requests do not append provision query params", async () => {
|
|
47
|
+
const originalFetch = globalThis.fetch;
|
|
48
|
+
let requestedURL = "";
|
|
49
|
+
|
|
50
|
+
globalThis.fetch = async (input) => {
|
|
51
|
+
requestedURL = String(input);
|
|
52
|
+
|
|
53
|
+
return new Response(
|
|
54
|
+
JSON.stringify({
|
|
55
|
+
id: "mem-1",
|
|
56
|
+
content: "remember this",
|
|
57
|
+
created_at: "2026-04-05T00:00:00Z",
|
|
58
|
+
updated_at: "2026-04-05T00:00:00Z",
|
|
59
|
+
}),
|
|
60
|
+
{
|
|
61
|
+
status: 200,
|
|
62
|
+
headers: {
|
|
63
|
+
"Content-Type": "application/json",
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const backend = new ServerBackend("https://api.mem9.ai", "space-key", "agent-1", {
|
|
71
|
+
provisionQueryParams: {
|
|
72
|
+
utm_source: "bosn",
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await backend.store({ content: "remember this" });
|
|
77
|
+
|
|
78
|
+
assert.equal(requestedURL, "https://api.mem9.ai/v1alpha2/mem9s/memories");
|
|
79
|
+
} finally {
|
|
80
|
+
globalThis.fetch = originalFetch;
|
|
81
|
+
}
|
|
82
|
+
});
|