@mem9/mem9 0.4.11 → 0.4.13

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Memory plugin for [OpenClaw](https://github.com/openclaw) — replaces the built-in memory slot with cloud-persistent shared memory. Runs in server mode only, connecting to `mnemo-server` via `apiUrl` + `apiKey` (preferred) or legacy `tenantID`. Optional `provisionToken` and `provisionQueryParams` are used only during first-time create-new setup before an explicit `apiKey` is configured.
4
4
 
5
- When `apiKey` is absent during create-new onboarding, the plugin 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`.
5
+ When `apiKey` is absent during create-new onboarding, the plugin does not auto-provision on startup. Instead, the first post-restart OpenClaw agent turn that runs `before_prompt_build` triggers exactly one create-new provision. Setup/control chats such as TUI local embedded sessions may not run that plugin hook themselves, so onboarding can trigger one minimal OpenClaw agent turn to complete provisioning. The plugin coordinates that call across concurrent OpenClaw plugin registrations on the same machine and reuses the generated key locally for future restarts tied to the same `provisionToken`.
6
6
 
7
7
  ## 🚀 Quick Start (Server Mode)
8
8
 
@@ -182,7 +182,7 @@ Defined in `openclaw.plugin.json`:
182
182
  |---|---|---|
183
183
  | `apiUrl` | string | mnemo-server URL |
184
184
  | `apiKey` | string | Preferred key. Uses `/v1alpha2/mem9s/...` with `X-API-Key` header |
185
- | `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 |
185
+ | `provisionToken` | string | Optional one-time create-new token used locally to ensure create-new provisioning runs only once from an OpenClaw agent turn and is reused on this machine until an explicit `apiKey` is configured |
186
186
  | `provisionQueryParams` | object | Optional `utm_*` map forwarded only to the initial `POST /v1alpha1/mem9s` request made during create-new when `apiKey` is absent |
187
187
  | `defaultTimeoutMs` | number | Default timeout for non-search mem9 API requests in milliseconds. Default: `8000` |
188
188
  | `searchTimeoutMs` | number | Timeout for `memory_search` and automatic recall search in milliseconds. Default: `15000` |
@@ -247,7 +247,7 @@ openclaw-plugin/
247
247
  | `No mode configured` | Missing config | Add `apiUrl` and `apiKey` (or legacy `tenantID`) to plugin config |
248
248
  | `Server mode requires...` | Missing key | Add `apiKey` (or legacy `tenantID`) to config |
249
249
  | `config reload skipped (invalid config): plugins.entries.mem9: Unrecognized key: "apiKey"` | Setup wrote `plugins.entries.mem9.apiKey` instead of `plugins.entries.mem9.config.apiKey` | Remove the invalid top-level key and keep the secret only under `config.apiKey` |
250
- | 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 |
250
+ | 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 an OpenClaw agent turn that runs `before_prompt_build` and reuse one local result across duplicate setup retries |
251
251
  | Search requests time out | Hybrid/vector search exceeds plugin timeout | Increase `searchTimeoutMs` in plugin config |
252
252
  | Conversations are not uploaded on OpenClaw 4.23+ | `agent_end` does not include conversation messages without explicit hook permission | Set `plugins.entries.mem9.hooks.allowConversationAccess` to `true` and restart OpenClaw |
253
253
  | Plugin not loading | Not in memory slot | Set `"slots": {"memory": "mem9"}` in openclaw.json |
package/dist/backend.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export class PendingProvisionError extends Error {
2
- constructor(message = "mem9 create-new setup is waiting for the first post-restart message to finish provisioning") {
2
+ constructor(message = "mem9 create-new setup is waiting for an OpenClaw agent turn that runs before_prompt_build to finish provisioning") {
3
3
  super(message);
4
4
  this.name = "PendingProvisionError";
5
5
  }
package/dist/hooks.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * Reference: OpenClaw's built-in memory-lancedb extension uses the same pattern.
12
12
  */
13
13
  import { isPendingProvisionError } from "./backend.js";
14
+ import { formatRuntimeQuotaNotice } from "./quota-error.js";
14
15
  // ---------------------------------------------------------------------------
15
16
  // Constants
16
17
  // ---------------------------------------------------------------------------
@@ -21,6 +22,7 @@ const MAX_CONTENT_LEN = 500; // truncate individual memory content in prompt
21
22
  // Ingest defaults — configurable via maxIngestBytes in plugin config
22
23
  const DEFAULT_MAX_INGEST_BYTES = 200_000; // ~200KB safe for most LLM context windows
23
24
  const MAX_INGEST_MESSAGES = 20; // absolute cap even if small messages
25
+ const BACKGROUND_QUOTA_PAUSED_LOG = "[mem9] memory saving paused by runtime quota";
24
26
  function previewText(text, maxLen = 160) {
25
27
  const normalized = text.replace(/\s+/g, " ").trim();
26
28
  if (normalized.length <= maxLen) {
@@ -166,6 +168,13 @@ export function registerHooks(api, backend, logger, options) {
166
168
  if (isPendingProvisionError(err)) {
167
169
  return;
168
170
  }
171
+ const quotaNotice = formatRuntimeQuotaNotice(err, "recall paused");
172
+ if (quotaNotice) {
173
+ logger.info(quotaNotice);
174
+ return {
175
+ prependContext: quotaNotice,
176
+ };
177
+ }
169
178
  // Graceful degradation — never block the LLM call
170
179
  logger.error(`[mem9] before_prompt_build failed: ${String(err)}`);
171
180
  }
@@ -215,6 +224,11 @@ export function registerHooks(api, backend, logger, options) {
215
224
  if (isPendingProvisionError(err)) {
216
225
  return;
217
226
  }
227
+ const quotaNotice = formatRuntimeQuotaNotice(err, "before_reset save paused");
228
+ if (quotaNotice) {
229
+ logger.info(BACKGROUND_QUOTA_PAUSED_LOG);
230
+ return;
231
+ }
218
232
  // Best-effort — never block /reset
219
233
  logger.error(`[mem9] before_reset save failed: ${String(err)}`);
220
234
  }
@@ -310,6 +324,11 @@ export function registerHooks(api, backend, logger, options) {
310
324
  if (isPendingProvisionError(err)) {
311
325
  return;
312
326
  }
327
+ const quotaNotice = formatRuntimeQuotaNotice(err, "agent_end ingest paused");
328
+ if (quotaNotice) {
329
+ logger.info(BACKGROUND_QUOTA_PAUSED_LOG);
330
+ return;
331
+ }
313
332
  // Best-effort — never fail the agent end phase
314
333
  }
315
334
  });
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import path from "node:path";
5
5
  import { PendingProvisionError, isPendingProvisionError } from "./backend.js";
6
6
  import { DEFAULT_SEARCH_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, ServerBackend, } from "./server-backend.js";
7
7
  import { registerHooks } from "./hooks.js";
8
+ import { toolErrorPayload } from "./quota-error.js";
8
9
  const DEFAULT_API_URL = "https://api.mem9.ai";
9
10
  const TIMEOUT_FIELDS = ["defaultTimeoutMs", "searchTimeoutMs"];
10
11
  const SHARED_PROVISION_DIR = path.join(os.homedir(), ".openclaw", "mem9", "provision");
@@ -47,6 +48,9 @@ function jsonResult(data) {
47
48
  function errorMessage(err) {
48
49
  return err instanceof Error ? err.message : String(err);
49
50
  }
51
+ function jsonToolError(err) {
52
+ return jsonResult(toolErrorPayload(err));
53
+ }
50
54
  function sleep(ms) {
51
55
  return new Promise((resolve) => setTimeout(resolve, ms));
52
56
  }
@@ -248,10 +252,7 @@ function buildTools(backend) {
248
252
  return jsonResult({ ok: true, data: result });
249
253
  }
250
254
  catch (err) {
251
- return jsonResult({
252
- ok: false,
253
- error: err instanceof Error ? err.message : String(err),
254
- });
255
+ return jsonToolError(err);
255
256
  }
256
257
  },
257
258
  },
@@ -287,10 +288,7 @@ function buildTools(backend) {
287
288
  return jsonResult({ ok: true, ...result });
288
289
  }
289
290
  catch (err) {
290
- return jsonResult({
291
- ok: false,
292
- error: err instanceof Error ? err.message : String(err),
293
- });
291
+ return jsonToolError(err);
294
292
  }
295
293
  },
296
294
  },
