@mem9/mem9 0.4.9 → 0.4.10
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/dist/backend.js +10 -0
- package/dist/hooks.js +316 -0
- package/dist/index.js +590 -0
- package/dist/server-backend.js +128 -0
- package/dist/types.js +1 -0
- package/package.json +10 -5
- package/backend.ts +0 -41
- package/hooks.ts +0 -431
- package/index.test.ts +0 -715
- package/index.ts +0 -809
- package/server-backend.test.ts +0 -82
- package/server-backend.ts +0 -194
- package/types.ts +0 -96
package/index.ts
DELETED
|
@@ -1,809 +0,0 @@
|
|
|
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";
|
|
7
|
-
import {
|
|
8
|
-
DEFAULT_SEARCH_TIMEOUT_MS,
|
|
9
|
-
DEFAULT_TIMEOUT_MS,
|
|
10
|
-
ServerBackend,
|
|
11
|
-
type BackendTimeouts,
|
|
12
|
-
} from "./server-backend.js";
|
|
13
|
-
import { registerHooks } from "./hooks.js";
|
|
14
|
-
import type {
|
|
15
|
-
PluginConfig,
|
|
16
|
-
Memory,
|
|
17
|
-
CreateMemoryInput,
|
|
18
|
-
UpdateMemoryInput,
|
|
19
|
-
SearchInput,
|
|
20
|
-
IngestInput,
|
|
21
|
-
IngestResult,
|
|
22
|
-
} from "./types.js";
|
|
23
|
-
|
|
24
|
-
const DEFAULT_API_URL = "https://api.mem9.ai";
|
|
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
|
-
};
|
|
48
|
-
|
|
49
|
-
function normalizeTimeoutMs(
|
|
50
|
-
value: unknown,
|
|
51
|
-
field: (typeof TIMEOUT_FIELDS)[number],
|
|
52
|
-
fallback: number,
|
|
53
|
-
logger: OpenClawPluginApi["logger"],
|
|
54
|
-
): number {
|
|
55
|
-
if (value == null) return fallback;
|
|
56
|
-
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
57
|
-
logger.info(`[mem9] invalid ${field}; using ${fallback}ms`);
|
|
58
|
-
return fallback;
|
|
59
|
-
}
|
|
60
|
-
return Math.floor(value);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function resolveTimeouts(
|
|
64
|
-
cfg: PluginConfig,
|
|
65
|
-
logger: OpenClawPluginApi["logger"],
|
|
66
|
-
): Required<BackendTimeouts> {
|
|
67
|
-
const timeouts = {
|
|
68
|
-
defaultTimeoutMs: normalizeTimeoutMs(
|
|
69
|
-
cfg.defaultTimeoutMs,
|
|
70
|
-
"defaultTimeoutMs",
|
|
71
|
-
DEFAULT_TIMEOUT_MS,
|
|
72
|
-
logger,
|
|
73
|
-
),
|
|
74
|
-
searchTimeoutMs: normalizeTimeoutMs(
|
|
75
|
-
cfg.searchTimeoutMs,
|
|
76
|
-
"searchTimeoutMs",
|
|
77
|
-
DEFAULT_SEARCH_TIMEOUT_MS,
|
|
78
|
-
logger,
|
|
79
|
-
),
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
if (TIMEOUT_FIELDS.some((field) => cfg[field] != null)) {
|
|
83
|
-
logger.info(
|
|
84
|
-
`[mem9] timeout config: defaultTimeoutMs=${timeouts.defaultTimeoutMs}, searchTimeoutMs=${timeouts.searchTimeoutMs}`,
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
return timeouts;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function jsonResult(data: unknown) {
|
|
92
|
-
// Older OpenClaw versions may assume tool results have a normalized
|
|
93
|
-
// assistant-content shape and can crash on plain objects that omit `content`.
|
|
94
|
-
// Returning a JSON string keeps results readable while remaining compatible
|
|
95
|
-
// with both old and new hosts.
|
|
96
|
-
// https://github.com/openclaw/openclaw/blob/936607ca221a2f0c37ad976ddefcd39596f54793/CHANGELOG.md?plain=1#L1144
|
|
97
|
-
if (typeof data === "string") return data;
|
|
98
|
-
try {
|
|
99
|
-
return JSON.stringify(data, null, 2);
|
|
100
|
-
} catch {
|
|
101
|
-
return String(data);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
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
|
-
|
|
324
|
-
interface MemoryCapability {
|
|
325
|
-
search: (query: string, opts?: { limit?: number }) => Promise<{ data: Memory[]; total: number }>;
|
|
326
|
-
store: (content: string, opts?: { tags?: string[]; source?: string }) => Promise<unknown>;
|
|
327
|
-
get: (id: string) => Promise<Memory | null>;
|
|
328
|
-
remove: (id: string) => Promise<boolean>;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
interface OpenClawPluginApi {
|
|
332
|
-
pluginConfig?: unknown;
|
|
333
|
-
logger: {
|
|
334
|
-
info: (...args: unknown[]) => void;
|
|
335
|
-
error: (...args: unknown[]) => void;
|
|
336
|
-
};
|
|
337
|
-
registerTool: (
|
|
338
|
-
factory: ToolFactory | (() => AnyAgentTool[]),
|
|
339
|
-
opts: { names: string[] }
|
|
340
|
-
) => void;
|
|
341
|
-
registerCapability?: (slot: string, capability: MemoryCapability) => void;
|
|
342
|
-
on: (hookName: string, handler: (...args: unknown[]) => unknown, opts?: { priority?: number }) => void;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
interface ToolContext {
|
|
346
|
-
workspaceDir?: string;
|
|
347
|
-
agentId?: string;
|
|
348
|
-
sessionKey?: string;
|
|
349
|
-
messageChannel?: string;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
type ToolFactory = (ctx: ToolContext) => AnyAgentTool | AnyAgentTool[] | null | undefined;
|
|
353
|
-
|
|
354
|
-
interface AnyAgentTool {
|
|
355
|
-
name: string;
|
|
356
|
-
label: string;
|
|
357
|
-
description: string;
|
|
358
|
-
parameters: {
|
|
359
|
-
type: "object";
|
|
360
|
-
properties: Record<string, unknown>;
|
|
361
|
-
required: string[];
|
|
362
|
-
};
|
|
363
|
-
execute: (_id: string, params: unknown) => Promise<unknown>;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
function buildTools(
|
|
367
|
-
backend: MemoryBackend,
|
|
368
|
-
): AnyAgentTool[] {
|
|
369
|
-
return [
|
|
370
|
-
{
|
|
371
|
-
name: "memory_store",
|
|
372
|
-
label: "Store Memory",
|
|
373
|
-
description:
|
|
374
|
-
"Store a memory. Returns the stored memory with its assigned id.",
|
|
375
|
-
parameters: {
|
|
376
|
-
type: "object",
|
|
377
|
-
properties: {
|
|
378
|
-
content: {
|
|
379
|
-
type: "string",
|
|
380
|
-
description: "Memory content (required, max 50000 chars)",
|
|
381
|
-
},
|
|
382
|
-
source: {
|
|
383
|
-
type: "string",
|
|
384
|
-
description: "Which agent wrote this memory",
|
|
385
|
-
},
|
|
386
|
-
tags: {
|
|
387
|
-
type: "array",
|
|
388
|
-
items: { type: "string" },
|
|
389
|
-
description: "Filterable tags (max 20)",
|
|
390
|
-
},
|
|
391
|
-
metadata: {
|
|
392
|
-
type: "object",
|
|
393
|
-
description: "Arbitrary structured data",
|
|
394
|
-
},
|
|
395
|
-
},
|
|
396
|
-
required: ["content"],
|
|
397
|
-
},
|
|
398
|
-
async execute(_id: string, params: unknown) {
|
|
399
|
-
try {
|
|
400
|
-
const input = params as CreateMemoryInput;
|
|
401
|
-
const result = await backend.store(input);
|
|
402
|
-
return jsonResult({ ok: true, data: result });
|
|
403
|
-
} catch (err) {
|
|
404
|
-
return jsonResult({
|
|
405
|
-
ok: false,
|
|
406
|
-
error: err instanceof Error ? err.message : String(err),
|
|
407
|
-
});
|
|
408
|
-
}
|
|
409
|
-
},
|
|
410
|
-
},
|
|
411
|
-
|
|
412
|
-
{
|
|
413
|
-
name: "memory_search",
|
|
414
|
-
label: "Search Memories",
|
|
415
|
-
description:
|
|
416
|
-
"Search memories using hybrid vector + keyword search. Higher score = more relevant.",
|
|
417
|
-
parameters: {
|
|
418
|
-
type: "object",
|
|
419
|
-
properties: {
|
|
420
|
-
q: { type: "string", description: "Search query" },
|
|
421
|
-
tags: {
|
|
422
|
-
type: "string",
|
|
423
|
-
description: "Comma-separated tags to filter by (AND)",
|
|
424
|
-
},
|
|
425
|
-
source: { type: "string", description: "Filter by source agent" },
|
|
426
|
-
limit: {
|
|
427
|
-
type: "number",
|
|
428
|
-
description: "Max results (default 20, max 200)",
|
|
429
|
-
},
|
|
430
|
-
offset: { type: "number", description: "Pagination offset" },
|
|
431
|
-
memory_type: {
|
|
432
|
-
type: "string",
|
|
433
|
-
description: "Comma-separated memory types to filter by (e.g. insight,pinned)",
|
|
434
|
-
},
|
|
435
|
-
},
|
|
436
|
-
required: [],
|
|
437
|
-
},
|
|
438
|
-
async execute(_id: string, params: unknown) {
|
|
439
|
-
try {
|
|
440
|
-
const input = (params ?? {}) as SearchInput;
|
|
441
|
-
const result = await backend.search(input);
|
|
442
|
-
return jsonResult({ ok: true, ...result });
|
|
443
|
-
} catch (err) {
|
|
444
|
-
return jsonResult({
|
|
445
|
-
ok: false,
|
|
446
|
-
error: err instanceof Error ? err.message : String(err),
|
|
447
|
-
});
|
|
448
|
-
}
|
|
449
|
-
},
|
|
450
|
-
},
|
|
451
|
-
|
|
452
|
-
{
|
|
453
|
-
name: "memory_get",
|
|
454
|
-
label: "Get Memory",
|
|
455
|
-
description: "Retrieve a single memory by its id.",
|
|
456
|
-
parameters: {
|
|
457
|
-
type: "object",
|
|
458
|
-
properties: {
|
|
459
|
-
id: { type: "string", description: "Memory id (UUID)" },
|
|
460
|
-
},
|
|
461
|
-
required: ["id"],
|
|
462
|
-
},
|
|
463
|
-
async execute(_id: string, params: unknown) {
|
|
464
|
-
try {
|
|
465
|
-
const { id } = params as { id: string };
|
|
466
|
-
const result = await backend.get(id);
|
|
467
|
-
if (!result)
|
|
468
|
-
return jsonResult({ ok: false, error: "memory not found" });
|
|
469
|
-
return jsonResult({ ok: true, data: result });
|
|
470
|
-
} catch (err) {
|
|
471
|
-
return jsonResult({
|
|
472
|
-
ok: false,
|
|
473
|
-
error: err instanceof Error ? err.message : String(err),
|
|
474
|
-
});
|
|
475
|
-
}
|
|
476
|
-
},
|
|
477
|
-
},
|
|
478
|
-
|
|
479
|
-
{
|
|
480
|
-
name: "memory_update",
|
|
481
|
-
label: "Update Memory",
|
|
482
|
-
description:
|
|
483
|
-
"Update an existing memory. Only provided fields are changed.",
|
|
484
|
-
parameters: {
|
|
485
|
-
type: "object",
|
|
486
|
-
properties: {
|
|
487
|
-
id: { type: "string", description: "Memory id to update" },
|
|
488
|
-
content: { type: "string", description: "New content" },
|
|
489
|
-
source: { type: "string", description: "New source" },
|
|
490
|
-
tags: {
|
|
491
|
-
type: "array",
|
|
492
|
-
items: { type: "string" },
|
|
493
|
-
description: "Replacement tags",
|
|
494
|
-
},
|
|
495
|
-
metadata: { type: "object", description: "Replacement metadata" },
|
|
496
|
-
},
|
|
497
|
-
required: ["id"],
|
|
498
|
-
},
|
|
499
|
-
async execute(_id: string, params: unknown) {
|
|
500
|
-
try {
|
|
501
|
-
const { id, ...input } = params as { id: string } & UpdateMemoryInput;
|
|
502
|
-
const result = await backend.update(id, input);
|
|
503
|
-
if (!result)
|
|
504
|
-
return jsonResult({ ok: false, error: "memory not found" });
|
|
505
|
-
return jsonResult({ ok: true, data: result });
|
|
506
|
-
} catch (err) {
|
|
507
|
-
return jsonResult({
|
|
508
|
-
ok: false,
|
|
509
|
-
error: err instanceof Error ? err.message : String(err),
|
|
510
|
-
});
|
|
511
|
-
}
|
|
512
|
-
},
|
|
513
|
-
},
|
|
514
|
-
|
|
515
|
-
{
|
|
516
|
-
name: "memory_delete",
|
|
517
|
-
label: "Delete Memory",
|
|
518
|
-
description: "Delete a memory by id.",
|
|
519
|
-
parameters: {
|
|
520
|
-
type: "object",
|
|
521
|
-
properties: {
|
|
522
|
-
id: { type: "string", description: "Memory id to delete" },
|
|
523
|
-
},
|
|
524
|
-
required: ["id"],
|
|
525
|
-
},
|
|
526
|
-
async execute(_id: string, params: unknown) {
|
|
527
|
-
try {
|
|
528
|
-
const { id } = params as { id: string };
|
|
529
|
-
const deleted = await backend.remove(id);
|
|
530
|
-
if (!deleted)
|
|
531
|
-
return jsonResult({ ok: false, error: "memory not found" });
|
|
532
|
-
return jsonResult({ ok: true });
|
|
533
|
-
} catch (err) {
|
|
534
|
-
return jsonResult({
|
|
535
|
-
ok: false,
|
|
536
|
-
error: err instanceof Error ? err.message : String(err),
|
|
537
|
-
});
|
|
538
|
-
}
|
|
539
|
-
},
|
|
540
|
-
},
|
|
541
|
-
];
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
const mnemoPlugin = {
|
|
545
|
-
id: "mem9",
|
|
546
|
-
name: "Mnemo Memory",
|
|
547
|
-
description:
|
|
548
|
-
"AI agent memory — server mode (mnemo-server) with hybrid vector + keyword search.",
|
|
549
|
-
|
|
550
|
-
register(api: OpenClawPluginApi) {
|
|
551
|
-
const cfg = (api.pluginConfig ?? {}) as PluginConfig;
|
|
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 ?? {};
|
|
558
|
-
const timeoutConfig = resolveTimeouts(cfg, api.logger);
|
|
559
|
-
const hookAgentId = cfg.agentName ?? "agent";
|
|
560
|
-
const debugEnabled = cfg.debug === true || cfg.debugRecall === true;
|
|
561
|
-
if (!cfg.apiUrl) {
|
|
562
|
-
api.logger.info(`[mem9] apiUrl not configured, using default ${DEFAULT_API_URL}`);
|
|
563
|
-
}
|
|
564
|
-
if (cfg.debugRecall === true && cfg.debug !== true) {
|
|
565
|
-
api.logger.info("[mem9] debugRecall is deprecated; use debug instead");
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
const configuredApiKey = cfg.apiKey ?? cfg.tenantID;
|
|
569
|
-
const provisionWaitTimeoutMs = Math.max(timeoutConfig.defaultTimeoutMs + 5_000, 30_000);
|
|
570
|
-
if (cfg.apiKey && cfg.tenantID) {
|
|
571
|
-
api.logger.info("[mem9] both apiKey and tenantID set; using apiKey");
|
|
572
|
-
} else if (cfg.tenantID) {
|
|
573
|
-
api.logger.info("[mem9] tenantID is deprecated; treating it as apiKey for v1alpha2");
|
|
574
|
-
}
|
|
575
|
-
const registerTenant = async (agentName: string): Promise<string> => {
|
|
576
|
-
const backend = new ServerBackend(
|
|
577
|
-
effectiveApiUrl,
|
|
578
|
-
"",
|
|
579
|
-
agentName,
|
|
580
|
-
{
|
|
581
|
-
timeouts: timeoutConfig,
|
|
582
|
-
provisionQueryParams,
|
|
583
|
-
},
|
|
584
|
-
);
|
|
585
|
-
const result = await backend.register();
|
|
586
|
-
api.logger.info(
|
|
587
|
-
`[mem9] *** Auto-provisioned apiKey=${result.id} *** Save this for recovery or reconnect as apiKey`
|
|
588
|
-
);
|
|
589
|
-
return result.id;
|
|
590
|
-
};
|
|
591
|
-
let runtimeProvisionedAPIKey: string | null = null;
|
|
592
|
-
let loggedPersistedProvisionedAPIKeyReuse = false;
|
|
593
|
-
let registrationPromise: Promise<string> | null = null;
|
|
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
|
-
}
|
|
610
|
-
if (!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
|
-
});
|
|
627
|
-
}
|
|
628
|
-
return registrationPromise;
|
|
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
|
-
};
|
|
656
|
-
|
|
657
|
-
api.logger.info("[mem9] Server mode (v1alpha2)");
|
|
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
|
-
}
|
|
667
|
-
|
|
668
|
-
const factory: ToolFactory = (ctx: ToolContext) => {
|
|
669
|
-
const agentId = ctx.agentId ?? cfg.agentName ?? "agent";
|
|
670
|
-
const backend = new LazyServerBackend(
|
|
671
|
-
effectiveApiUrl,
|
|
672
|
-
resolveAPIKey,
|
|
673
|
-
agentId,
|
|
674
|
-
timeoutConfig,
|
|
675
|
-
);
|
|
676
|
-
return buildTools(backend);
|
|
677
|
-
};
|
|
678
|
-
|
|
679
|
-
api.registerTool(factory, { names: toolNames });
|
|
680
|
-
|
|
681
|
-
// Shared lazy backend for hooks and capability registration.
|
|
682
|
-
const hookBackend = new LazyServerBackend(
|
|
683
|
-
effectiveApiUrl,
|
|
684
|
-
resolveAPIKey,
|
|
685
|
-
hookAgentId,
|
|
686
|
-
timeoutConfig,
|
|
687
|
-
);
|
|
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
|
-
|
|
703
|
-
// Register memory capability so OpenClaw 2026.4.2+ binds this plugin to
|
|
704
|
-
// the memory slot. Without this, the plugin is treated as a legacy
|
|
705
|
-
// hook-only plugin and automatic context injection won't work.
|
|
706
|
-
// Guard with typeof check for backward compatibility with older hosts.
|
|
707
|
-
if (typeof api.registerCapability === "function") {
|
|
708
|
-
api.registerCapability("memory", {
|
|
709
|
-
search: async (query, opts) => {
|
|
710
|
-
const result = await withPendingProvisionFallback(
|
|
711
|
-
() => hookBackend.search({ q: query, limit: opts?.limit }),
|
|
712
|
-
{ data: [], total: 0, limit: opts?.limit ?? 20, offset: 0 },
|
|
713
|
-
);
|
|
714
|
-
return { data: result.data, total: result.total };
|
|
715
|
-
},
|
|
716
|
-
store: async (content, opts) => {
|
|
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
|
-
}
|
|
725
|
-
},
|
|
726
|
-
get: (id) => withPendingProvisionFallback(() => hookBackend.get(id), null),
|
|
727
|
-
remove: (id) => withPendingProvisionFallback(() => hookBackend.remove(id), false),
|
|
728
|
-
});
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
// Register hooks with lazy backend for lifecycle memory management.
|
|
732
|
-
registerHooks(api, hookBackend, api.logger, {
|
|
733
|
-
maxIngestBytes: cfg.maxIngestBytes,
|
|
734
|
-
fallbackAgentId: hookAgentId,
|
|
735
|
-
debug: debugEnabled,
|
|
736
|
-
provisionForCreateNew: configuredApiKey || !configuredProvisionToken
|
|
737
|
-
? undefined
|
|
738
|
-
: () => provisionAPIKey(hookAgentId),
|
|
739
|
-
});
|
|
740
|
-
},
|
|
741
|
-
};
|
|
742
|
-
|
|
743
|
-
const toolNames = [
|
|
744
|
-
"memory_store",
|
|
745
|
-
"memory_search",
|
|
746
|
-
"memory_get",
|
|
747
|
-
"memory_update",
|
|
748
|
-
"memory_delete",
|
|
749
|
-
];
|
|
750
|
-
|
|
751
|
-
class LazyServerBackend implements MemoryBackend {
|
|
752
|
-
private apiUrl: string;
|
|
753
|
-
private apiKeyProvider: () => Promise<string>;
|
|
754
|
-
private agentId: string;
|
|
755
|
-
private timeouts: BackendTimeouts;
|
|
756
|
-
private resolved: ServerBackend | null = null;
|
|
757
|
-
private resolving: Promise<ServerBackend> | null = null;
|
|
758
|
-
|
|
759
|
-
constructor(
|
|
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
|
-
}
|
|
770
|
-
|
|
771
|
-
private async resolve(): Promise<ServerBackend> {
|
|
772
|
-
if (this.resolved) return this.resolved;
|
|
773
|
-
if (this.resolving) return this.resolving;
|
|
774
|
-
|
|
775
|
-
this.resolving = this.apiKeyProvider().then((apiKey) =>
|
|
776
|
-
Promise.resolve().then(() => {
|
|
777
|
-
this.resolved = new ServerBackend(this.apiUrl, apiKey, this.agentId, {
|
|
778
|
-
timeouts: this.timeouts,
|
|
779
|
-
});
|
|
780
|
-
return this.resolved;
|
|
781
|
-
})
|
|
782
|
-
).catch((err) => {
|
|
783
|
-
this.resolving = null; // allow retry on next call
|
|
784
|
-
throw err;
|
|
785
|
-
});
|
|
786
|
-
|
|
787
|
-
return this.resolving;
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
async store(input: CreateMemoryInput) {
|
|
791
|
-
return (await this.resolve()).store(input);
|
|
792
|
-
}
|
|
793
|
-
async search(input: SearchInput) {
|
|
794
|
-
return (await this.resolve()).search(input);
|
|
795
|
-
}
|
|
796
|
-
async get(id: string) {
|
|
797
|
-
return (await this.resolve()).get(id);
|
|
798
|
-
}
|
|
799
|
-
async update(id: string, input: UpdateMemoryInput) {
|
|
800
|
-
return (await this.resolve()).update(id, input);
|
|
801
|
-
}
|
|
802
|
-
async remove(id: string) {
|
|
803
|
-
return (await this.resolve()).remove(id);
|
|
804
|
-
}
|
|
805
|
-
async ingest(input: IngestInput): Promise<IngestResult> {
|
|
806
|
-
return (await this.resolve()).ingest(input);
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
export default mnemoPlugin;
|