@memoryrelay/plugin-memoryrelay-ai 0.6.0 → 0.6.2
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/LICENSE +1 -1
- package/README.md +184 -513
- package/index.ts +587 -300
- package/openclaw.plugin.json +25 -8
- package/package.json +11 -4
package/index.ts
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OpenClaw Memory Plugin - MemoryRelay
|
|
2
|
+
* OpenClaw Memory Plugin - MemoryRelay
|
|
3
|
+
* Version: 0.6.0 (Enhanced)
|
|
3
4
|
*
|
|
4
5
|
* Long-term memory with vector search using MemoryRelay API.
|
|
5
6
|
* Provides auto-recall and auto-capture via lifecycle hooks.
|
|
6
7
|
*
|
|
7
|
-
* Improvements in v0.6.0:
|
|
8
|
-
* - Circuit breaker for API failures
|
|
9
|
-
* - Retry logic with exponential backoff
|
|
10
|
-
* - Enhanced entity extraction
|
|
11
|
-
* - Query preprocessing for better search
|
|
12
|
-
* - Structured error logging
|
|
13
|
-
*
|
|
14
8
|
* API: https://api.memoryrelay.net
|
|
15
9
|
* Docs: https://memoryrelay.io
|
|
10
|
+
*
|
|
11
|
+
* ENHANCEMENTS (v0.6.0):
|
|
12
|
+
* - Retry logic with exponential backoff (3 attempts)
|
|
13
|
+
* - Request timeout (30 seconds)
|
|
14
|
+
* - Environment variable fallback support
|
|
15
|
+
* - Channel filtering (excludeChannels config)
|
|
16
|
+
* - Additional CLI commands (stats, delete, export)
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
19
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
@@ -23,36 +24,23 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
|
23
24
|
|
|
24
25
|
const DEFAULT_API_URL = "https://api.memoryrelay.net";
|
|
25
26
|
const VALID_HEALTH_STATUSES = ["ok", "healthy", "up"];
|
|
27
|
+
const REQUEST_TIMEOUT_MS = 30000; // 30 seconds
|
|
28
|
+
const MAX_RETRIES = 3;
|
|
29
|
+
const INITIAL_RETRY_DELAY_MS = 1000; // 1 second
|
|
26
30
|
|
|
27
31
|
// ============================================================================
|
|
28
32
|
// Types
|
|
29
33
|
// ============================================================================
|
|
30
34
|
|
|
31
35
|
interface MemoryRelayConfig {
|
|
32
|
-
apiKey
|
|
33
|
-
agentId
|
|
36
|
+
apiKey?: string; // Optional now (can use env var)
|
|
37
|
+
agentId?: string; // Optional now (can use env var)
|
|
34
38
|
apiUrl?: string;
|
|
35
39
|
autoCapture?: boolean;
|
|
36
40
|
autoRecall?: boolean;
|
|
37
41
|
recallLimit?: number;
|
|
38
42
|
recallThreshold?: number;
|
|
39
|
-
//
|
|
40
|
-
circuitBreaker?: {
|
|
41
|
-
enabled?: boolean;
|
|
42
|
-
maxFailures?: number;
|
|
43
|
-
resetTimeoutMs?: number;
|
|
44
|
-
};
|
|
45
|
-
retry?: {
|
|
46
|
-
enabled?: boolean;
|
|
47
|
-
maxRetries?: number;
|
|
48
|
-
baseDelayMs?: number;
|
|
49
|
-
};
|
|
50
|
-
entityExtraction?: {
|
|
51
|
-
enabled?: boolean;
|
|
52
|
-
};
|
|
53
|
-
queryPreprocessing?: {
|
|
54
|
-
enabled?: boolean;
|
|
55
|
-
};
|
|
43
|
+
excludeChannels?: string[]; // NEW: Channels to skip auto-recall
|
|
56
44
|
}
|
|
57
45
|
|
|
58
46
|
interface Memory {
|
|
@@ -71,256 +59,135 @@ interface SearchResult {
|
|
|
71
59
|
score: number;
|
|
72
60
|
}
|
|
73
61
|
|
|
74
|
-
interface
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
enum ErrorType {
|
|
80
|
-
AUTH = "auth_error",
|
|
81
|
-
RATE_LIMIT = "rate_limit",
|
|
82
|
-
SERVER = "server_error",
|
|
83
|
-
NETWORK = "network_error",
|
|
84
|
-
VALIDATION = "validation_error",
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// ============================================================================
|
|
88
|
-
// Circuit Breaker
|
|
89
|
-
// ============================================================================
|
|
90
|
-
|
|
91
|
-
class CircuitBreaker {
|
|
92
|
-
private consecutiveFailures = 0;
|
|
93
|
-
private openUntil: number | null = null;
|
|
94
|
-
|
|
95
|
-
constructor(
|
|
96
|
-
private readonly maxFailures: number = 3,
|
|
97
|
-
private readonly resetTimeoutMs: number = 60000,
|
|
98
|
-
) {}
|
|
99
|
-
|
|
100
|
-
isOpen(): boolean {
|
|
101
|
-
if (this.openUntil && Date.now() < this.openUntil) {
|
|
102
|
-
return true; // Circuit still open
|
|
103
|
-
}
|
|
104
|
-
if (this.openUntil && Date.now() >= this.openUntil) {
|
|
105
|
-
this.reset(); // Auto-close after timeout
|
|
106
|
-
}
|
|
107
|
-
return false;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
recordSuccess(): void {
|
|
111
|
-
this.consecutiveFailures = 0;
|
|
112
|
-
this.openUntil = null;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
recordFailure(): void {
|
|
116
|
-
this.consecutiveFailures++;
|
|
117
|
-
if (this.consecutiveFailures >= this.maxFailures) {
|
|
118
|
-
this.openUntil = Date.now() + this.resetTimeoutMs;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
reset(): void {
|
|
123
|
-
this.consecutiveFailures = 0;
|
|
124
|
-
this.openUntil = null;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
getState(): { open: boolean; failures: number; opensAt?: number } {
|
|
128
|
-
return {
|
|
129
|
-
open: this.isOpen(),
|
|
130
|
-
failures: this.consecutiveFailures,
|
|
131
|
-
opensAt: this.openUntil || undefined,
|
|
132
|
-
};
|
|
133
|
-
}
|
|
62
|
+
interface Stats {
|
|
63
|
+
total_memories: number;
|
|
64
|
+
last_updated?: string;
|
|
134
65
|
}
|
|
135
66
|
|
|
136
67
|
// ============================================================================
|
|
137
68
|
// Utility Functions
|
|
138
69
|
// ============================================================================
|
|
139
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Sleep for specified milliseconds
|
|
73
|
+
*/
|
|
140
74
|
function sleep(ms: number): Promise<void> {
|
|
141
75
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
142
76
|
}
|
|
143
77
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Check if error is retryable (network/timeout errors)
|
|
80
|
+
*/
|
|
81
|
+
function isRetryableError(error: unknown): boolean {
|
|
82
|
+
const errStr = String(error).toLowerCase();
|
|
83
|
+
return (
|
|
84
|
+
errStr.includes("timeout") ||
|
|
85
|
+
errStr.includes("econnrefused") ||
|
|
86
|
+
errStr.includes("enotfound") ||
|
|
87
|
+
errStr.includes("network") ||
|
|
88
|
+
errStr.includes("fetch failed") ||
|
|
89
|
+
errStr.includes("502") ||
|
|
90
|
+
errStr.includes("503") ||
|
|
91
|
+
errStr.includes("504")
|
|
92
|
+
);
|
|
156
93
|
}
|
|
157
94
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
// Email addresses
|
|
170
|
-
const emailPattern =
|
|
171
|
-
/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g;
|
|
172
|
-
while ((match = emailPattern.exec(text)) !== null) {
|
|
173
|
-
entities.push({ type: "email", value: match[0] });
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// URLs
|
|
177
|
-
const urlPattern = /https?:\/\/[^\s<>"{}|\\^`\[\]]+/g;
|
|
178
|
-
while ((match = urlPattern.exec(text)) !== null) {
|
|
179
|
-
entities.push({ type: "url", value: match[0] });
|
|
180
|
-
}
|
|
95
|
+
/**
|
|
96
|
+
* Fetch with timeout
|
|
97
|
+
*/
|
|
98
|
+
async function fetchWithTimeout(
|
|
99
|
+
url: string,
|
|
100
|
+
options: RequestInit,
|
|
101
|
+
timeoutMs: number,
|
|
102
|
+
): Promise<Response> {
|
|
103
|
+
const controller = new AbortController();
|
|
104
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
181
105
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
106
|
+
try {
|
|
107
|
+
const response = await fetch(url, {
|
|
108
|
+
...options,
|
|
109
|
+
signal: controller.signal,
|
|
110
|
+
});
|
|
111
|
+
clearTimeout(timeout);
|
|
112
|
+
return response;
|
|
113
|
+
} catch (err) {
|
|
114
|
+
clearTimeout(timeout);
|
|
115
|
+
if ((err as Error).name === "AbortError") {
|
|
116
|
+
throw new Error("Request timeout");
|
|
188
117
|
}
|
|
118
|
+
throw err;
|
|
189
119
|
}
|
|
190
|
-
|
|
191
|
-
return entities;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function preprocessQuery(query: string): string {
|
|
195
|
-
// Remove question words
|
|
196
|
-
let cleaned = query.replace(
|
|
197
|
-
/\b(what|how|when|where|why|who|which|whose|whom|is|are|was|were|do|does|did|can|could|should|would|will)\b/gi,
|
|
198
|
-
"",
|
|
199
|
-
);
|
|
200
|
-
|
|
201
|
-
// Remove punctuation
|
|
202
|
-
cleaned = cleaned.replace(/[?!.,;:'"()]/g, " ");
|
|
203
|
-
|
|
204
|
-
// Collapse multiple spaces
|
|
205
|
-
cleaned = cleaned.replace(/\s+/g, " ").trim();
|
|
206
|
-
|
|
207
|
-
return cleaned;
|
|
208
120
|
}
|
|
209
121
|
|
|
210
122
|
// ============================================================================
|
|
211
|
-
// MemoryRelay API Client
|
|
123
|
+
// MemoryRelay API Client (Enhanced)
|
|
212
124
|
// ============================================================================
|
|
213
125
|
|
|
214
126
|
class MemoryRelayClient {
|
|
215
|
-
private circuitBreaker: CircuitBreaker | null = null;
|
|
216
|
-
|
|
217
127
|
constructor(
|
|
218
128
|
private readonly apiKey: string,
|
|
219
129
|
private readonly agentId: string,
|
|
220
130
|
private readonly apiUrl: string = DEFAULT_API_URL,
|
|
221
|
-
|
|
222
|
-
enabled: boolean;
|
|
223
|
-
maxRetries: number;
|
|
224
|
-
baseDelayMs: number;
|
|
225
|
-
},
|
|
226
|
-
circuitBreakerConfig?: {
|
|
227
|
-
enabled: boolean;
|
|
228
|
-
maxFailures: number;
|
|
229
|
-
resetTimeoutMs: number;
|
|
230
|
-
},
|
|
231
|
-
) {
|
|
232
|
-
if (circuitBreakerConfig?.enabled) {
|
|
233
|
-
this.circuitBreaker = new CircuitBreaker(
|
|
234
|
-
circuitBreakerConfig.maxFailures,
|
|
235
|
-
circuitBreakerConfig.resetTimeoutMs,
|
|
236
|
-
);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
131
|
+
) {}
|
|
239
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Make HTTP request with retry logic and timeout
|
|
135
|
+
*/
|
|
240
136
|
private async request<T>(
|
|
241
137
|
method: string,
|
|
242
138
|
path: string,
|
|
243
139
|
body?: unknown,
|
|
140
|
+
retryCount = 0,
|
|
244
141
|
): Promise<T> {
|
|
245
142
|
const url = `${this.apiUrl}${path}`;
|
|
246
143
|
|
|
247
|
-
|
|
248
|
-
const response = await
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
144
|
+
try {
|
|
145
|
+
const response = await fetchWithTimeout(
|
|
146
|
+
url,
|
|
147
|
+
{
|
|
148
|
+
method,
|
|
149
|
+
headers: {
|
|
150
|
+
"Content-Type": "application/json",
|
|
151
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
152
|
+
"User-Agent": "openclaw-memory-memoryrelay/0.6.0",
|
|
153
|
+
},
|
|
154
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
254
155
|
},
|
|
255
|
-
|
|
256
|
-
|
|
156
|
+
REQUEST_TIMEOUT_MS,
|
|
157
|
+
);
|
|
257
158
|
|
|
258
159
|
if (!response.ok) {
|
|
259
160
|
const errorData = await response.json().catch(() => ({}));
|
|
260
|
-
|
|
161
|
+
const error = new Error(
|
|
261
162
|
`MemoryRelay API error: ${response.status} ${response.statusText}` +
|
|
262
163
|
(errorData.message ? ` - ${errorData.message}` : ""),
|
|
263
164
|
);
|
|
264
|
-
}
|
|
265
165
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
// Retry logic
|
|
270
|
-
if (this.retryConfig?.enabled) {
|
|
271
|
-
return this.requestWithRetry(doRequest);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
return doRequest();
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
private async requestWithRetry<T>(fn: () => Promise<T>): Promise<T> {
|
|
278
|
-
const maxRetries = this.retryConfig?.maxRetries || 3;
|
|
279
|
-
const baseDelayMs = this.retryConfig?.baseDelayMs || 1000;
|
|
280
|
-
let lastError: Error;
|
|
281
|
-
|
|
282
|
-
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
283
|
-
try {
|
|
284
|
-
const result = await fn();
|
|
285
|
-
this.circuitBreaker?.recordSuccess();
|
|
286
|
-
return result;
|
|
287
|
-
} catch (err: any) {
|
|
288
|
-
lastError = err;
|
|
289
|
-
const errorType = classifyError(err);
|
|
290
|
-
|
|
291
|
-
// Don't retry auth errors
|
|
292
|
-
if (errorType === ErrorType.AUTH) {
|
|
293
|
-
this.circuitBreaker?.recordFailure();
|
|
294
|
-
throw err;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// Record failure for circuit breaker
|
|
298
|
-
this.circuitBreaker?.recordFailure();
|
|
299
|
-
|
|
300
|
-
// Don't retry on last attempt
|
|
301
|
-
if (attempt < maxRetries) {
|
|
302
|
-
const delay = baseDelayMs * Math.pow(2, attempt);
|
|
166
|
+
// Retry on 5xx errors
|
|
167
|
+
if (response.status >= 500 && retryCount < MAX_RETRIES) {
|
|
168
|
+
const delay = INITIAL_RETRY_DELAY_MS * Math.pow(2, retryCount);
|
|
303
169
|
await sleep(delay);
|
|
170
|
+
return this.request<T>(method, path, body, retryCount + 1);
|
|
304
171
|
}
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
172
|
|
|
308
|
-
|
|
309
|
-
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
310
175
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
176
|
+
return response.json();
|
|
177
|
+
} catch (err) {
|
|
178
|
+
// Retry on network errors
|
|
179
|
+
if (isRetryableError(err) && retryCount < MAX_RETRIES) {
|
|
180
|
+
const delay = INITIAL_RETRY_DELAY_MS * Math.pow(2, retryCount);
|
|
181
|
+
await sleep(delay);
|
|
182
|
+
return this.request<T>(method, path, body, retryCount + 1);
|
|
183
|
+
}
|
|
314
184
|
|
|
315
|
-
|
|
316
|
-
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
317
187
|
}
|
|
318
188
|
|
|
319
|
-
async store(
|
|
320
|
-
|
|
321
|
-
metadata?: Record<string, string>,
|
|
322
|
-
): Promise<Memory> {
|
|
323
|
-
return this.request<Memory>("POST", "/v1/memories/memories", {
|
|
189
|
+
async store(content: string, metadata?: Record<string, string>): Promise<Memory> {
|
|
190
|
+
return this.request<Memory>("POST", "/v1/memories", {
|
|
324
191
|
content,
|
|
325
192
|
metadata,
|
|
326
193
|
agent_id: this.agentId,
|
|
@@ -365,15 +232,35 @@ class MemoryRelayClient {
|
|
|
365
232
|
return this.request<{ status: string }>("GET", "/v1/health");
|
|
366
233
|
}
|
|
367
234
|
|
|
368
|
-
async stats(): Promise<
|
|
369
|
-
const response = await this.request<{
|
|
370
|
-
|
|
371
|
-
|
|
235
|
+
async stats(): Promise<Stats> {
|
|
236
|
+
const response = await this.request<{ data: Stats }>(
|
|
237
|
+
"GET",
|
|
238
|
+
`/v1/stats?agent_id=${encodeURIComponent(this.agentId)}`,
|
|
239
|
+
);
|
|
372
240
|
return {
|
|
373
241
|
total_memories: response.data?.total_memories ?? 0,
|
|
374
242
|
last_updated: response.data?.last_updated,
|
|
375
243
|
};
|
|
376
244
|
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Export all memories as JSON
|
|
248
|
+
*/
|
|
249
|
+
async export(): Promise<Memory[]> {
|
|
250
|
+
const allMemories: Memory[] = [];
|
|
251
|
+
let offset = 0;
|
|
252
|
+
const limit = 100;
|
|
253
|
+
|
|
254
|
+
while (true) {
|
|
255
|
+
const batch = await this.list(limit, offset);
|
|
256
|
+
if (batch.length === 0) break;
|
|
257
|
+
allMemories.push(...batch);
|
|
258
|
+
offset += limit;
|
|
259
|
+
if (batch.length < limit) break; // Last page
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return allMemories;
|
|
263
|
+
}
|
|
377
264
|
}
|
|
378
265
|
|
|
379
266
|
// ============================================================================
|
|
@@ -390,23 +277,10 @@ const CAPTURE_PATTERNS = [
|
|
|
390
277
|
/(?:ssh|server|host|ip|port)(?:\s+is)?[:\s]/i,
|
|
391
278
|
];
|
|
392
279
|
|
|
393
|
-
function shouldCapture(
|
|
394
|
-
text: string,
|
|
395
|
-
entityExtractionEnabled: boolean = true,
|
|
396
|
-
): boolean {
|
|
280
|
+
function shouldCapture(text: string): boolean {
|
|
397
281
|
if (text.length < 20 || text.length > 2000) {
|
|
398
282
|
return false;
|
|
399
283
|
}
|
|
400
|
-
|
|
401
|
-
// Check for entities (if enabled)
|
|
402
|
-
if (entityExtractionEnabled) {
|
|
403
|
-
const entities = extractEntities(text);
|
|
404
|
-
if (entities.length > 0) {
|
|
405
|
-
return true; // Has structured data worth capturing
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
// Check original patterns
|
|
410
284
|
return CAPTURE_PATTERNS.some((pattern) => pattern.test(text));
|
|
411
285
|
}
|
|
412
286
|
|
|
@@ -417,82 +291,495 @@ function shouldCapture(
|
|
|
417
291
|
export default async function plugin(api: OpenClawPluginApi): Promise<void> {
|
|
418
292
|
const cfg = api.pluginConfig as MemoryRelayConfig | undefined;
|
|
419
293
|
|
|
420
|
-
|
|
294
|
+
// NEW: Fall back to environment variables
|
|
295
|
+
const apiKey = cfg?.apiKey || process.env.MEMORYRELAY_API_KEY;
|
|
296
|
+
const agentId = cfg?.agentId || process.env.MEMORYRELAY_AGENT_ID || api.agentName;
|
|
297
|
+
|
|
298
|
+
if (!apiKey) {
|
|
421
299
|
api.logger.error(
|
|
422
|
-
"memory-memoryrelay: Missing API key in config.\n\n" +
|
|
300
|
+
"memory-memoryrelay: Missing API key in config or MEMORYRELAY_API_KEY env var.\n\n" +
|
|
423
301
|
"REQUIRED: Add config after installation:\n\n" +
|
|
424
302
|
'cat ~/.openclaw/openclaw.json | jq \'.plugins.entries."plugin-memoryrelay-ai".config = {\n' +
|
|
425
303
|
' "apiKey": "YOUR_API_KEY",\n' +
|
|
426
304
|
' "agentId": "YOUR_AGENT_ID"\n' +
|
|
427
305
|
"}' > /tmp/config.json && mv /tmp/config.json ~/.openclaw/openclaw.json\n\n" +
|
|
306
|
+
"Or set environment variable:\n" +
|
|
307
|
+
'export MEMORYRELAY_API_KEY="mem_prod_..."\n\n' +
|
|
428
308
|
"Then restart: openclaw gateway restart\n\n" +
|
|
429
309
|
"Get your API key from: https://memoryrelay.ai",
|
|
430
310
|
);
|
|
431
311
|
return;
|
|
432
312
|
}
|
|
433
313
|
|
|
434
|
-
if (!
|
|
435
|
-
api.logger.error("memory-memoryrelay: Missing agentId in config");
|
|
314
|
+
if (!agentId) {
|
|
315
|
+
api.logger.error("memory-memoryrelay: Missing agentId in config or MEMORYRELAY_AGENT_ID env var");
|
|
436
316
|
return;
|
|
437
317
|
}
|
|
438
318
|
|
|
439
|
-
const apiUrl = cfg
|
|
440
|
-
|
|
441
|
-
// Circuit breaker config (default: enabled)
|
|
442
|
-
const circuitBreakerConfig = {
|
|
443
|
-
enabled: cfg.circuitBreaker?.enabled ?? true,
|
|
444
|
-
maxFailures: cfg.circuitBreaker?.maxFailures || 3,
|
|
445
|
-
resetTimeoutMs: cfg.circuitBreaker?.resetTimeoutMs || 60000,
|
|
446
|
-
};
|
|
447
|
-
|
|
448
|
-
// Retry config (default: enabled)
|
|
449
|
-
const retryConfig = {
|
|
450
|
-
enabled: cfg.retry?.enabled ?? true,
|
|
451
|
-
maxRetries: cfg.retry?.maxRetries || 3,
|
|
452
|
-
baseDelayMs: cfg.retry?.baseDelayMs || 1000,
|
|
453
|
-
};
|
|
454
|
-
|
|
455
|
-
// Entity extraction config (default: enabled)
|
|
456
|
-
const entityExtractionEnabled = cfg.entityExtraction?.enabled ?? true;
|
|
457
|
-
|
|
458
|
-
// Query preprocessing config (default: enabled)
|
|
459
|
-
const queryPreprocessingEnabled = cfg.queryPreprocessing?.enabled ?? true;
|
|
460
|
-
|
|
461
|
-
const client = new MemoryRelayClient(
|
|
462
|
-
cfg.apiKey,
|
|
463
|
-
cfg.agentId,
|
|
464
|
-
apiUrl,
|
|
465
|
-
retryConfig,
|
|
466
|
-
circuitBreakerConfig,
|
|
467
|
-
);
|
|
319
|
+
const apiUrl = cfg?.apiUrl || process.env.MEMORYRELAY_API_URL || DEFAULT_API_URL;
|
|
320
|
+
const client = new MemoryRelayClient(apiKey, agentId, apiUrl);
|
|
468
321
|
|
|
469
|
-
// Verify connection on startup
|
|
322
|
+
// Verify connection on startup (with timeout)
|
|
470
323
|
try {
|
|
471
324
|
await client.health();
|
|
472
|
-
api.logger.info(
|
|
473
|
-
`memory-memoryrelay: connected to ${apiUrl} (v0.6.0 - enhanced)`,
|
|
474
|
-
);
|
|
475
|
-
api.logger.info(
|
|
476
|
-
`memory-memoryrelay: circuit breaker=${circuitBreakerConfig.enabled}, retry=${retryConfig.enabled}, entity extraction=${entityExtractionEnabled}`,
|
|
477
|
-
);
|
|
325
|
+
api.logger.info(`memory-memoryrelay: connected to ${apiUrl}`);
|
|
478
326
|
} catch (err) {
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
327
|
+
api.logger.error(`memory-memoryrelay: health check failed: ${String(err)}`);
|
|
328
|
+
// Continue loading plugin even if health check fails (will retry on first use)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ========================================================================
|
|
332
|
+
// Status Reporting (for openclaw status command)
|
|
333
|
+
// ========================================================================
|
|
334
|
+
|
|
335
|
+
api.registerGatewayMethod?.("memory.status", async ({ respond }) => {
|
|
336
|
+
try {
|
|
337
|
+
const health = await client.health();
|
|
338
|
+
let memoryCount = 0;
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
const stats = await client.stats();
|
|
342
|
+
memoryCount = stats.total_memories;
|
|
343
|
+
} catch (statsErr) {
|
|
344
|
+
api.logger.debug?.(`memory-memoryrelay: stats endpoint unavailable: ${String(statsErr)}`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const healthStatus = String(health.status).toLowerCase();
|
|
348
|
+
const isConnected = VALID_HEALTH_STATUSES.includes(healthStatus);
|
|
349
|
+
|
|
350
|
+
respond(true, {
|
|
351
|
+
available: true,
|
|
352
|
+
connected: isConnected,
|
|
353
|
+
endpoint: apiUrl,
|
|
354
|
+
memoryCount: memoryCount,
|
|
355
|
+
agentId: agentId,
|
|
356
|
+
vector: {
|
|
357
|
+
available: true,
|
|
358
|
+
enabled: true,
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
} catch (err) {
|
|
362
|
+
respond(true, {
|
|
363
|
+
available: false,
|
|
364
|
+
connected: false,
|
|
365
|
+
error: String(err),
|
|
366
|
+
endpoint: apiUrl,
|
|
367
|
+
agentId: agentId,
|
|
368
|
+
vector: {
|
|
369
|
+
available: false,
|
|
370
|
+
enabled: true,
|
|
371
|
+
},
|
|
372
|
+
});
|
|
487
373
|
}
|
|
488
|
-
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// ========================================================================
|
|
377
|
+
// Tools (using JSON Schema directly)
|
|
378
|
+
// ========================================================================
|
|
379
|
+
|
|
380
|
+
// memory_store tool
|
|
381
|
+
api.registerTool(
|
|
382
|
+
{
|
|
383
|
+
name: "memory_store",
|
|
384
|
+
description:
|
|
385
|
+
"Store a new memory in MemoryRelay. Use this to save important information, facts, preferences, or context that should be remembered for future conversations.",
|
|
386
|
+
parameters: {
|
|
387
|
+
type: "object",
|
|
388
|
+
properties: {
|
|
389
|
+
content: {
|
|
390
|
+
type: "string",
|
|
391
|
+
description: "The memory content to store. Be specific and include relevant context.",
|
|
392
|
+
},
|
|
393
|
+
metadata: {
|
|
394
|
+
type: "object",
|
|
395
|
+
description: "Optional key-value metadata to attach to the memory",
|
|
396
|
+
additionalProperties: { type: "string" },
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
required: ["content"],
|
|
400
|
+
},
|
|
401
|
+
execute: async (_id, { content, metadata }: { content: string; metadata?: Record<string, string> }) => {
|
|
402
|
+
try {
|
|
403
|
+
const memory = await client.store(content, metadata);
|
|
404
|
+
return {
|
|
405
|
+
content: [
|
|
406
|
+
{
|
|
407
|
+
type: "text",
|
|
408
|
+
text: `Memory stored successfully (id: ${memory.id.slice(0, 8)}...)`,
|
|
409
|
+
},
|
|
410
|
+
],
|
|
411
|
+
details: { id: memory.id, stored: true },
|
|
412
|
+
};
|
|
413
|
+
} catch (err) {
|
|
414
|
+
return {
|
|
415
|
+
content: [{ type: "text", text: `Failed to store memory: ${String(err)}` }],
|
|
416
|
+
details: { error: String(err) },
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
{ name: "memory_store" },
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
// memory_recall tool (semantic search)
|
|
425
|
+
api.registerTool(
|
|
426
|
+
{
|
|
427
|
+
name: "memory_recall",
|
|
428
|
+
description:
|
|
429
|
+
"Search memories using natural language. Returns the most relevant memories based on semantic similarity.",
|
|
430
|
+
parameters: {
|
|
431
|
+
type: "object",
|
|
432
|
+
properties: {
|
|
433
|
+
query: {
|
|
434
|
+
type: "string",
|
|
435
|
+
description: "Natural language search query",
|
|
436
|
+
},
|
|
437
|
+
limit: {
|
|
438
|
+
type: "number",
|
|
439
|
+
description: "Maximum results (1-20)",
|
|
440
|
+
minimum: 1,
|
|
441
|
+
maximum: 20,
|
|
442
|
+
default: 5,
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
required: ["query"],
|
|
446
|
+
},
|
|
447
|
+
execute: async (_id, { query, limit = 5 }: { query: string; limit?: number }) => {
|
|
448
|
+
try {
|
|
449
|
+
const results = await client.search(query, limit, cfg?.recallThreshold || 0.3);
|
|
450
|
+
|
|
451
|
+
if (results.length === 0) {
|
|
452
|
+
return {
|
|
453
|
+
content: [{ type: "text", text: "No relevant memories found." }],
|
|
454
|
+
details: { count: 0 },
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const formatted = results
|
|
459
|
+
.map(
|
|
460
|
+
(r) =>
|
|
461
|
+
`- [${r.score.toFixed(2)}] ${r.memory.content.slice(0, 200)}${
|
|
462
|
+
r.memory.content.length > 200 ? "..." : ""
|
|
463
|
+
}`,
|
|
464
|
+
)
|
|
465
|
+
.join("\n");
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
content: [
|
|
469
|
+
{
|
|
470
|
+
type: "text",
|
|
471
|
+
text: `Found ${results.length} relevant memories:\n${formatted}`,
|
|
472
|
+
},
|
|
473
|
+
],
|
|
474
|
+
details: {
|
|
475
|
+
count: results.length,
|
|
476
|
+
memories: results.map((r) => ({
|
|
477
|
+
id: r.memory.id,
|
|
478
|
+
content: r.memory.content,
|
|
479
|
+
score: r.score,
|
|
480
|
+
})),
|
|
481
|
+
},
|
|
482
|
+
};
|
|
483
|
+
} catch (err) {
|
|
484
|
+
return {
|
|
485
|
+
content: [{ type: "text", text: `Search failed: ${String(err)}` }],
|
|
486
|
+
details: { error: String(err) },
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
{ name: "memory_recall" },
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
// memory_forget tool
|
|
495
|
+
api.registerTool(
|
|
496
|
+
{
|
|
497
|
+
name: "memory_forget",
|
|
498
|
+
description: "Delete a memory by ID or search for memories to forget.",
|
|
499
|
+
parameters: {
|
|
500
|
+
type: "object",
|
|
501
|
+
properties: {
|
|
502
|
+
memoryId: {
|
|
503
|
+
type: "string",
|
|
504
|
+
description: "Memory ID to delete",
|
|
505
|
+
},
|
|
506
|
+
query: {
|
|
507
|
+
type: "string",
|
|
508
|
+
description: "Search query to find memory",
|
|
509
|
+
},
|
|
510
|
+
},
|
|
511
|
+
},
|
|
512
|
+
execute: async (_id, { memoryId, query }: { memoryId?: string; query?: string }) => {
|
|
513
|
+
if (memoryId) {
|
|
514
|
+
try {
|
|
515
|
+
await client.delete(memoryId);
|
|
516
|
+
return {
|
|
517
|
+
content: [{ type: "text", text: `Memory ${memoryId.slice(0, 8)}... deleted.` }],
|
|
518
|
+
details: { action: "deleted", id: memoryId },
|
|
519
|
+
};
|
|
520
|
+
} catch (err) {
|
|
521
|
+
return {
|
|
522
|
+
content: [{ type: "text", text: `Delete failed: ${String(err)}` }],
|
|
523
|
+
details: { error: String(err) },
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (query) {
|
|
529
|
+
const results = await client.search(query, 5, 0.5);
|
|
530
|
+
|
|
531
|
+
if (results.length === 0) {
|
|
532
|
+
return {
|
|
533
|
+
content: [{ type: "text", text: "No matching memories found." }],
|
|
534
|
+
details: { count: 0 },
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// If single high-confidence match, delete it
|
|
539
|
+
if (results.length === 1 && results[0].score > 0.9) {
|
|
540
|
+
await client.delete(results[0].memory.id);
|
|
541
|
+
return {
|
|
542
|
+
content: [
|
|
543
|
+
{ type: "text", text: `Forgotten: "${results[0].memory.content.slice(0, 60)}..."` },
|
|
544
|
+
],
|
|
545
|
+
details: { action: "deleted", id: results[0].memory.id },
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const list = results
|
|
550
|
+
.map((r) => `- [${r.memory.id.slice(0, 8)}] ${r.memory.content.slice(0, 60)}...`)
|
|
551
|
+
.join("\n");
|
|
552
|
+
|
|
553
|
+
return {
|
|
554
|
+
content: [
|
|
555
|
+
{
|
|
556
|
+
type: "text",
|
|
557
|
+
text: `Found ${results.length} candidates. Specify memoryId:\n${list}`,
|
|
558
|
+
},
|
|
559
|
+
],
|
|
560
|
+
details: { action: "candidates", count: results.length },
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return {
|
|
565
|
+
content: [{ type: "text", text: "Provide query or memoryId." }],
|
|
566
|
+
details: { error: "missing_param" },
|
|
567
|
+
};
|
|
568
|
+
},
|
|
569
|
+
},
|
|
570
|
+
{ name: "memory_forget" },
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
// ========================================================================
|
|
574
|
+
// CLI Commands (Enhanced)
|
|
575
|
+
// ========================================================================
|
|
576
|
+
|
|
577
|
+
api.registerCli(
|
|
578
|
+
({ program }) => {
|
|
579
|
+
const mem = program.command("memoryrelay").description("MemoryRelay memory plugin commands");
|
|
580
|
+
|
|
581
|
+
mem
|
|
582
|
+
.command("status")
|
|
583
|
+
.description("Check MemoryRelay connection status")
|
|
584
|
+
.action(async () => {
|
|
585
|
+
try {
|
|
586
|
+
const health = await client.health();
|
|
587
|
+
const stats = await client.stats();
|
|
588
|
+
console.log(`Status: ${health.status}`);
|
|
589
|
+
console.log(`Agent ID: ${agentId}`);
|
|
590
|
+
console.log(`API: ${apiUrl}`);
|
|
591
|
+
console.log(`Total Memories: ${stats.total_memories}`);
|
|
592
|
+
if (stats.last_updated) {
|
|
593
|
+
console.log(`Last Updated: ${new Date(stats.last_updated).toLocaleString()}`);
|
|
594
|
+
}
|
|
595
|
+
} catch (err) {
|
|
596
|
+
console.error(`Connection failed: ${String(err)}`);
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
mem
|
|
601
|
+
.command("stats")
|
|
602
|
+
.description("Show agent statistics")
|
|
603
|
+
.action(async () => {
|
|
604
|
+
try {
|
|
605
|
+
const stats = await client.stats();
|
|
606
|
+
console.log(`Total Memories: ${stats.total_memories}`);
|
|
607
|
+
if (stats.last_updated) {
|
|
608
|
+
console.log(`Last Updated: ${new Date(stats.last_updated).toLocaleString()}`);
|
|
609
|
+
}
|
|
610
|
+
} catch (err) {
|
|
611
|
+
console.error(`Failed to fetch stats: ${String(err)}`);
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
mem
|
|
616
|
+
.command("list")
|
|
617
|
+
.description("List recent memories")
|
|
618
|
+
.option("--limit <n>", "Max results", "10")
|
|
619
|
+
.action(async (opts) => {
|
|
620
|
+
try {
|
|
621
|
+
const memories = await client.list(parseInt(opts.limit));
|
|
622
|
+
for (const m of memories) {
|
|
623
|
+
console.log(`[${m.id.slice(0, 8)}] ${m.content.slice(0, 80)}...`);
|
|
624
|
+
}
|
|
625
|
+
console.log(`\nTotal: ${memories.length} memories`);
|
|
626
|
+
} catch (err) {
|
|
627
|
+
console.error(`Failed to list memories: ${String(err)}`);
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
mem
|
|
632
|
+
.command("search")
|
|
633
|
+
.description("Search memories")
|
|
634
|
+
.argument("<query>", "Search query")
|
|
635
|
+
.option("--limit <n>", "Max results", "5")
|
|
636
|
+
.action(async (query, opts) => {
|
|
637
|
+
try {
|
|
638
|
+
const results = await client.search(query, parseInt(opts.limit));
|
|
639
|
+
for (const r of results) {
|
|
640
|
+
console.log(`[${r.score.toFixed(2)}] ${r.memory.content.slice(0, 80)}...`);
|
|
641
|
+
}
|
|
642
|
+
} catch (err) {
|
|
643
|
+
console.error(`Search failed: ${String(err)}`);
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
mem
|
|
648
|
+
.command("delete")
|
|
649
|
+
.description("Delete a memory by ID")
|
|
650
|
+
.argument("<id>", "Memory ID")
|
|
651
|
+
.action(async (id) => {
|
|
652
|
+
try {
|
|
653
|
+
await client.delete(id);
|
|
654
|
+
console.log(`Memory ${id.slice(0, 8)}... deleted.`);
|
|
655
|
+
} catch (err) {
|
|
656
|
+
console.error(`Delete failed: ${String(err)}`);
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
mem
|
|
661
|
+
.command("export")
|
|
662
|
+
.description("Export all memories to JSON file")
|
|
663
|
+
.option("--output <path>", "Output file path", "memories-export.json")
|
|
664
|
+
.action(async (opts) => {
|
|
665
|
+
try {
|
|
666
|
+
console.log("Exporting memories...");
|
|
667
|
+
const memories = await client.export();
|
|
668
|
+
const fs = await import("fs/promises");
|
|
669
|
+
await fs.writeFile(opts.output, JSON.stringify(memories, null, 2));
|
|
670
|
+
console.log(`Exported ${memories.length} memories to ${opts.output}`);
|
|
671
|
+
} catch (err) {
|
|
672
|
+
console.error(`Export failed: ${String(err)}`);
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
},
|
|
676
|
+
{ commands: ["memoryrelay"] },
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
// ========================================================================
|
|
680
|
+
// Lifecycle Hooks
|
|
681
|
+
// ========================================================================
|
|
682
|
+
|
|
683
|
+
// Auto-recall: inject relevant memories before agent starts
|
|
684
|
+
if (cfg?.autoRecall) {
|
|
685
|
+
api.on("before_agent_start", async (event) => {
|
|
686
|
+
if (!event.prompt || event.prompt.length < 10) {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// NEW: Check if current channel is excluded
|
|
691
|
+
if (cfg.excludeChannels && event.channel) {
|
|
692
|
+
const channelId = String(event.channel);
|
|
693
|
+
if (cfg.excludeChannels.some((excluded) => channelId.includes(excluded))) {
|
|
694
|
+
api.logger.debug?.(
|
|
695
|
+
`memory-memoryrelay: skipping auto-recall for excluded channel: ${channelId}`,
|
|
696
|
+
);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
try {
|
|
702
|
+
const results = await client.search(
|
|
703
|
+
event.prompt,
|
|
704
|
+
cfg.recallLimit || 5,
|
|
705
|
+
cfg.recallThreshold || 0.3,
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
if (results.length === 0) {
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const memoryContext = results.map((r) => `- ${r.memory.content}`).join("\n");
|
|
713
|
+
|
|
714
|
+
api.logger.info?.(
|
|
715
|
+
`memory-memoryrelay: injecting ${results.length} memories into context`,
|
|
716
|
+
);
|
|
717
|
+
|
|
718
|
+
return {
|
|
719
|
+
prependContext: `<relevant-memories>\nThe following memories from MemoryRelay may be relevant:\n${memoryContext}\n</relevant-memories>`,
|
|
720
|
+
};
|
|
721
|
+
} catch (err) {
|
|
722
|
+
api.logger.warn?.(`memory-memoryrelay: recall failed: ${String(err)}`);
|
|
723
|
+
}
|
|
724
|
+
});
|
|
489
725
|
}
|
|
490
726
|
|
|
491
|
-
//
|
|
492
|
-
|
|
493
|
-
|
|
727
|
+
// Auto-capture: analyze and store important information after agent ends
|
|
728
|
+
if (cfg?.autoCapture) {
|
|
729
|
+
api.on("agent_end", async (event) => {
|
|
730
|
+
if (!event.success || !event.messages || event.messages.length === 0) {
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
try {
|
|
735
|
+
const texts: string[] = [];
|
|
736
|
+
for (const msg of event.messages) {
|
|
737
|
+
if (!msg || typeof msg !== "object") continue;
|
|
738
|
+
const msgObj = msg as Record<string, unknown>;
|
|
739
|
+
const role = msgObj.role;
|
|
740
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
741
|
+
|
|
742
|
+
const content = msgObj.content;
|
|
743
|
+
if (typeof content === "string") {
|
|
744
|
+
texts.push(content);
|
|
745
|
+
} else if (Array.isArray(content)) {
|
|
746
|
+
for (const block of content) {
|
|
747
|
+
if (
|
|
748
|
+
block &&
|
|
749
|
+
typeof block === "object" &&
|
|
750
|
+
"type" in block &&
|
|
751
|
+
(block as Record<string, unknown>).type === "text" &&
|
|
752
|
+
"text" in block
|
|
753
|
+
) {
|
|
754
|
+
texts.push((block as Record<string, unknown>).text as string);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const toCapture = texts.filter((text) => text && shouldCapture(text));
|
|
761
|
+
if (toCapture.length === 0) return;
|
|
762
|
+
|
|
763
|
+
let stored = 0;
|
|
764
|
+
for (const text of toCapture.slice(0, 3)) {
|
|
765
|
+
// Check for duplicates via search
|
|
766
|
+
const existing = await client.search(text, 1, 0.95);
|
|
767
|
+
if (existing.length > 0) continue;
|
|
768
|
+
|
|
769
|
+
await client.store(text, { source: "auto-capture" });
|
|
770
|
+
stored++;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (stored > 0) {
|
|
774
|
+
api.logger.info?.(`memory-memoryrelay: auto-captured ${stored} memories`);
|
|
775
|
+
}
|
|
776
|
+
} catch (err) {
|
|
777
|
+
api.logger.warn?.(`memory-memoryrelay: capture failed: ${String(err)}`);
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
}
|
|
494
781
|
|
|
495
782
|
api.logger.info?.(
|
|
496
|
-
`memory-memoryrelay: plugin loaded (autoRecall: ${cfg
|
|
783
|
+
`memory-memoryrelay: plugin loaded (autoRecall: ${cfg?.autoRecall}, autoCapture: ${cfg?.autoCapture})`,
|
|
497
784
|
);
|
|
498
785
|
}
|