@@ -314,10 +312,7 @@ function buildTools(backend) {
314
312
  return jsonResult({ ok: true, data: result });
315
313
  }
316
314
  catch (err) {
317
- return jsonResult({
318
- ok: false,
319
- error: err instanceof Error ? err.message : String(err),
320
- });
315
+ return jsonToolError(err);
321
316
  }
322
317
  },
323
318
  },
@@ -349,10 +344,7 @@ function buildTools(backend) {
349
344
  return jsonResult({ ok: true, data: result });
350
345
  }
351
346
  catch (err) {
352
- return jsonResult({
353
- ok: false,
354
- error: err instanceof Error ? err.message : String(err),
355
- });
347
+ return jsonToolError(err);
356
348
  }
357
349
  },
358
350
  },
@@ -376,10 +368,7 @@ function buildTools(backend) {
376
368
  return jsonResult({ ok: true });
377
369
  }
378
370
  catch (err) {
379
- return jsonResult({
380
- ok: false,
381
- error: err instanceof Error ? err.message : String(err),
382
- });
371
+ return jsonToolError(err);
383
372
  }
384
373
  },
385
374
  },
@@ -471,7 +460,7 @@ const mnemoPlugin = {
471
460
  api.logger.info("[mem9] Server mode (v1alpha2)");
472
461
  if (!configuredApiKey) {
473
462
  if (configuredProvisionToken) {
474
- api.logger.info("[mem9] apiKey not configured; waiting for the first post-restart message to finish create-new provision");
463
+ api.logger.info("[mem9] apiKey not configured; waiting for an OpenClaw agent turn that runs before_prompt_build to finish create-new provision");
475
464
  }
476
465
  else {
477
466
  api.logger.info("[mem9] apiKey not configured; mem9 will stay idle until apiKey is configured");
@@ -0,0 +1,215 @@
1
+ export class Mem9HttpError extends Error {
2
+ status;
3
+ body;
4
+ data;
5
+ constructor(message, status, body, data) {
6
+ super(message);
7
+ this.status = status;
8
+ this.body = body;
9
+ this.data = data;
10
+ this.name = "Mem9HttpError";
11
+ }
12
+ }
13
+ function isRecord(value) {
14
+ return typeof value === "object" && value !== null && !Array.isArray(value);
15
+ }
16
+ function normalizeString(value) {
17
+ return typeof value === "string" ? value.trim() : "";
18
+ }
19
+ function normalizePositiveInteger(value) {
20
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
21
+ }
22
+ export function parseJsonOrUndefined(text) {
23
+ if (!text.trim()) {
24
+ return undefined;
25
+ }
26
+ try {
27
+ return JSON.parse(text);
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ }
33
+ export function messageFromErrorBody(status, body, data) {
34
+ if (isRecord(data)) {
35
+ const message = normalizeString(data.message);
36
+ if (message) {
37
+ return message;
38
+ }
39
+ const error = normalizeString(data.error);
40
+ if (error) {
41
+ return error;
42
+ }
43
+ }
44
+ const text = body.trim();
45
+ return text || `HTTP ${status}`;
46
+ }
47
+ function normalizeRecommendedAction(runtimeQuota) {
48
+ const nested = isRecord(runtimeQuota.recommendedAction) ? runtimeQuota.recommendedAction : {};
49
+ const providerActionCode = normalizeString(nested.providerActionCode);
50
+ const severity = normalizeString(nested.severity);
51
+ const type = normalizeString(nested.type);
52
+ const url = normalizeString(nested.url);
53
+ if (!providerActionCode && !severity && !type && !url) {
54
+ return null;
55
+ }
56
+ return {
57
+ ...(providerActionCode ? { providerActionCode } : {}),
58
+ ...(severity ? { severity } : {}),
59
+ ...(type ? { type } : {}),
60
+ ...(url ? { url } : {}),
61
+ };
62
+ }
63
+ function quotaGateReason(runtimeQuota) {
64
+ const quotaGateResult = isRecord(runtimeQuota.quotaGateResult) ? runtimeQuota.quotaGateResult : {};
65
+ return normalizeString(quotaGateResult.reason);
66
+ }
67
+ function retryAfterSeconds(runtimeQuota) {
68
+ const direct = normalizePositiveInteger(runtimeQuota.retryAfterSeconds);
69
+ if (direct !== null) {
70
+ return direct;
71
+ }
72
+ const quotaGateResult = isRecord(runtimeQuota.quotaGateResult) ? runtimeQuota.quotaGateResult : {};
73
+ const postQuotaRateLimit = isRecord(quotaGateResult.postQuotaRateLimit) ? quotaGateResult.postQuotaRateLimit : {};
74
+ return normalizePositiveInteger(postQuotaRateLimit.retryAfterSeconds);
75
+ }
76
+ function isPostQuotaRateLimited(denied) {
77
+ return denied.status === 429 ||
78
+ denied.quotaGateReason === "postQuotaRateLimitExceeded";
79
+ }
80
+ function quotaReason(denied) {
81
+ if (isPostQuotaRateLimited(denied)) {
82
+ return "this API key has reached the temporary request limit for this memory feature";
83
+ }
84
+ const providerActionCode = normalizeString(denied.recommendedAction?.providerActionCode);
85
+ if (providerActionCode === "claimApiKey") {
86
+ return "the included usage quota for this API key has been used up";
87
+ }
88
+ if (providerActionCode === "increaseSpendingLimit") {
89
+ return "the configured spending limit would be exceeded";
90
+ }
91
+ if (providerActionCode === "enableOnDemand") {
92
+ return "the included usage quota has been used up and on-demand usage is not enabled";
93
+ }
94
+ if (providerActionCode === "upgradePlan") {
95
+ return "the included usage quota for this mem9 account has been used up";
96
+ }
97
+ if (providerActionCode === "resolveAccountState") {
98
+ return "the current account or billing state blocks runtime memory access";
99
+ }
100
+ return "the runtime quota check blocked this request";
101
+ }
102
+ function quotaNoticeSubject(denied, operation) {
103
+ const meter = normalizeString(denied.meter);
104
+ if (meter === "memory_write_requests") {
105
+ return {
106
+ headline: "Mem9 memory saving is temporarily unavailable",
107
+ userState: "mem9 cannot save new memories right now",
108
+ };
109
+ }
110
+ if (meter === "memory_recall_requests") {
111
+ return {
112
+ headline: "Mem9 recall is temporarily unavailable",
113
+ userState: "mem9 cannot recall memories right now",
114
+ };
115
+ }
116
+ const operationText = normalizeString(operation).toLowerCase();
117
+ if (/\b(ingest|save|store|write)\b/.test(operationText)) {
118
+ return {
119
+ headline: "Mem9 memory saving is temporarily unavailable",
120
+ userState: "mem9 cannot save new memories right now",
121
+ };
122
+ }
123
+ if (/\b(recall|search)\b/.test(operationText)) {
124
+ return {
125
+ headline: "Mem9 recall is temporarily unavailable",
126
+ userState: "mem9 cannot recall memories right now",
127
+ };
128
+ }
129
+ return {
130
+ headline: "Mem9 memory is temporarily unavailable",
131
+ userState: "mem9 cannot complete the memory request right now",
132
+ };
133
+ }
134
+ function actionUrlForDenied(denied) {
135
+ return normalizeString(denied.recommendedAction?.url);
136
+ }
137
+ function actionInstruction(denied) {
138
+ const action = denied.recommendedAction;
139
+ const providerActionCode = normalizeString(action?.providerActionCode);
140
+ const actionUrl = actionUrlForDenied(denied);
141
+ if (!actionUrl) {
142
+ if (isPostQuotaRateLimited(denied)) {
143
+ return "Tell them that the quota/rate-limit check blocked this request and to retry later or open the mem9 console to review account and billing settings.";
144
+ }
145
+ return "Ask them to open the mem9 console to resolve the account or billing state.";
146
+ }
147
+ switch (providerActionCode) {
148
+ case "claimApiKey":
149
+ return `Ask them to open this link to sign in or create a mem9 account and claim this API key: ${actionUrl}. After claiming the key, they can upgrade their plan or set up billing to get more usage. Include the link exactly as written.`;
150
+ case "upgradePlan":
151
+ return `Ask them to open this link to upgrade their mem9 plan and get more included usage: ${actionUrl}. Include the link exactly as written.`;
152
+ case "increaseSpendingLimit":
153
+ return `Ask them to open this link to increase the mem9 spending limit or adjust billing settings: ${actionUrl}. Include the link exactly as written.`;
154
+ case "enableOnDemand":
155
+ return `Ask them to open this link to enable billing or on-demand usage for mem9: ${actionUrl}. Include the link exactly as written.`;
156
+ case "resolveAccountState":
157
+ return `Ask them to open this mem9 link to resolve the account or billing state: ${actionUrl}. Include the link exactly as written.`;
158
+ default:
159
+ return `Ask them to open this mem9 link to resolve the account or billing state: ${actionUrl}. Include the link exactly as written.`;
160
+ }
161
+ }
162
+ export function parseRuntimeQuotaDenied(value) {
163
+ const payload = value instanceof Mem9HttpError ? value.data : value;
164
+ if (!isRecord(payload)) {
165
+ return null;
166
+ }
167
+ const details = isRecord(payload.details) ? payload.details : {};
168
+ if (normalizeString(details.errorCategory) !== "runtime_quota_denied") {
169
+ return null;
170
+ }
171
+ const runtimeQuota = isRecord(details.runtimeQuota) ? details.runtimeQuota : {};
172
+ const recommendedAction = normalizeRecommendedAction(runtimeQuota);
173
+ const retryAfter = retryAfterSeconds(runtimeQuota);
174
+ const reason = quotaGateReason(runtimeQuota);
175
+ return {
176
+ status: value instanceof Mem9HttpError ? value.status : null,
177
+ code: "runtime_quota_denied",
178
+ message: normalizeString(payload.error) || "Runtime usage quota denied.",
179
+ ...(normalizeString(runtimeQuota.meter) ? { meter: normalizeString(runtimeQuota.meter) } : {}),
180
+ ...(reason ? { quotaGateReason: reason } : {}),
181
+ ...(retryAfter !== null ? { retryAfterSeconds: retryAfter } : {}),
182
+ ...(recommendedAction ? { recommendedAction } : {}),
183
+ };
184
+ }
185
+ export function formatRuntimeQuotaNotice(value, operation) {
186
+ const denied = parseRuntimeQuotaDenied(value);
187
+ if (!denied) {
188
+ return "";
189
+ }
190
+ const subject = quotaNoticeSubject(denied, operation);
191
+ return `${subject.headline} because ${quotaReason(denied)}. In your reply, briefly tell the user that ${subject.userState}. ${actionInstruction(denied)}`;
192
+ }
193
+ export function toolErrorPayload(error) {
194
+ const denied = parseRuntimeQuotaDenied(error);
195
+ if (denied) {
196
+ const actionUrl = actionUrlForDenied(denied);
197
+ return {
198
+ ok: false,
199
+ error: denied.message,
200
+ status_code: denied.status,
201
+ code: denied.code,
202
+ quota: {
203
+ code: denied.code,
204
+ message: denied.message,
205
+ ...(denied.retryAfterSeconds !== undefined ? { retryAfterSeconds: denied.retryAfterSeconds } : {}),
206
+ ...(denied.recommendedAction ? { recommendedAction: denied.recommendedAction } : {}),
207
+ },
208
+ ...(actionUrl ? { action_url: actionUrl } : {}),
209
+ };
210
+ }
211
+ return {
212
+ ok: false,
213
+ error: error instanceof Error ? error.message : String(error),
214
+ };
215
+ }
@@ -1,3 +1,4 @@
1
+ import { Mem9HttpError, messageFromErrorBody, parseJsonOrUndefined, } from "./quota-error.js";
1
2
  export const DEFAULT_TIMEOUT_MS = 8_000;
2
3
  export const DEFAULT_SEARCH_TIMEOUT_MS = 15_000;
3
4
  export class ServerBackend {
@@ -76,16 +77,22 @@ export class ServerBackend {
76
77
  try {
77
78
  return await this.request("GET", this.memoryPath(`/memories/${id}`));
78
79
  }
79
- catch {
80
- return null;
80
+ catch (err) {
81
+ if (err instanceof Mem9HttpError && err.status === 404) {
82
+ return null;
83
+ }
84
+ throw err;
81
85
  }
82
86
  }
83
87
  async update(id, input) {
84
88
  try {
85
89
  return await this.request("PUT", this.memoryPath(`/memories/${id}`), input);
86
90
  }
87
- catch {
88
- return null;
91
+ catch (err) {
92
+ if (err instanceof Mem9HttpError && err.status === 404) {
93
+ return null;
94
+ }
95
+ throw err;
89
96
  }
90
97
  }
91
98
  async remove(id) {
@@ -93,8 +100,11 @@ export class ServerBackend {
93
100
  await this.request("DELETE", this.memoryPath(`/memories/${id}`));
94
101
  return true;
95
102
  }
96
- catch {
97
- return false;
103
+ catch (err) {
104
+ if (err instanceof Mem9HttpError && err.status === 404) {
105
+ return false;
106
+ }
107
+ throw err;
98
108
  }
99
109
  }
100
110
  async ingest(input) {
@@ -119,10 +129,11 @@ export class ServerBackend {
119
129
  if (resp.status === 204) {
120
130
  return undefined;
121
131
  }
122
- const data = await resp.json();
132
+ const text = await resp.text();
123
133
  if (!resp.ok) {
124
- throw new Error(data.error || `HTTP ${resp.status}`);
134
+ const data = parseJsonOrUndefined(text);
135
+ throw new Mem9HttpError(messageFromErrorBody(resp.status, text, data), resp.status, text, data);
125
136
  }
126
- return data;
137
+ return JSON.parse(text);
127
138
  }
128
139
  }
@@ -16,11 +16,11 @@
16
16
  },
17
17
  "provisionToken": {
18
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"
19
+ "description": "One-time create-new token used locally to ensure API-key provisioning runs only once from an OpenClaw agent turn and can be reused on this machine before apiKey is configured explicitly"
20
20
  },
21
21
  "provisionQueryParams": {
22
22
  "type": "object",
23
- "description": "Optional utm_* params forwarded only by the first create-new provision request triggered after the initial restart",
23
+ "description": "Optional utm_* params forwarded only by the first create-new provision request triggered by an OpenClaw agent turn after the initial restart",
24
24
  "additionalProperties": {
25
25
  "type": "string"
26
26
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mem9/mem9",
3
- "version": "0.4.11",
3
+ "version": "0.4.13",
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",