@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/index.ts CHANGED
@@ -1,18 +1,19 @@
1
1
  /**
2
- * OpenClaw Memory Plugin - MemoryRelay v0.6.0
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: string;
33
- agentId: string;
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
- // New in v0.6.0
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 Entity {
75
- type: string;
76
- value: string;
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
- function classifyError(err: any): ErrorType {
145
- const msg = String(err.message || err);
146
-
147
- if (msg.includes("401") || msg.includes("403")) return ErrorType.AUTH;
148
- if (msg.includes("429")) return ErrorType.RATE_LIMIT;
149
- if (msg.includes("500") || msg.includes("502") || msg.includes("503"))
150
- return ErrorType.SERVER;
151
- if (msg.includes("ECONNREFUSED") || msg.includes("timeout"))
152
- return ErrorType.NETWORK;
153
- if (msg.includes("400")) return ErrorType.VALIDATION;
154
-
155
- return ErrorType.SERVER; // Default
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
- function extractEntities(text: string): Entity[] {
159
- const entities: Entity[] = [];
160
-
161
- // API keys (common patterns)
162
- const apiKeyPattern =
163
- /\b(?:mem|nr|sk|pk|api)_(?:prod|test|dev|live)_[a-zA-Z0-9]{16,64}\b/gi;
164
- let match;
165
- while ((match = apiKeyPattern.exec(text)) !== null) {
166
- entities.push({ type: "api_key", value: match[0] });
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
- // IP addresses (with validation)
183
- const ipPattern = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g;
184
- while ((match = ipPattern.exec(text)) !== null) {
185
- const octets = match[0].split(".").map(Number);
186
- if (octets.every((n) => n >= 0 && n <= 255)) {
187
- entities.push({ type: "ip_address", value: match[0] });
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 with Retry
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
- private readonly retryConfig?: {
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
- const doRequest = async (): Promise<T> => {
248
- const response = await fetch(url, {
249
- method,
250
- headers: {
251
- "Content-Type": "application/json",
252
- Authorization: `Bearer ${this.apiKey}`,
253
- "User-Agent": "openclaw-memory-memoryrelay/0.6.0",
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
- body: body ? JSON.stringify(body) : undefined,
256
- });
156
+ REQUEST_TIMEOUT_MS,
157
+ );
257
158
 
258
159
  if (!response.ok) {
259
160
  const errorData = await response.json().catch(() => ({}));
260
- throw new Error(
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
- return response.json();
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
- throw lastError!;
309
- }
173
+ throw error;
174
+ }
310
175
 
311
- isCircuitOpen(): boolean {
312
- return this.circuitBreaker?.isOpen() || false;
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
- getCircuitState() {
316
- return this.circuitBreaker?.getState();
185
+ throw err;
186
+ }
317
187
  }
318
188
 
319
- async store(
320
- content: string,
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<{ total_memories: number; last_updated?: string }> {
369
- const response = await this.request<{
370
- data: { total_memories: number; last_updated?: string };
371
- }>("GET", `/v1/stats?agent_id=${encodeURIComponent(this.agentId)}`);
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
- if (!cfg?.apiKey) {
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 (!cfg.agentId) {
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.apiUrl || DEFAULT_API_URL;
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
- const errorType = classifyError(err);
480
- api.logger.error(
481
- `memory-memoryrelay: health check failed (${errorType}): ${String(err)}`,
482
- );
483
- if (errorType === ErrorType.AUTH) {
484
- api.logger.error(
485
- "memory-memoryrelay: Check your API key configuration",
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
- return;
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
- // ... (rest of the plugin implementation continues - tools, CLI, hooks)
492
- // For brevity, the full implementation would follow here
493
- // This PR focuses on the core improvements shown above
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.autoRecall}, autoCapture: ${cfg.autoCapture})`,
783
+ `memory-memoryrelay: plugin loaded (autoRecall: ${cfg?.autoRecall}, autoCapture: ${cfg?.autoCapture})`,
497
784
  );
498
785
  }