@mem9/opencode 0.1.3 → 0.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mem9/opencode",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "OpenCode memory plugin — cloud-persistent AI agent memory with hybrid vector + keyword search via mem9",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -3,6 +3,7 @@ import type { IngestMessage, MemoryBackend } from "./backend.ts";
3
3
  import type { DebugLogger } from "./debug.ts";
4
4
  import { selectMessagesForIngest } from "./ingest/select.ts";
5
5
  import { submitMessagesForIngest } from "./ingest/submit.ts";
6
+ import { formatRuntimeQuotaNotice, parseRuntimeQuotaDenied } from "./quota-error.ts";
6
7
  import { formatRecallBlock } from "./recall/format.ts";
7
8
  import { buildRecallQuery } from "./recall/query.ts";
8
9
  import type { SessionTranscriptLoader } from "./session-transcript.ts";
@@ -185,14 +186,16 @@ async function ingestSessionTranscript(
185
186
  sessionID,
186
187
  messageCount: selectedMessages.length,
187
188
  });
188
- await submitMessagesForIngest({
189
+ const result = await submitMessagesForIngest({
189
190
  backend,
190
191
  messages: transcript,
191
192
  sessionID,
192
193
  agentID: state.agentID,
193
194
  debugLogger: options.debugLogger,
194
195
  });
195
- state.lastIngestFingerprint = fingerprint;
196
+ if (result) {
197
+ state.lastIngestFingerprint = fingerprint;
198
+ }
196
199
  } finally {
197
200
  if (state.pendingIngestFingerprint === fingerprint) {
198
201
  state.pendingIngestFingerprint = null;
@@ -308,6 +311,18 @@ export function buildHooks(
308
311
  output.system.push(block);
309
312
  }
310
313
  } catch (error) {
314
+ const quotaDenied = parseRuntimeQuotaDenied(error);
315
+ if (quotaDenied) {
316
+ await options.debugLogger?.("recall.quota_denied", {
317
+ sessionID: input.sessionID,
318
+ code: quotaDenied.code,
319
+ actionType: quotaDenied.recommendedAction?.type,
320
+ hasActionUrl: Boolean(quotaDenied.recommendedAction?.url),
321
+ });
322
+ output.system.push(formatRuntimeQuotaNotice(error, "recall paused"));
323
+ return;
324
+ }
325
+
311
326
  await options.debugLogger?.("recall.error", {
312
327
  sessionID: input.sessionID,
313
328
  error: error instanceof Error ? error.message : String(error),
@@ -1,5 +1,6 @@
1
1
  import type { IngestInput, IngestResult, MemoryBackend } from "../backend.ts";
2
2
  import type { DebugLogger } from "../debug.ts";
3
+ import { parseRuntimeQuotaDenied } from "../quota-error.ts";
3
4
  import { selectMessagesForIngest } from "./select.ts";
4
5
 
5
6
  export interface SubmitIngestOptions {
@@ -45,6 +46,18 @@ export async function submitMessagesForIngest(
45
46
  });
46
47
  return result;
47
48
  } catch (error) {
49
+ const quotaDenied = parseRuntimeQuotaDenied(error);
50
+ if (quotaDenied) {
51
+ await options.debugLogger?.("ingest.quota_denied", {
52
+ sessionID: options.sessionID,
53
+ agentID: options.agentID,
54
+ code: quotaDenied.code,
55
+ actionType: quotaDenied.recommendedAction?.type,
56
+ hasActionUrl: Boolean(quotaDenied.recommendedAction?.url),
57
+ });
58
+ return null;
59
+ }
60
+
48
61
  await options.debugLogger?.("ingest.error", {
49
62
  sessionID: options.sessionID,
50
63
  agentID: options.agentID,
@@ -0,0 +1,259 @@
1
+ export class Mem9HttpError extends Error {
2
+ constructor(
3
+ message: string,
4
+ readonly status: number,
5
+ readonly body: string,
6
+ readonly data: unknown,
7
+ ) {
8
+ super(message);
9
+ this.name = "Mem9HttpError";
10
+ }
11
+ }
12
+
13
+ export interface RuntimeRecommendedAction {
14
+ providerActionCode?: string;
15
+ severity?: string;
16
+ type?: string;
17
+ url?: string;
18
+ }
19
+
20
+ export interface RuntimeQuotaDenied {
21
+ status: number | null;
22
+ code: string;
23
+ message: string;
24
+ meter?: string;
25
+ quotaGateReason?: string;
26
+ retryAfterSeconds?: number;
27
+ recommendedAction?: RuntimeRecommendedAction;
28
+ }
29
+
30
+ function isRecord(value: unknown): value is Record<string, unknown> {
31
+ return typeof value === "object" && value !== null && !Array.isArray(value);
32
+ }
33
+
34
+ function normalizeString(value: unknown): string {
35
+ return typeof value === "string" ? value.trim() : "";
36
+ }
37
+
38
+ function normalizePositiveInteger(value: unknown): number | null {
39
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
40
+ }
41
+
42
+ export function parseJsonOrUndefined(text: string): unknown {
43
+ if (!text.trim()) {
44
+ return undefined;
45
+ }
46
+
47
+ try {
48
+ return JSON.parse(text) as unknown;
49
+ } catch {
50
+ return undefined;
51
+ }
52
+ }
53
+
54
+ export function messageFromErrorBody(status: number, body: string, data: unknown): string {
55
+ if (isRecord(data)) {
56
+ const message = normalizeString(data.message);
57
+ if (message) {
58
+ return message;
59
+ }
60
+
61
+ const error = normalizeString(data.error);
62
+ if (error) {
63
+ return error;
64
+ }
65
+ }
66
+
67
+ const text = body.trim();
68
+ return text || `HTTP ${status}`;
69
+ }
70
+
71
+ function normalizeRecommendedAction(runtimeQuota: Record<string, unknown>): RuntimeRecommendedAction | null {
72
+ const nested = isRecord(runtimeQuota.recommendedAction) ? runtimeQuota.recommendedAction : {};
73
+ const providerActionCode = normalizeString(nested.providerActionCode);
74
+ const severity = normalizeString(nested.severity);
75
+ const type = normalizeString(nested.type);
76
+ const url = normalizeString(nested.url);
77
+
78
+ if (!providerActionCode && !severity && !type && !url) {
79
+ return null;
80
+ }
81
+
82
+ return {
83
+ ...(providerActionCode ? { providerActionCode } : {}),
84
+ ...(severity ? { severity } : {}),
85
+ ...(type ? { type } : {}),
86
+ ...(url ? { url } : {}),
87
+ };
88
+ }
89
+
90
+ function quotaGateReason(runtimeQuota: Record<string, unknown>): string {
91
+ const quotaGateResult = isRecord(runtimeQuota.quotaGateResult) ? runtimeQuota.quotaGateResult : {};
92
+ return normalizeString(quotaGateResult.reason);
93
+ }
94
+
95
+ function retryAfterSeconds(runtimeQuota: Record<string, unknown>): number | null {
96
+ const direct = normalizePositiveInteger(runtimeQuota.retryAfterSeconds);
97
+ if (direct !== null) {
98
+ return direct;
99
+ }
100
+
101
+ const quotaGateResult = isRecord(runtimeQuota.quotaGateResult) ? runtimeQuota.quotaGateResult : {};
102
+ const postQuotaRateLimit = isRecord(quotaGateResult.postQuotaRateLimit) ? quotaGateResult.postQuotaRateLimit : {};
103
+ return normalizePositiveInteger(postQuotaRateLimit.retryAfterSeconds);
104
+ }
105
+
106
+ function isPostQuotaRateLimited(denied: RuntimeQuotaDenied): boolean {
107
+ return denied.status === 429 ||
108
+ denied.quotaGateReason === "postQuotaRateLimitExceeded";
109
+ }
110
+
111
+ function quotaReason(denied: RuntimeQuotaDenied): string {
112
+ if (isPostQuotaRateLimited(denied)) {
113
+ return "this API key has reached the temporary request limit for this memory feature";
114
+ }
115
+ const providerActionCode = normalizeString(denied.recommendedAction?.providerActionCode);
116
+ if (providerActionCode === "claimApiKey") {
117
+ return "the included usage quota for this API key has been used up";
118
+ }
119
+ if (providerActionCode === "increaseSpendingLimit") {
120
+ return "the configured spending limit would be exceeded";
121
+ }
122
+ if (providerActionCode === "enableOnDemand") {
123
+ return "the included usage quota has been used up and on-demand usage is not enabled";
124
+ }
125
+ if (providerActionCode === "upgradePlan") {
126
+ return "the included usage quota for this mem9 account has been used up";
127
+ }
128
+ if (providerActionCode === "resolveAccountState") {
129
+ return "the current account or billing state blocks runtime memory access";
130
+ }
131
+ return "the runtime quota check blocked this request";
132
+ }
133
+
134
+ function quotaNoticeSubject(denied: RuntimeQuotaDenied, operation: string): { headline: string; userState: string } {
135
+ const meter = normalizeString(denied.meter);
136
+ if (meter === "memory_write_requests") {
137
+ return {
138
+ headline: "Mem9 memory saving is temporarily unavailable",
139
+ userState: "mem9 cannot save new memories right now",
140
+ };
141
+ }
142
+ if (meter === "memory_recall_requests") {
143
+ return {
144
+ headline: "Mem9 recall is temporarily unavailable",
145
+ userState: "mem9 cannot recall memories right now",
146
+ };
147
+ }
148
+
149
+ const operationText = normalizeString(operation).toLowerCase();
150
+ if (/\b(ingest|save|store|write)\b/.test(operationText)) {
151
+ return {
152
+ headline: "Mem9 memory saving is temporarily unavailable",
153
+ userState: "mem9 cannot save new memories right now",
154
+ };
155
+ }
156
+ if (/\b(recall|search)\b/.test(operationText)) {
157
+ return {
158
+ headline: "Mem9 recall is temporarily unavailable",
159
+ userState: "mem9 cannot recall memories right now",
160
+ };
161
+ }
162
+
163
+ return {
164
+ headline: "Mem9 memory is temporarily unavailable",
165
+ userState: "mem9 cannot complete the memory request right now",
166
+ };
167
+ }
168
+
169
+ function actionUrlForDenied(denied: RuntimeQuotaDenied): string {
170
+ return normalizeString(denied.recommendedAction?.url);
171
+ }
172
+
173
+ function actionInstruction(denied: RuntimeQuotaDenied): string {
174
+ const action = denied.recommendedAction;
175
+ const providerActionCode = normalizeString(action?.providerActionCode);
176
+ const actionUrl = actionUrlForDenied(denied);
177
+ if (!actionUrl) {
178
+ if (isPostQuotaRateLimited(denied)) {
179
+ 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.";
180
+ }
181
+ return "Ask them to open the mem9 console to resolve the account or billing state.";
182
+ }
183
+
184
+ switch (providerActionCode) {
185
+ case "claimApiKey":
186
+ 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.`;
187
+ case "upgradePlan":
188
+ return `Ask them to open this link to upgrade their mem9 plan and get more included usage: ${actionUrl}. Include the link exactly as written.`;
189
+ case "increaseSpendingLimit":
190
+ return `Ask them to open this link to increase the mem9 spending limit or adjust billing settings: ${actionUrl}. Include the link exactly as written.`;
191
+ case "enableOnDemand":
192
+ return `Ask them to open this link to enable billing or on-demand usage for mem9: ${actionUrl}. Include the link exactly as written.`;
193
+ case "resolveAccountState":
194
+ return `Ask them to open this mem9 link to resolve the account or billing state: ${actionUrl}. Include the link exactly as written.`;
195
+ default:
196
+ return `Ask them to open this mem9 link to resolve the account or billing state: ${actionUrl}. Include the link exactly as written.`;
197
+ }
198
+ }
199
+
200
+ export function parseRuntimeQuotaDenied(value: unknown): RuntimeQuotaDenied | null {
201
+ const payload = value instanceof Mem9HttpError ? value.data : value;
202
+ if (!isRecord(payload)) {
203
+ return null;
204
+ }
205
+
206
+ const details = isRecord(payload.details) ? payload.details : {};
207
+ if (normalizeString(details.errorCategory) !== "runtime_quota_denied") {
208
+ return null;
209
+ }
210
+ const runtimeQuota = isRecord(details.runtimeQuota) ? details.runtimeQuota : {};
211
+
212
+ const recommendedAction = normalizeRecommendedAction(runtimeQuota);
213
+ const retryAfter = retryAfterSeconds(runtimeQuota);
214
+ const reason = quotaGateReason(runtimeQuota);
215
+ return {
216
+ status: value instanceof Mem9HttpError ? value.status : null,
217
+ code: "runtime_quota_denied",
218
+ message: normalizeString(payload.error) || "Runtime usage quota denied.",
219
+ ...(normalizeString(runtimeQuota.meter) ? { meter: normalizeString(runtimeQuota.meter) } : {}),
220
+ ...(reason ? { quotaGateReason: reason } : {}),
221
+ ...(retryAfter !== null ? { retryAfterSeconds: retryAfter } : {}),
222
+ ...(recommendedAction ? { recommendedAction } : {}),
223
+ };
224
+ }
225
+
226
+ export function formatRuntimeQuotaNotice(value: unknown, operation: string): string {
227
+ const denied = parseRuntimeQuotaDenied(value);
228
+ if (!denied) {
229
+ return "";
230
+ }
231
+
232
+ const subject = quotaNoticeSubject(denied, operation);
233
+ return `${subject.headline} because ${quotaReason(denied)}. In your reply, briefly tell the user that ${subject.userState}. ${actionInstruction(denied)}`;
234
+ }
235
+
236
+ export function toolErrorPayload(error: unknown): Record<string, unknown> {
237
+ const denied = parseRuntimeQuotaDenied(error);
238
+ if (denied) {
239
+ const actionUrl = actionUrlForDenied(denied);
240
+ return {
241
+ ok: false,
242
+ error: denied.message,
243
+ status_code: denied.status,
244
+ code: denied.code,
245
+ quota: {
246
+ code: denied.code,
247
+ message: denied.message,
248
+ ...(denied.retryAfterSeconds !== undefined ? { retryAfterSeconds: denied.retryAfterSeconds } : {}),
249
+ ...(denied.recommendedAction ? { recommendedAction: denied.recommendedAction } : {}),
250
+ },
251
+ ...(actionUrl ? { action_url: actionUrl } : {}),
252
+ };
253
+ }
254
+
255
+ return {
256
+ ok: false,
257
+ error: error instanceof Error ? error.message : String(error),
258
+ };
259
+ }
@@ -12,10 +12,11 @@ import type {
12
12
  SearchInput,
13
13
  } from "../shared/types.ts";
14
14
  import { DEFAULT_SCOPE_CONFIG } from "../shared/defaults.ts";
15
-
16
- function isRecord(value: unknown): value is Record<string, unknown> {
17
- return typeof value === "object" && value !== null && !Array.isArray(value);
18
- }
15
+ import {
16
+ Mem9HttpError,
17
+ messageFromErrorBody,
18
+ parseJsonOrUndefined,
19
+ } from "./quota-error.ts";
19
20
 
20
21
  function normalizeTimeoutMs(value: number | undefined, fallback: number): number {
21
22
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
@@ -82,13 +83,16 @@ export class ServerBackend implements MemoryBackend {
82
83
  if (resp.status === 204) return undefined as T;
83
84
 
84
85
  const text = await resp.text();
85
- const data = text ? (JSON.parse(text) as unknown) : undefined;
86
86
  if (!resp.ok) {
87
- const message =
88
- isRecord(data) && typeof data.error === "string" ? data.error : `HTTP ${resp.status}`;
89
- throw new Error(message);
87
+ const data = parseJsonOrUndefined(text);
88
+ throw new Mem9HttpError(
89
+ messageFromErrorBody(resp.status, text, data),
90
+ resp.status,
91
+ text,
92
+ data,
93
+ );
90
94
  }
91
- return data as T;
95
+ return JSON.parse(text) as T;
92
96
  }
93
97
 
94
98
  async store(input: CreateMemoryInput): Promise<StoreResult> {
@@ -133,7 +137,7 @@ export class ServerBackend implements MemoryBackend {
133
137
  try {
134
138
  return await this.request<Memory>("GET", this.memoryPath(`/memories/${id}`));
135
139
  } catch (err) {
136
- if (err instanceof Error && (err.message.includes("not found") || err.message.includes("404"))) {
140
+ if (err instanceof Mem9HttpError && err.status === 404) {
137
141
  return null;
138
142
  }
139
143
  throw err;
@@ -144,7 +148,7 @@ export class ServerBackend implements MemoryBackend {
144
148
  try {
145
149
  return await this.request<Memory>("PUT", this.memoryPath(`/memories/${id}`), input);
146
150
  } catch (err) {
147
- if (err instanceof Error && (err.message.includes("not found") || err.message.includes("404"))) {
151
+ if (err instanceof Mem9HttpError && err.status === 404) {
148
152
  return null;
149
153
  }
150
154
  throw err;
@@ -156,7 +160,7 @@ export class ServerBackend implements MemoryBackend {
156
160
  await this.request("DELETE", this.memoryPath(`/memories/${id}`));
157
161
  return true;
158
162
  } catch (err) {
159
- if (err instanceof Error && (err.message.includes("not found") || err.message.includes("404"))) {
163
+ if (err instanceof Mem9HttpError && err.status === 404) {
160
164
  return false;
161
165
  }
162
166
  throw err;
@@ -5,6 +5,11 @@ import type {
5
5
  UpdateMemoryInput,
6
6
  SearchInput,
7
7
  } from "../shared/types.ts";
8
+ import { toolErrorPayload } from "./quota-error.ts";
9
+
10
+ function jsonToolError(error: unknown): string {
11
+ return JSON.stringify(toolErrorPayload(error));
12
+ }
8
13
 
9
14
  /**
10
15
  * Build the 5 memory tools for OpenCode.
@@ -45,10 +50,7 @@ export function buildTools(backend: MemoryBackend): Record<string, ReturnType<ty
45
50
  const result = await backend.store(input);
46
51
  return JSON.stringify({ ok: true, data: result });
47
52
  } catch (err) {
48
- return JSON.stringify({
49
- ok: false,
50
- error: err instanceof Error ? err.message : String(err),
51
- });
53
+ return jsonToolError(err);
52
54
  }
53
55
  },
54
56
  }),
@@ -97,10 +99,7 @@ export function buildTools(backend: MemoryBackend): Record<string, ReturnType<ty
97
99
  const result = await backend.search(input);
98
100
  return JSON.stringify({ ok: true, ...result });
99
101
  } catch (err) {
100
- return JSON.stringify({
101
- ok: false,
102
- error: err instanceof Error ? err.message : String(err),
103
- });
102
+ return jsonToolError(err);
104
103
  }
105
104
  },
106
105
  }),
@@ -118,10 +117,7 @@ export function buildTools(backend: MemoryBackend): Record<string, ReturnType<ty
118
117
  }
119
118
  return JSON.stringify({ ok: true, data: result });
120
119
  } catch (err) {
121
- return JSON.stringify({
122
- ok: false,
123
- error: err instanceof Error ? err.message : String(err),
124
- });
120
+ return jsonToolError(err);
125
121
  }
126
122
  },
127
123
  }),
@@ -157,10 +153,7 @@ export function buildTools(backend: MemoryBackend): Record<string, ReturnType<ty
157
153
  }
158
154
  return JSON.stringify({ ok: true, data: result });
159
155
  } catch (err) {
160
- return JSON.stringify({
161
- ok: false,
162
- error: err instanceof Error ? err.message : String(err),
163
- });
156
+ return jsonToolError(err);
164
157
  }
165
158
  },
166
159
  }),
@@ -178,10 +171,7 @@ export function buildTools(backend: MemoryBackend): Record<string, ReturnType<ty
178
171
  }
179
172
  return JSON.stringify({ ok: true });
180
173
  } catch (err) {
181
- return JSON.stringify({
182
- ok: false,
183
- error: err instanceof Error ? err.message : String(err),
184
- });
174
+ return jsonToolError(err);
185
175
  }
186
176
  },
187
177
  }),