@mem9/mem9 0.4.6 → 0.4.7
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 +17 -22
- package/backend.ts +0 -14
- package/hooks.ts +3 -19
- package/index.ts +21 -386
- package/openclaw.plugin.json +0 -15
- package/package.json +1 -3
- package/server-backend.ts +4 -21
- package/types.ts +0 -2
- package/index.test.ts +0 -393
- package/server-backend.test.ts +0 -82
package/README.md
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
# OpenClaw Plugin for
|
|
1
|
+
# OpenClaw Plugin for mnemos
|
|
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`.
|
|
4
|
-
|
|
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`.
|
|
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`.
|
|
6
4
|
|
|
7
5
|
## 🚀 Quick Start (Server Mode)
|
|
8
6
|
|
|
@@ -22,14 +20,14 @@ curl -s -X POST http://localhost:8080/v1alpha1/mem9s \
|
|
|
22
20
|
# {"id": "uuid"}
|
|
23
21
|
```
|
|
24
22
|
|
|
25
|
-
Add
|
|
23
|
+
Add mnemo to your project's `openclaw.json`:
|
|
26
24
|
|
|
27
25
|
```json
|
|
28
26
|
{
|
|
29
27
|
"plugins": {
|
|
30
|
-
"slots": { "memory": "
|
|
28
|
+
"slots": { "memory": "openclaw" },
|
|
31
29
|
"entries": {
|
|
32
|
-
"
|
|
30
|
+
"openclaw": {
|
|
33
31
|
"enabled": true,
|
|
34
32
|
"config": {
|
|
35
33
|
"apiUrl": "http://localhost:8080",
|
|
@@ -66,8 +64,8 @@ This is a `kind: "memory"` plugin — OpenClaw's framework manages when to load/
|
|
|
66
64
|
|
|
67
65
|
| Hook | Trigger | What it does |
|
|
68
66
|
|---|---|---|
|
|
69
|
-
| `before_prompt_build` | Every LLM call | Searches memories by current prompt
|
|
70
|
-
| `after_compaction` | After `/compact` |
|
|
67
|
+
| `before_prompt_build` | Every LLM call | Searches memories by current prompt, injects relevant ones as context (3-min TTL cache) |
|
|
68
|
+
| `after_compaction` | After `/compact` | Invalidates cache so the next prompt gets fresh memories from the database |
|
|
71
69
|
| `before_reset` | Before `/reset` | Saves a session summary (last 3 user messages) as memory before context is wiped |
|
|
72
70
|
| `agent_end` | Agent finishes | Auto-captures the last assistant response as memory (if substantial) |
|
|
73
71
|
|
|
@@ -75,7 +73,7 @@ This is a `kind: "memory"` plugin — OpenClaw's framework manages when to load/
|
|
|
75
73
|
|
|
76
74
|
| Tool | Description |
|
|
77
75
|
|---|---|
|
|
78
|
-
| `memory_store` | Store a new memory |
|
|
76
|
+
| `memory_store` | Store a new memory (upsert by key) |
|
|
79
77
|
| `memory_search` | Hybrid vector + keyword search (or keyword-only) |
|
|
80
78
|
| `memory_get` | Retrieve a single memory by ID |
|
|
81
79
|
| `memory_update` | Update an existing memory |
|
|
@@ -93,7 +91,7 @@ This is a `kind: "memory"` plugin — OpenClaw's framework manages when to load/
|
|
|
93
91
|
### Method A: npm install (Recommended)
|
|
94
92
|
|
|
95
93
|
```bash
|
|
96
|
-
openclaw plugins install @mem9/
|
|
94
|
+
openclaw plugins install @mem9/openclaw
|
|
97
95
|
```
|
|
98
96
|
|
|
99
97
|
### Method B: From source
|
|
@@ -106,7 +104,7 @@ npm install
|
|
|
106
104
|
|
|
107
105
|
### Configure OpenClaw
|
|
108
106
|
|
|
109
|
-
Add
|
|
107
|
+
Add mnemo to your project's `openclaw.json`:
|
|
110
108
|
|
|
111
109
|
OpenClaw is often deployed across teams with multiple agents. Server mode gives you:
|
|
112
110
|
|
|
@@ -141,10 +139,10 @@ Each agent uses the same `apiKey` for the shared memory pool. The plugin sends t
|
|
|
141
139
|
{
|
|
142
140
|
"plugins": {
|
|
143
141
|
"slots": {
|
|
144
|
-
"memory": "
|
|
142
|
+
"memory": "openclaw"
|
|
145
143
|
},
|
|
146
144
|
"entries": {
|
|
147
|
-
"
|
|
145
|
+
"openclaw": {
|
|
148
146
|
"enabled": true,
|
|
149
147
|
"config": {
|
|
150
148
|
"apiUrl": "http://your-server:8080",
|
|
@@ -162,8 +160,8 @@ That's it. The server handles scoping and conflict resolution. Conceptually, the
|
|
|
162
160
|
|
|
163
161
|
Start OpenClaw. You should see:
|
|
164
162
|
|
|
165
|
-
```
|
|
166
|
-
[mem9] Server mode
|
|
163
|
+
```
|
|
164
|
+
[mem9] Server mode
|
|
167
165
|
```
|
|
168
166
|
|
|
169
167
|
If you see `[mem9] No mode configured...`, check your `openclaw.json` config.
|
|
@@ -176,13 +174,11 @@ Defined in `openclaw.plugin.json`:
|
|
|
176
174
|
|---|---|---|
|
|
177
175
|
| `apiUrl` | string | mnemo-server URL |
|
|
178
176
|
| `apiKey` | string | Preferred key. Uses `/v1alpha2/mem9s/...` with `X-API-Key` header |
|
|
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 |
|
|
181
177
|
| `defaultTimeoutMs` | number | Default timeout for non-search mem9 API requests in milliseconds. Default: `8000` |
|
|
182
178
|
| `searchTimeoutMs` | number | Timeout for `memory_search` and automatic recall search in milliseconds. Default: `15000` |
|
|
183
179
|
| `tenantID` | string | Legacy alias for `apiKey`. The plugin still uses `/v1alpha2/mem9s/...` with `X-API-Key`. |
|
|
184
180
|
|
|
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.
|
|
181
|
+
> **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.
|
|
186
182
|
|
|
187
183
|
## Timeout Behavior
|
|
188
184
|
|
|
@@ -217,7 +213,7 @@ Example:
|
|
|
217
213
|
openclaw-plugin/
|
|
218
214
|
├── README.md # This file
|
|
219
215
|
├── openclaw.plugin.json # Plugin metadata + config schema
|
|
220
|
-
├── package.json # npm package (@mem9/
|
|
216
|
+
├── package.json # npm package (@mem9/openclaw)
|
|
221
217
|
├── index.ts # Plugin entry point + tool registration
|
|
222
218
|
├── backend.ts # MemoryBackend interface
|
|
223
219
|
├── server-backend.ts # Server mode: fetch → mnemo API
|
|
@@ -231,6 +227,5 @@ openclaw-plugin/
|
|
|
231
227
|
|---|---|---|
|
|
232
228
|
| `No mode configured` | Missing config | Add `apiUrl` and `apiKey` (or legacy `tenantID`) to plugin config |
|
|
233
229
|
| `Server mode requires...` | Missing key | Add `apiKey` (or legacy `tenantID`) to config |
|
|
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 |
|
|
235
230
|
| Search requests time out | Hybrid/vector search exceeds plugin timeout | Increase `searchTimeoutMs` in plugin config |
|
|
236
|
-
| Plugin not loading | Not in memory slot | Set `"slots": {"memory": "
|
|
231
|
+
| Plugin not loading | Not in memory slot | Set `"slots": {"memory": "openclaw"}` in openclaw.json |
|
package/backend.ts
CHANGED
|
@@ -9,20 +9,6 @@ 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
|
-
|
|
26
12
|
/**
|
|
27
13
|
* MemoryBackend — the abstraction that tools and hooks call through.
|
|
28
14
|
*/
|
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 {
|
|
14
|
+
import type { MemoryBackend } from "./backend.js";
|
|
15
15
|
import type { Memory, IngestMessage } from "./types.js";
|
|
16
16
|
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
@@ -169,11 +169,7 @@ export function registerHooks(
|
|
|
169
169
|
api: HookApi,
|
|
170
170
|
backend: MemoryBackend,
|
|
171
171
|
logger: Logger,
|
|
172
|
-
options?: {
|
|
173
|
-
maxIngestBytes?: number;
|
|
174
|
-
fallbackAgentId?: string;
|
|
175
|
-
provisionForCreateNew?: () => Promise<string>;
|
|
176
|
-
},
|
|
172
|
+
options?: { maxIngestBytes?: number; fallbackAgentId?: string },
|
|
177
173
|
): void {
|
|
178
174
|
const maxIngestBytes = options?.maxIngestBytes ?? DEFAULT_MAX_INGEST_BYTES;
|
|
179
175
|
|
|
@@ -186,9 +182,6 @@ export function registerHooks(
|
|
|
186
182
|
try {
|
|
187
183
|
const evt = event as { prompt?: string };
|
|
188
184
|
const prompt = evt?.prompt;
|
|
189
|
-
if (options?.provisionForCreateNew) {
|
|
190
|
-
await options.provisionForCreateNew();
|
|
191
|
-
}
|
|
192
185
|
if (!prompt || prompt.length < MIN_PROMPT_LEN) return;
|
|
193
186
|
|
|
194
187
|
const result = await backend.search({ q: prompt, limit: MAX_INJECT });
|
|
@@ -202,9 +195,6 @@ export function registerHooks(
|
|
|
202
195
|
prependContext: formatMemoriesBlock(memories),
|
|
203
196
|
};
|
|
204
197
|
} catch (err) {
|
|
205
|
-
if (isPendingProvisionError(err)) {
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
198
|
// Graceful degradation — never block the LLM call
|
|
209
199
|
logger.error(`[mem9] before_prompt_build failed: ${String(err)}`);
|
|
210
200
|
}
|
|
@@ -255,9 +245,6 @@ export function registerHooks(
|
|
|
255
245
|
|
|
256
246
|
logger.info("[mem9] Session context saved before reset");
|
|
257
247
|
} catch (err) {
|
|
258
|
-
if (isPendingProvisionError(err)) {
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
248
|
// Best-effort — never block /reset
|
|
262
249
|
logger.error(`[mem9] before_reset save failed: ${String(err)}`);
|
|
263
250
|
}
|
|
@@ -359,10 +346,7 @@ export function registerHooks(
|
|
|
359
346
|
`[mem9] Ingested session: memories_changed=${result.memories_changed}, status=${result.status}`
|
|
360
347
|
);
|
|
361
348
|
}
|
|
362
|
-
} catch
|
|
363
|
-
if (isPendingProvisionError(err)) {
|
|
364
|
-
return;
|
|
365
|
-
}
|
|
349
|
+
} catch {
|
|
366
350
|
// Best-effort — never fail the agent end phase
|
|
367
351
|
}
|
|
368
352
|
});
|
package/index.ts
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
import {
|
|
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";
|
|
1
|
+
import type { MemoryBackend } from "./backend.js";
|
|
7
2
|
import {
|
|
8
3
|
DEFAULT_SEARCH_TIMEOUT_MS,
|
|
9
4
|
DEFAULT_TIMEOUT_MS,
|
|
@@ -23,28 +18,6 @@ import type {
|
|
|
23
18
|
|
|
24
19
|
const DEFAULT_API_URL = "https://api.mem9.ai";
|
|
25
20
|
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
21
|
|
|
49
22
|
function normalizeTimeoutMs(
|
|
50
23
|
value: unknown,
|
|
@@ -102,225 +75,6 @@ function jsonResult(data: unknown) {
|
|
|
102
75
|
}
|
|
103
76
|
}
|
|
104
77
|
|
|
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
78
|
interface MemoryCapability {
|
|
325
79
|
search: (query: string, opts?: { limit?: number }) => Promise<{ data: Memory[]; total: number }>;
|
|
326
80
|
store: (content: string, opts?: { tags?: string[]; source?: string }) => Promise<unknown>;
|
|
@@ -363,9 +117,7 @@ interface AnyAgentTool {
|
|
|
363
117
|
execute: (_id: string, params: unknown) => Promise<unknown>;
|
|
364
118
|
}
|
|
365
119
|
|
|
366
|
-
function buildTools(
|
|
367
|
-
backend: MemoryBackend,
|
|
368
|
-
): AnyAgentTool[] {
|
|
120
|
+
function buildTools(backend: MemoryBackend): AnyAgentTool[] {
|
|
369
121
|
return [
|
|
370
122
|
{
|
|
371
123
|
name: "memory_store",
|
|
@@ -550,122 +302,43 @@ const mnemoPlugin = {
|
|
|
550
302
|
register(api: OpenClawPluginApi) {
|
|
551
303
|
const cfg = (api.pluginConfig ?? {}) as PluginConfig;
|
|
552
304
|
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
305
|
const timeoutConfig = resolveTimeouts(cfg, api.logger);
|
|
559
|
-
const hookAgentId = cfg.agentName ?? "agent";
|
|
560
306
|
if (!cfg.apiUrl) {
|
|
561
307
|
api.logger.info(`[mem9] apiUrl not configured, using default ${DEFAULT_API_URL}`);
|
|
562
308
|
}
|
|
563
309
|
|
|
564
310
|
const configuredApiKey = cfg.apiKey ?? cfg.tenantID;
|
|
565
|
-
const provisionWaitTimeoutMs = Math.max(timeoutConfig.defaultTimeoutMs + 5_000, 30_000);
|
|
566
311
|
if (cfg.apiKey && cfg.tenantID) {
|
|
567
312
|
api.logger.info("[mem9] both apiKey and tenantID set; using apiKey");
|
|
568
313
|
} else if (cfg.tenantID) {
|
|
569
314
|
api.logger.info("[mem9] tenantID is deprecated; treating it as apiKey for v1alpha2");
|
|
570
315
|
}
|
|
571
316
|
const registerTenant = async (agentName: string): Promise<string> => {
|
|
572
|
-
const backend = new ServerBackend(
|
|
573
|
-
effectiveApiUrl,
|
|
574
|
-
"",
|
|
575
|
-
agentName,
|
|
576
|
-
{
|
|
577
|
-
timeouts: timeoutConfig,
|
|
578
|
-
provisionQueryParams,
|
|
579
|
-
},
|
|
580
|
-
);
|
|
317
|
+
const backend = new ServerBackend(effectiveApiUrl, "", agentName, timeoutConfig);
|
|
581
318
|
const result = await backend.register();
|
|
582
319
|
api.logger.info(
|
|
583
|
-
`[mem9] *** Auto-provisioned apiKey=${result.id} *** Save this
|
|
320
|
+
`[mem9] *** Auto-provisioned apiKey=${result.id} *** Save this to your config as apiKey`
|
|
584
321
|
);
|
|
585
322
|
return result.id;
|
|
586
323
|
};
|
|
587
|
-
let runtimeProvisionedAPIKey: string | null = null;
|
|
588
|
-
let loggedPersistedProvisionedAPIKeyReuse = false;
|
|
589
324
|
let registrationPromise: Promise<string> | null = null;
|
|
590
|
-
const
|
|
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
|
-
}
|
|
325
|
+
const resolveAPIKey = (agentName: string): Promise<string> => {
|
|
326
|
+
if (configuredApiKey) return Promise.resolve(configuredApiKey);
|
|
606
327
|
if (!registrationPromise) {
|
|
607
|
-
registrationPromise =
|
|
608
|
-
effectiveApiUrl,
|
|
609
|
-
provisionQueryParams,
|
|
610
|
-
configuredProvisionToken,
|
|
611
|
-
timeoutConfig,
|
|
612
|
-
api.logger,
|
|
613
|
-
() => registerTenant(agentName),
|
|
614
|
-
)
|
|
615
|
-
.then((apiKey) => {
|
|
616
|
-
runtimeProvisionedAPIKey = apiKey;
|
|
617
|
-
return apiKey;
|
|
618
|
-
})
|
|
619
|
-
.catch((err) => {
|
|
620
|
-
registrationPromise = null;
|
|
621
|
-
throw err;
|
|
622
|
-
});
|
|
328
|
+
registrationPromise = registerTenant(agentName);
|
|
623
329
|
}
|
|
624
330
|
return registrationPromise;
|
|
625
331
|
};
|
|
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
|
-
};
|
|
652
332
|
|
|
653
333
|
api.logger.info("[mem9] Server mode (v1alpha2)");
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
}
|
|
662
|
-
}
|
|
334
|
+
|
|
335
|
+
const hookAgentId = cfg.agentName ?? "agent";
|
|
663
336
|
|
|
664
337
|
const factory: ToolFactory = (ctx: ToolContext) => {
|
|
665
338
|
const agentId = ctx.agentId ?? cfg.agentName ?? "agent";
|
|
666
339
|
const backend = new LazyServerBackend(
|
|
667
340
|
effectiveApiUrl,
|
|
668
|
-
resolveAPIKey,
|
|
341
|
+
() => resolveAPIKey(agentId),
|
|
669
342
|
agentId,
|
|
670
343
|
timeoutConfig,
|
|
671
344
|
);
|
|
@@ -677,25 +350,11 @@ const mnemoPlugin = {
|
|
|
677
350
|
// Shared lazy backend for hooks and capability registration.
|
|
678
351
|
const hookBackend = new LazyServerBackend(
|
|
679
352
|
effectiveApiUrl,
|
|
680
|
-
resolveAPIKey,
|
|
353
|
+
() => resolveAPIKey(hookAgentId),
|
|
681
354
|
hookAgentId,
|
|
682
355
|
timeoutConfig,
|
|
683
356
|
);
|
|
684
357
|
|
|
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
|
-
|
|
699
358
|
// Register memory capability so OpenClaw 2026.4.2+ binds this plugin to
|
|
700
359
|
// the memory slot. Without this, the plugin is treated as a legacy
|
|
701
360
|
// hook-only plugin and automatic context injection won't work.
|
|
@@ -703,24 +362,14 @@ const mnemoPlugin = {
|
|
|
703
362
|
if (typeof api.registerCapability === "function") {
|
|
704
363
|
api.registerCapability("memory", {
|
|
705
364
|
search: async (query, opts) => {
|
|
706
|
-
const result = await
|
|
707
|
-
() => hookBackend.search({ q: query, limit: opts?.limit }),
|
|
708
|
-
{ data: [], total: 0, limit: opts?.limit ?? 20, offset: 0 },
|
|
709
|
-
);
|
|
365
|
+
const result = await hookBackend.search({ q: query, limit: opts?.limit });
|
|
710
366
|
return { data: result.data, total: result.total };
|
|
711
367
|
},
|
|
712
368
|
store: async (content, opts) => {
|
|
713
|
-
|
|
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
|
-
}
|
|
369
|
+
return hookBackend.store({ content, tags: opts?.tags, source: opts?.source });
|
|
721
370
|
},
|
|
722
|
-
get: (id) =>
|
|
723
|
-
remove: (id) =>
|
|
371
|
+
get: (id) => hookBackend.get(id),
|
|
372
|
+
remove: (id) => hookBackend.remove(id),
|
|
724
373
|
});
|
|
725
374
|
}
|
|
726
375
|
|
|
@@ -728,9 +377,6 @@ const mnemoPlugin = {
|
|
|
728
377
|
registerHooks(api, hookBackend, api.logger, {
|
|
729
378
|
maxIngestBytes: cfg.maxIngestBytes,
|
|
730
379
|
fallbackAgentId: hookAgentId,
|
|
731
|
-
provisionForCreateNew: configuredApiKey || !configuredProvisionToken
|
|
732
|
-
? undefined
|
|
733
|
-
: () => provisionAPIKey(hookAgentId),
|
|
734
380
|
});
|
|
735
381
|
},
|
|
736
382
|
};
|
|
@@ -744,24 +390,15 @@ const toolNames = [
|
|
|
744
390
|
];
|
|
745
391
|
|
|
746
392
|
class LazyServerBackend implements MemoryBackend {
|
|
747
|
-
private apiUrl: string;
|
|
748
|
-
private apiKeyProvider: () => Promise<string>;
|
|
749
|
-
private agentId: string;
|
|
750
|
-
private timeouts: BackendTimeouts;
|
|
751
393
|
private resolved: ServerBackend | null = null;
|
|
752
394
|
private resolving: Promise<ServerBackend> | null = null;
|
|
753
395
|
|
|
754
396
|
constructor(
|
|
755
|
-
apiUrl: string,
|
|
756
|
-
apiKeyProvider: () => Promise<string>,
|
|
757
|
-
agentId: string,
|
|
758
|
-
timeouts: BackendTimeouts,
|
|
759
|
-
) {
|
|
760
|
-
this.apiUrl = apiUrl;
|
|
761
|
-
this.apiKeyProvider = apiKeyProvider;
|
|
762
|
-
this.agentId = agentId;
|
|
763
|
-
this.timeouts = timeouts;
|
|
764
|
-
}
|
|
397
|
+
private apiUrl: string,
|
|
398
|
+
private apiKeyProvider: () => Promise<string>,
|
|
399
|
+
private agentId: string,
|
|
400
|
+
private timeouts: BackendTimeouts,
|
|
401
|
+
) {}
|
|
765
402
|
|
|
766
403
|
private async resolve(): Promise<ServerBackend> {
|
|
767
404
|
if (this.resolved) return this.resolved;
|
|
@@ -769,9 +406,7 @@ class LazyServerBackend implements MemoryBackend {
|
|
|
769
406
|
|
|
770
407
|
this.resolving = this.apiKeyProvider().then((apiKey) =>
|
|
771
408
|
Promise.resolve().then(() => {
|
|
772
|
-
this.resolved = new ServerBackend(this.apiUrl, apiKey, this.agentId,
|
|
773
|
-
timeouts: this.timeouts,
|
|
774
|
-
});
|
|
409
|
+
this.resolved = new ServerBackend(this.apiUrl, apiKey, this.agentId, this.timeouts);
|
|
775
410
|
return this.resolved;
|
|
776
411
|
})
|
|
777
412
|
).catch((err) => {
|
package/openclaw.plugin.json
CHANGED
|
@@ -14,17 +14,6 @@
|
|
|
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
|
-
},
|
|
28
17
|
"defaultTimeoutMs": {
|
|
29
18
|
"type": "number",
|
|
30
19
|
"minimum": 1,
|
|
@@ -51,10 +40,6 @@
|
|
|
51
40
|
"placeholder": "key...",
|
|
52
41
|
"sensitive": true
|
|
53
42
|
},
|
|
54
|
-
"provisionToken": {
|
|
55
|
-
"label": "Provision Token",
|
|
56
|
-
"placeholder": "create-new only"
|
|
57
|
-
},
|
|
58
43
|
"defaultTimeoutMs": {
|
|
59
44
|
"label": "Default Timeout (ms)",
|
|
60
45
|
"placeholder": "8000"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mem9/mem9",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.7",
|
|
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,7 +34,6 @@
|
|
|
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",
|
|
38
37
|
"typecheck": "tsc --noEmit",
|
|
39
38
|
"prepublishOnly": "npm run typecheck"
|
|
40
39
|
},
|
|
@@ -43,7 +42,6 @@
|
|
|
43
42
|
},
|
|
44
43
|
"dependencies": {},
|
|
45
44
|
"devDependencies": {
|
|
46
|
-
"@types/node": "^22.15.30",
|
|
47
45
|
"typescript": "^5.5.0"
|
|
48
46
|
},
|
|
49
47
|
"openclaw": {
|
package/server-backend.ts
CHANGED
|
@@ -22,11 +22,6 @@ export interface BackendTimeouts {
|
|
|
22
22
|
searchTimeoutMs?: number;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
interface ServerBackendOptions {
|
|
26
|
-
timeouts?: BackendTimeouts;
|
|
27
|
-
provisionQueryParams?: Record<string, string>;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
25
|
interface RequestOptions {
|
|
31
26
|
timeoutMs?: number;
|
|
32
27
|
}
|
|
@@ -35,37 +30,25 @@ export class ServerBackend implements MemoryBackend {
|
|
|
35
30
|
private baseUrl: string;
|
|
36
31
|
private apiKey: string;
|
|
37
32
|
private agentName: string;
|
|
38
|
-
private provisionQueryParams: Record<string, string>;
|
|
39
33
|
private timeouts: Required<BackendTimeouts>;
|
|
40
34
|
|
|
41
35
|
constructor(
|
|
42
36
|
apiUrl: string,
|
|
43
37
|
apiKey: string,
|
|
44
38
|
agentName: string,
|
|
45
|
-
|
|
39
|
+
timeouts: BackendTimeouts = {},
|
|
46
40
|
) {
|
|
47
41
|
this.baseUrl = apiUrl.replace(/\/+$/, "");
|
|
48
42
|
this.apiKey = apiKey;
|
|
49
43
|
this.agentName = agentName;
|
|
50
|
-
this.provisionQueryParams = options.provisionQueryParams ?? {};
|
|
51
44
|
this.timeouts = {
|
|
52
|
-
defaultTimeoutMs:
|
|
53
|
-
searchTimeoutMs:
|
|
45
|
+
defaultTimeoutMs: timeouts.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
46
|
+
searchTimeoutMs: timeouts.searchTimeoutMs ?? DEFAULT_SEARCH_TIMEOUT_MS,
|
|
54
47
|
};
|
|
55
48
|
}
|
|
56
49
|
|
|
57
50
|
async register(): Promise<ProvisionMem9sResponse> {
|
|
58
|
-
const
|
|
59
|
-
for (const [key, value] of Object.entries(this.provisionQueryParams)) {
|
|
60
|
-
if (!key.startsWith("utm_") || typeof value !== "string" || value === "") {
|
|
61
|
-
continue;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
query.set(key, value);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const qs = query.toString();
|
|
68
|
-
const resp = await fetch(this.baseUrl + "/v1alpha1/mem9s" + (qs ? `?${qs}` : ""), {
|
|
51
|
+
const resp = await fetch(this.baseUrl + "/v1alpha1/mem9s", {
|
|
69
52
|
method: "POST",
|
|
70
53
|
signal: AbortSignal.timeout(this.timeouts.defaultTimeoutMs),
|
|
71
54
|
});
|
package/types.ts
CHANGED
package/index.test.ts
DELETED
|
@@ -1,393 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import test from "node:test";
|
|
3
|
-
|
|
4
|
-
import mnemoPlugin from "./index.js";
|
|
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
|
-
|
|
17
|
-
interface StubApi {
|
|
18
|
-
pluginConfig?: unknown;
|
|
19
|
-
logger: {
|
|
20
|
-
info: (...args: unknown[]) => void;
|
|
21
|
-
error: (...args: unknown[]) => void;
|
|
22
|
-
};
|
|
23
|
-
registerTool: (factory: unknown, _opts?: unknown) => void;
|
|
24
|
-
registerCapability?: (slot: string, capability: unknown) => void;
|
|
25
|
-
on: (...args: unknown[]) => void;
|
|
26
|
-
getTools: (ctx?: { agentId?: string }) => RegisteredTool[];
|
|
27
|
-
getHook: (name: string) => HookHandler;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function createStubApi(
|
|
31
|
-
pluginConfig: unknown,
|
|
32
|
-
options?: {
|
|
33
|
-
onRegisterCapability?: (slot: string, capability: unknown) => void;
|
|
34
|
-
infoLogs?: string[];
|
|
35
|
-
errorLogs?: string[];
|
|
36
|
-
},
|
|
37
|
-
): StubApi {
|
|
38
|
-
const infoLogs = options?.infoLogs ?? [];
|
|
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;
|
|
44
|
-
|
|
45
|
-
return {
|
|
46
|
-
pluginConfig,
|
|
47
|
-
logger: {
|
|
48
|
-
info: (...args: unknown[]) => {
|
|
49
|
-
infoLogs.push(args.map(String).join(" "));
|
|
50
|
-
},
|
|
51
|
-
error: (...args: unknown[]) => {
|
|
52
|
-
errorLogs.push(args.map(String).join(" "));
|
|
53
|
-
},
|
|
54
|
-
},
|
|
55
|
-
registerTool: (factory: unknown) => {
|
|
56
|
-
toolFactory = factory as typeof toolFactory;
|
|
57
|
-
},
|
|
58
|
-
registerCapability: options?.onRegisterCapability,
|
|
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
|
-
},
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
async function flushAsyncWork(): Promise<void> {
|
|
85
|
-
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise<void> {
|
|
89
|
-
const startedAt = Date.now();
|
|
90
|
-
|
|
91
|
-
while (!predicate()) {
|
|
92
|
-
if (Date.now() - startedAt > timeoutMs) {
|
|
93
|
-
throw new Error("timed out waiting for condition");
|
|
94
|
-
}
|
|
95
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function uniqueApiUrl(name: string): string {
|
|
100
|
-
return `https://api.mem9.ai/${name}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
test("register does not auto-provision on startup during create-new", async () => {
|
|
104
|
-
const originalFetch = globalThis.fetch;
|
|
105
|
-
const apiUrl = uniqueApiUrl("no-startup-provision");
|
|
106
|
-
const requests: string[] = [];
|
|
107
|
-
const infoLogs: string[] = [];
|
|
108
|
-
const errorLogs: string[] = [];
|
|
109
|
-
|
|
110
|
-
globalThis.fetch = async (input) => {
|
|
111
|
-
requests.push(String(input));
|
|
112
|
-
throw new Error("unexpected fetch");
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
try {
|
|
116
|
-
mnemoPlugin.register(
|
|
117
|
-
createStubApi(
|
|
118
|
-
{
|
|
119
|
-
apiUrl,
|
|
120
|
-
provisionToken: "token-startup",
|
|
121
|
-
provisionQueryParams: {
|
|
122
|
-
utm_source: "bosn",
|
|
123
|
-
},
|
|
124
|
-
},
|
|
125
|
-
{ infoLogs, errorLogs },
|
|
126
|
-
),
|
|
127
|
-
);
|
|
128
|
-
|
|
129
|
-
await flushAsyncWork();
|
|
130
|
-
|
|
131
|
-
assert.deepEqual(requests, []);
|
|
132
|
-
assert.deepEqual(errorLogs, []);
|
|
133
|
-
assert.equal(
|
|
134
|
-
infoLogs.includes(
|
|
135
|
-
"[mem9] apiKey not configured; waiting for the first post-restart message to finish create-new provision",
|
|
136
|
-
),
|
|
137
|
-
true,
|
|
138
|
-
);
|
|
139
|
-
} finally {
|
|
140
|
-
globalThis.fetch = originalFetch;
|
|
141
|
-
}
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
test("memory capability stays idle until explicit provision runs", async () => {
|
|
145
|
-
const originalFetch = globalThis.fetch;
|
|
146
|
-
const apiUrl = uniqueApiUrl("pending-capability");
|
|
147
|
-
let capability: SearchCapability | null = null;
|
|
148
|
-
const requests: string[] = [];
|
|
149
|
-
|
|
150
|
-
globalThis.fetch = async (input) => {
|
|
151
|
-
requests.push(String(input));
|
|
152
|
-
throw new Error("unexpected fetch");
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
try {
|
|
156
|
-
mnemoPlugin.register(
|
|
157
|
-
createStubApi(
|
|
158
|
-
{
|
|
159
|
-
apiUrl,
|
|
160
|
-
provisionToken: "token-capability",
|
|
161
|
-
},
|
|
162
|
-
{
|
|
163
|
-
onRegisterCapability: (_slot, registeredCapability) => {
|
|
164
|
-
capability = registeredCapability as SearchCapability;
|
|
165
|
-
},
|
|
166
|
-
},
|
|
167
|
-
),
|
|
168
|
-
);
|
|
169
|
-
|
|
170
|
-
assert.notEqual(capability, null);
|
|
171
|
-
|
|
172
|
-
const result = await capability!.search("hello");
|
|
173
|
-
|
|
174
|
-
assert.deepEqual(result, { data: [], total: 0 });
|
|
175
|
-
assert.deepEqual(requests, []);
|
|
176
|
-
} finally {
|
|
177
|
-
globalThis.fetch = originalFetch;
|
|
178
|
-
}
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
test("first post-restart prompt provisions once and unlocks memory access", async () => {
|
|
182
|
-
const originalFetch = globalThis.fetch;
|
|
183
|
-
const apiUrl = uniqueApiUrl("explicit-provision");
|
|
184
|
-
let capability: SearchCapability | null = null;
|
|
185
|
-
let provisionRequests = 0;
|
|
186
|
-
let searchRequests = 0;
|
|
187
|
-
|
|
188
|
-
globalThis.fetch = async (input, init) => {
|
|
189
|
-
const url = String(input);
|
|
190
|
-
|
|
191
|
-
if (url === `${apiUrl}/v1alpha1/mem9s?utm_source=bosn`) {
|
|
192
|
-
provisionRequests += 1;
|
|
193
|
-
return new Response(JSON.stringify({ id: "space-explicit" }), {
|
|
194
|
-
status: 201,
|
|
195
|
-
headers: {
|
|
196
|
-
"Content-Type": "application/json",
|
|
197
|
-
},
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
if (url.includes("/v1alpha2/mem9s/memories")) {
|
|
202
|
-
searchRequests += 1;
|
|
203
|
-
const headers = init?.headers as Record<string, string> | undefined;
|
|
204
|
-
assert.equal(headers?.["X-API-Key"], "space-explicit");
|
|
205
|
-
|
|
206
|
-
return new Response(
|
|
207
|
-
JSON.stringify({
|
|
208
|
-
memories: [],
|
|
209
|
-
total: 0,
|
|
210
|
-
limit: 20,
|
|
211
|
-
offset: 0,
|
|
212
|
-
}),
|
|
213
|
-
{
|
|
214
|
-
status: 200,
|
|
215
|
-
headers: {
|
|
216
|
-
"Content-Type": "application/json",
|
|
217
|
-
},
|
|
218
|
-
},
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
throw new Error(`unexpected fetch: ${url}`);
|
|
223
|
-
};
|
|
224
|
-
|
|
225
|
-
try {
|
|
226
|
-
const api = createStubApi(
|
|
227
|
-
{
|
|
228
|
-
apiUrl,
|
|
229
|
-
provisionToken: "token-explicit",
|
|
230
|
-
provisionQueryParams: {
|
|
231
|
-
utm_source: "bosn",
|
|
232
|
-
},
|
|
233
|
-
},
|
|
234
|
-
{
|
|
235
|
-
onRegisterCapability: (_slot, registeredCapability) => {
|
|
236
|
-
capability = registeredCapability as SearchCapability;
|
|
237
|
-
},
|
|
238
|
-
},
|
|
239
|
-
);
|
|
240
|
-
mnemoPlugin.register(api);
|
|
241
|
-
|
|
242
|
-
const beforePromptBuild = api.getHook("before_prompt_build");
|
|
243
|
-
const hookResult = await beforePromptBuild({ prompt: "hi" });
|
|
244
|
-
|
|
245
|
-
assert.equal(hookResult, undefined);
|
|
246
|
-
assert.equal(provisionRequests, 1);
|
|
247
|
-
|
|
248
|
-
assert.notEqual(capability, null);
|
|
249
|
-
const searchResult = await capability!.search("hello");
|
|
250
|
-
|
|
251
|
-
assert.deepEqual(searchResult, { data: [], total: 0 });
|
|
252
|
-
assert.equal(provisionRequests, 1);
|
|
253
|
-
assert.equal(searchRequests, 1);
|
|
254
|
-
} finally {
|
|
255
|
-
globalThis.fetch = originalFetch;
|
|
256
|
-
}
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
test("concurrent first-post-restart prompts share one server request", async () => {
|
|
260
|
-
const originalFetch = globalThis.fetch;
|
|
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
|
-
});
|
|
269
|
-
|
|
270
|
-
globalThis.fetch = async (input) => {
|
|
271
|
-
const url = String(input);
|
|
272
|
-
|
|
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}`);
|
|
285
|
-
};
|
|
286
|
-
|
|
287
|
-
try {
|
|
288
|
-
const pluginConfig = {
|
|
289
|
-
apiUrl,
|
|
290
|
-
provisionToken: "token-shared-explicit",
|
|
291
|
-
};
|
|
292
|
-
const apiA = createStubApi(pluginConfig);
|
|
293
|
-
const apiB = createStubApi(pluginConfig);
|
|
294
|
-
|
|
295
|
-
mnemoPlugin.register(apiA);
|
|
296
|
-
mnemoPlugin.register(apiB);
|
|
297
|
-
|
|
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);
|
|
311
|
-
} finally {
|
|
312
|
-
provisionControl.release();
|
|
313
|
-
globalThis.fetch = originalFetch;
|
|
314
|
-
}
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
test("a second setup retry reuses the locally persisted provisioned key before config write-back", async () => {
|
|
318
|
-
const originalFetch = globalThis.fetch;
|
|
319
|
-
const apiUrl = uniqueApiUrl("shared-retry");
|
|
320
|
-
const infoLogsA: string[] = [];
|
|
321
|
-
const infoLogsB: string[] = [];
|
|
322
|
-
let provisionRequests = 0;
|
|
323
|
-
let searchRequests = 0;
|
|
324
|
-
|
|
325
|
-
globalThis.fetch = async (input, init) => {
|
|
326
|
-
const url = String(input);
|
|
327
|
-
|
|
328
|
-
if (url === `${apiUrl}/v1alpha1/mem9s`) {
|
|
329
|
-
provisionRequests += 1;
|
|
330
|
-
return new Response(JSON.stringify({ id: "space-shared-retry" }), {
|
|
331
|
-
status: 201,
|
|
332
|
-
headers: {
|
|
333
|
-
"Content-Type": "application/json",
|
|
334
|
-
},
|
|
335
|
-
});
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
if (url.includes("/v1alpha2/mem9s/memories")) {
|
|
339
|
-
searchRequests += 1;
|
|
340
|
-
const headers = init?.headers as Record<string, string> | undefined;
|
|
341
|
-
assert.equal(headers?.["X-API-Key"], "space-shared-retry");
|
|
342
|
-
return new Response(
|
|
343
|
-
JSON.stringify({
|
|
344
|
-
memories: [],
|
|
345
|
-
total: 0,
|
|
346
|
-
limit: 20,
|
|
347
|
-
offset: 0,
|
|
348
|
-
}),
|
|
349
|
-
{
|
|
350
|
-
status: 200,
|
|
351
|
-
headers: {
|
|
352
|
-
"Content-Type": "application/json",
|
|
353
|
-
},
|
|
354
|
-
},
|
|
355
|
-
);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
throw new Error(`unexpected fetch: ${url}`);
|
|
359
|
-
};
|
|
360
|
-
|
|
361
|
-
try {
|
|
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");
|
|
382
|
-
|
|
383
|
-
assert.equal(provisionRequests, 1);
|
|
384
|
-
assert.deepEqual(secondResult, { data: [], total: 0 });
|
|
385
|
-
assert.equal(searchRequests, 1);
|
|
386
|
-
assert.equal(
|
|
387
|
-
infoLogsB.includes("[mem9] reusing locally persisted create-new API key for this provisionToken"),
|
|
388
|
-
true,
|
|
389
|
-
);
|
|
390
|
-
} finally {
|
|
391
|
-
globalThis.fetch = originalFetch;
|
|
392
|
-
}
|
|
393
|
-
});
|
package/server-backend.test.ts
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
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
|
-
});
|