@memoryrelay/plugin-memoryrelay-ai 0.6.0 → 0.7.0

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,23 @@
1
1
  /**
2
- * OpenClaw Memory Plugin - MemoryRelay v0.6.0
2
+ * OpenClaw Memory Plugin - MemoryRelay
3
+ * Version: 0.7.0 (Full Suite)
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
- * 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
7
+ * Includes: memories, entities, agents, sessions, decisions, patterns, projects.
13
8
  *
14
9
  * API: https://api.memoryrelay.net
15
- * Docs: https://memoryrelay.io
10
+ * Docs: https://memoryrelay.ai
11
+ *
12
+ * ENHANCEMENTS (v0.7.0):
13
+ * - 39 tools covering all MemoryRelay API resources
14
+ * - Session tracking, decision logging, pattern management, project context
15
+ * - Agent workflow instructions injected via before_agent_start
16
+ * - Retry logic with exponential backoff (3 attempts)
17
+ * - Request timeout (30 seconds)
18
+ * - Environment variable fallback support
19
+ * - Channel filtering (excludeChannels config)
20
+ * - Additional CLI commands (stats, delete, export)
16
21
  */
17
22
 
18
23
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
@@ -23,36 +28,25 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
23
28
 
24
29
  const DEFAULT_API_URL = "https://api.memoryrelay.net";
25
30
  const VALID_HEALTH_STATUSES = ["ok", "healthy", "up"];
31
+ const REQUEST_TIMEOUT_MS = 30000; // 30 seconds
32
+ const MAX_RETRIES = 3;
33
+ const INITIAL_RETRY_DELAY_MS = 1000; // 1 second
26
34
 
27
35
  // ============================================================================
28
36
  // Types
29
37
  // ============================================================================
30
38
 
31
39
  interface MemoryRelayConfig {
32
- apiKey: string;
33
- agentId: string;
40
+ apiKey?: string;
41
+ agentId?: string;
34
42
  apiUrl?: string;
35
43
  autoCapture?: boolean;
36
44
  autoRecall?: boolean;
37
45
  recallLimit?: number;
38
46
  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
- };
47
+ excludeChannels?: string[];
48
+ defaultProject?: string;
49
+ enabledTools?: string;
56
50
  }
57
51
 
58
52
  interface Memory {
@@ -71,259 +65,154 @@ interface SearchResult {
71
65
  score: number;
72
66
  }
73
67
 
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
- }
68
+ interface Stats {
69
+ total_memories: number;
70
+ last_updated?: string;
134
71
  }
135
72
 
136
73
  // ============================================================================
137
74
  // Utility Functions
138
75
  // ============================================================================
139
76
 
77
+ /**
78
+ * Sleep for specified milliseconds
79
+ */
140
80
  function sleep(ms: number): Promise<void> {
141
81
  return new Promise((resolve) => setTimeout(resolve, ms));
142
82
  }
143
83
 
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
84
+ /**
85
+ * Check if error is retryable (network/timeout errors)
86
+ */
87
+ function isRetryableError(error: unknown): boolean {
88
+ const errStr = String(error).toLowerCase();
89
+ return (
90
+ errStr.includes("timeout") ||
91
+ errStr.includes("econnrefused") ||
92
+ errStr.includes("enotfound") ||
93
+ errStr.includes("network") ||
94
+ errStr.includes("fetch failed") ||
95
+ errStr.includes("502") ||
96
+ errStr.includes("503") ||
97
+ errStr.includes("504")
98
+ );
156
99
  }
157
100
 
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
- }
101
+ /**
102
+ * Fetch with timeout
103
+ */
104
+ async function fetchWithTimeout(
105
+ url: string,
106
+ options: RequestInit,
107
+ timeoutMs: number,
108
+ ): Promise<Response> {
109
+ const controller = new AbortController();
110
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
181
111
 
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] });
112
+ try {
113
+ const response = await fetch(url, {
114
+ ...options,
115
+ signal: controller.signal,
116
+ });
117
+ clearTimeout(timeout);
118
+ return response;
119
+ } catch (err) {
120
+ clearTimeout(timeout);
121
+ if ((err as Error).name === "AbortError") {
122
+ throw new Error("Request timeout");
188
123
  }
124
+ throw err;
189
125
  }
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
126
  }
209
127
 
210
128
  // ============================================================================
211
- // MemoryRelay API Client with Retry
129
+ // MemoryRelay API Client (Full Suite)
212
130
  // ============================================================================
213
131
 
214
132
  class MemoryRelayClient {
215
- private circuitBreaker: CircuitBreaker | null = null;
216
-
217
133
  constructor(
218
134
  private readonly apiKey: string,
219
135
  private readonly agentId: string,
220
136
  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
- }
137
+ ) {}
239
138
 
139
+ /**
140
+ * Make HTTP request with retry logic and timeout
141
+ */
240
142
  private async request<T>(
241
143
  method: string,
242
144
  path: string,
243
145
  body?: unknown,
146
+ retryCount = 0,
244
147
  ): Promise<T> {
245
148
  const url = `${this.apiUrl}${path}`;
246
149
 
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",
150
+ try {
151
+ const response = await fetchWithTimeout(
152
+ url,
153
+ {
154
+ method,
155
+ headers: {
156
+ "Content-Type": "application/json",
157
+ Authorization: `Bearer ${this.apiKey}`,
158
+ "User-Agent": "openclaw-memory-memoryrelay/0.7.0",
159
+ },
160
+ body: body ? JSON.stringify(body) : undefined,
254
161
  },
255
- body: body ? JSON.stringify(body) : undefined,
256
- });
162
+ REQUEST_TIMEOUT_MS,
163
+ );
257
164
 
258
165
  if (!response.ok) {
259
166
  const errorData = await response.json().catch(() => ({}));
260
- throw new Error(
167
+ const errorMsg = errorData.detail || errorData.message || "";
168
+ const error = new Error(
261
169
  `MemoryRelay API error: ${response.status} ${response.statusText}` +
262
- (errorData.message ? ` - ${errorData.message}` : ""),
170
+ (errorMsg ? ` - ${errorMsg}` : ""),
263
171
  );
264
- }
265
-
266
- return response.json();
267
- };
268
-
269
- // Retry logic
270
- if (this.retryConfig?.enabled) {
271
- return this.requestWithRetry(doRequest);
272
- }
273
172
 
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);
173
+ // Retry on 5xx errors
174
+ if (response.status >= 500 && retryCount < MAX_RETRIES) {
175
+ const delay = INITIAL_RETRY_DELAY_MS * Math.pow(2, retryCount);
303
176
  await sleep(delay);
177
+ return this.request<T>(method, path, body, retryCount + 1);
304
178
  }
179
+
180
+ throw error;
305
181
  }
306
- }
307
182
 
308
- throw lastError!;
309
- }
183
+ return response.json();
184
+ } catch (err) {
185
+ // Retry on network errors
186
+ if (isRetryableError(err) && retryCount < MAX_RETRIES) {
187
+ const delay = INITIAL_RETRY_DELAY_MS * Math.pow(2, retryCount);
188
+ await sleep(delay);
189
+ return this.request<T>(method, path, body, retryCount + 1);
190
+ }
310
191
 
311
- isCircuitOpen(): boolean {
312
- return this.circuitBreaker?.isOpen() || false;
192
+ throw err;
193
+ }
313
194
  }
314
195
 
315
- getCircuitState() {
316
- return this.circuitBreaker?.getState();
317
- }
196
+ // --------------------------------------------------------------------------
197
+ // Memory operations
198
+ // --------------------------------------------------------------------------
318
199
 
319
200
  async store(
320
201
  content: string,
321
202
  metadata?: Record<string, string>,
203
+ options?: {
204
+ deduplicate?: boolean;
205
+ dedup_threshold?: number;
206
+ project?: string;
207
+ importance?: number;
208
+ tier?: string;
209
+ },
322
210
  ): Promise<Memory> {
323
- return this.request<Memory>("POST", "/v1/memories/memories", {
211
+ return this.request<Memory>("POST", "/v1/memories", {
324
212
  content,
325
213
  metadata,
326
214
  agent_id: this.agentId,
215
+ ...options,
327
216
  });
328
217
  }
329
218
 
@@ -331,6 +220,15 @@ class MemoryRelayClient {
331
220
  query: string,
332
221
  limit: number = 5,
333
222
  threshold: number = 0.3,
223
+ options?: {
224
+ include_confidential?: boolean;
225
+ include_archived?: boolean;
226
+ compress?: boolean;
227
+ max_context_tokens?: number;
228
+ project?: string;
229
+ tier?: string;
230
+ min_importance?: number;
231
+ },
334
232
  ): Promise<SearchResult[]> {
335
233
  const response = await this.request<{ data: SearchResult[] }>(
336
234
  "POST",
@@ -340,6 +238,7 @@ class MemoryRelayClient {
340
238
  limit,
341
239
  threshold,
342
240
  agent_id: this.agentId,
241
+ ...options,
343
242
  },
344
243
  );
345
244
  return response.data || [];
@@ -348,7 +247,7 @@ class MemoryRelayClient {
348
247
  async list(limit: number = 20, offset: number = 0): Promise<Memory[]> {
349
248
  const response = await this.request<{ data: Memory[] }>(
350
249
  "GET",
351
- `/v1/memories/memories?limit=${limit}&offset=${offset}`,
250
+ `/v1/memories?limit=${limit}&offset=${offset}&agent_id=${encodeURIComponent(this.agentId)}`,
352
251
  );
353
252
  return response.data || [];
354
253
  }
@@ -357,23 +256,387 @@ class MemoryRelayClient {
357
256
  return this.request<Memory>("GET", `/v1/memories/${id}`);
358
257
  }
359
258
 
259
+ async update(id: string, content: string, metadata?: Record<string, string>): Promise<Memory> {
260
+ return this.request<Memory>("PUT", `/v1/memories/${id}`, {
261
+ content,
262
+ metadata,
263
+ });
264
+ }
265
+
360
266
  async delete(id: string): Promise<void> {
361
267
  await this.request<void>("DELETE", `/v1/memories/${id}`);
362
268
  }
363
269
 
270
+ async batchStore(
271
+ memories: Array<{ content: string; metadata?: Record<string, string> }>,
272
+ ): Promise<any> {
273
+ return this.request("POST", "/v1/memories/batch", {
274
+ memories,
275
+ agent_id: this.agentId,
276
+ });
277
+ }
278
+
279
+ async buildContext(
280
+ query: string,
281
+ limit?: number,
282
+ threshold?: number,
283
+ maxTokens?: number,
284
+ project?: string,
285
+ ): Promise<any> {
286
+ return this.request("POST", "/v1/memories/context", {
287
+ query,
288
+ limit,
289
+ threshold,
290
+ max_tokens: maxTokens,
291
+ agent_id: this.agentId,
292
+ project,
293
+ });
294
+ }
295
+
296
+ async promote(memoryId: string, importance: number, tier?: string): Promise<any> {
297
+ return this.request("PUT", `/v1/memories/${memoryId}/importance`, {
298
+ importance,
299
+ tier,
300
+ });
301
+ }
302
+
303
+ // --------------------------------------------------------------------------
304
+ // Entity operations
305
+ // --------------------------------------------------------------------------
306
+
307
+ async createEntity(
308
+ name: string,
309
+ type: string,
310
+ metadata?: Record<string, string>,
311
+ ): Promise<any> {
312
+ return this.request("POST", "/v1/entities", {
313
+ name,
314
+ type,
315
+ metadata,
316
+ agent_id: this.agentId,
317
+ });
318
+ }
319
+
320
+ async linkEntity(
321
+ entityId: string,
322
+ memoryId: string,
323
+ relationship?: string,
324
+ ): Promise<any> {
325
+ return this.request("POST", `/v1/entities/links`, {
326
+ entity_id: entityId,
327
+ memory_id: memoryId,
328
+ relationship,
329
+ });
330
+ }
331
+
332
+ async listEntities(limit: number = 20, offset: number = 0): Promise<any> {
333
+ return this.request("GET", `/v1/entities?limit=${limit}&offset=${offset}`);
334
+ }
335
+
336
+ async entityGraph(
337
+ entityId: string,
338
+ depth: number = 2,
339
+ maxNeighbors: number = 10,
340
+ ): Promise<any> {
341
+ return this.request(
342
+ "GET",
343
+ `/v1/entities/${entityId}/neighborhood?depth=${depth}&max_neighbors=${maxNeighbors}`,
344
+ );
345
+ }
346
+
347
+ // --------------------------------------------------------------------------
348
+ // Agent operations
349
+ // --------------------------------------------------------------------------
350
+
351
+ async listAgents(limit: number = 20): Promise<any> {
352
+ return this.request("GET", `/v1/agents?limit=${limit}`);
353
+ }
354
+
355
+ async createAgent(name: string, description?: string): Promise<any> {
356
+ return this.request("POST", "/v1/agents", { name, description });
357
+ }
358
+
359
+ async getAgent(id: string): Promise<any> {
360
+ return this.request("GET", `/v1/agents/${id}`);
361
+ }
362
+
363
+ // --------------------------------------------------------------------------
364
+ // Session operations
365
+ // --------------------------------------------------------------------------
366
+
367
+ async startSession(
368
+ title?: string,
369
+ project?: string,
370
+ metadata?: Record<string, string>,
371
+ ): Promise<any> {
372
+ return this.request("POST", "/v1/sessions", {
373
+ title,
374
+ project,
375
+ metadata,
376
+ agent_id: this.agentId,
377
+ });
378
+ }
379
+
380
+ async endSession(id: string, summary?: string): Promise<any> {
381
+ return this.request("PUT", `/v1/sessions/${id}/end`, { summary });
382
+ }
383
+
384
+ async getSession(id: string): Promise<any> {
385
+ return this.request("GET", `/v1/sessions/${id}`);
386
+ }
387
+
388
+ async listSessions(
389
+ limit: number = 20,
390
+ project?: string,
391
+ status?: string,
392
+ ): Promise<any> {
393
+ let path = `/v1/sessions?limit=${limit}`;
394
+ if (project) path += `&project=${encodeURIComponent(project)}`;
395
+ if (status) path += `&status=${encodeURIComponent(status)}`;
396
+ return this.request("GET", path);
397
+ }
398
+
399
+ // --------------------------------------------------------------------------
400
+ // Decision operations
401
+ // --------------------------------------------------------------------------
402
+
403
+ async recordDecision(
404
+ title: string,
405
+ rationale: string,
406
+ alternatives?: string,
407
+ project?: string,
408
+ tags?: string[],
409
+ status?: string,
410
+ ): Promise<any> {
411
+ return this.request("POST", "/v1/decisions", {
412
+ title,
413
+ rationale,
414
+ alternatives,
415
+ project_slug: project,
416
+ tags,
417
+ status,
418
+ agent_id: this.agentId,
419
+ });
420
+ }
421
+
422
+ async listDecisions(
423
+ limit: number = 20,
424
+ project?: string,
425
+ status?: string,
426
+ tags?: string,
427
+ ): Promise<any> {
428
+ let path = `/v1/decisions?limit=${limit}`;
429
+ if (project) path += `&project=${encodeURIComponent(project)}`;
430
+ if (status) path += `&status=${encodeURIComponent(status)}`;
431
+ if (tags) path += `&tags=${encodeURIComponent(tags)}`;
432
+ return this.request("GET", path);
433
+ }
434
+
435
+ async supersedeDecision(
436
+ id: string,
437
+ title: string,
438
+ rationale: string,
439
+ alternatives?: string,
440
+ tags?: string[],
441
+ ): Promise<any> {
442
+ return this.request("POST", `/v1/decisions/${id}/supersede`, {
443
+ title,
444
+ rationale,
445
+ alternatives,
446
+ tags,
447
+ });
448
+ }
449
+
450
+ async checkDecisions(
451
+ query: string,
452
+ project?: string,
453
+ limit?: number,
454
+ threshold?: number,
455
+ includeSuperseded?: boolean,
456
+ ): Promise<any> {
457
+ const params = new URLSearchParams();
458
+ params.set("query", query);
459
+ if (project) params.set("project", project);
460
+ if (limit !== undefined) params.set("limit", String(limit));
461
+ if (threshold !== undefined) params.set("threshold", String(threshold));
462
+ if (includeSuperseded) params.set("include_superseded", "true");
463
+ return this.request("GET", `/v1/decisions/check?${params.toString()}`);
464
+ }
465
+
466
+ // --------------------------------------------------------------------------
467
+ // Pattern operations
468
+ // --------------------------------------------------------------------------
469
+
470
+ async createPattern(
471
+ title: string,
472
+ description: string,
473
+ category?: string,
474
+ exampleCode?: string,
475
+ scope?: string,
476
+ tags?: string[],
477
+ sourceProject?: string,
478
+ ): Promise<any> {
479
+ return this.request("POST", "/v1/patterns", {
480
+ title,
481
+ description,
482
+ category,
483
+ example_code: exampleCode,
484
+ scope,
485
+ tags,
486
+ source_project: sourceProject,
487
+ });
488
+ }
489
+
490
+ async searchPatterns(
491
+ query: string,
492
+ category?: string,
493
+ project?: string,
494
+ limit?: number,
495
+ threshold?: number,
496
+ ): Promise<any> {
497
+ const params = new URLSearchParams();
498
+ params.set("query", query);
499
+ if (category) params.set("category", category);
500
+ if (project) params.set("project", project);
501
+ if (limit !== undefined) params.set("limit", String(limit));
502
+ if (threshold !== undefined) params.set("threshold", String(threshold));
503
+ return this.request("GET", `/v1/patterns/search?${params.toString()}`);
504
+ }
505
+
506
+ async adoptPattern(id: string, project: string): Promise<any> {
507
+ return this.request("POST", `/v1/patterns/${id}/adopt`, { project });
508
+ }
509
+
510
+ async suggestPatterns(project: string, limit?: number): Promise<any> {
511
+ let path = `/v1/patterns/suggest?project=${encodeURIComponent(project)}`;
512
+ if (limit) path += `&limit=${limit}`;
513
+ return this.request("GET", path);
514
+ }
515
+
516
+ // --------------------------------------------------------------------------
517
+ // Project operations
518
+ // --------------------------------------------------------------------------
519
+
520
+ async registerProject(
521
+ slug: string,
522
+ name: string,
523
+ description?: string,
524
+ stack?: Record<string, unknown>,
525
+ repoUrl?: string,
526
+ ): Promise<any> {
527
+ return this.request("POST", "/v1/projects", {
528
+ slug,
529
+ name,
530
+ description,
531
+ stack,
532
+ repo_url: repoUrl,
533
+ });
534
+ }
535
+
536
+ async listProjects(limit: number = 20): Promise<any> {
537
+ return this.request("GET", `/v1/projects?limit=${limit}`);
538
+ }
539
+
540
+ async getProject(slug: string): Promise<any> {
541
+ return this.request("GET", `/v1/projects/${encodeURIComponent(slug)}`);
542
+ }
543
+
544
+ async addProjectRelationship(
545
+ from: string,
546
+ to: string,
547
+ type: string,
548
+ metadata?: Record<string, unknown>,
549
+ ): Promise<any> {
550
+ return this.request("POST", `/v1/projects/${encodeURIComponent(from)}/relationships`, {
551
+ target_project: to,
552
+ relationship_type: type,
553
+ metadata,
554
+ });
555
+ }
556
+
557
+ async getProjectDependencies(project: string): Promise<any> {
558
+ return this.request(
559
+ "GET",
560
+ `/v1/projects/${encodeURIComponent(project)}/dependencies`,
561
+ );
562
+ }
563
+
564
+ async getProjectDependents(project: string): Promise<any> {
565
+ return this.request(
566
+ "GET",
567
+ `/v1/projects/${encodeURIComponent(project)}/dependents`,
568
+ );
569
+ }
570
+
571
+ async getProjectRelated(project: string): Promise<any> {
572
+ return this.request(
573
+ "GET",
574
+ `/v1/projects/${encodeURIComponent(project)}/related`,
575
+ );
576
+ }
577
+
578
+ async projectImpact(project: string, changeDescription: string): Promise<any> {
579
+ return this.request(
580
+ "POST",
581
+ `/v1/projects/impact-analysis`,
582
+ { project, change_description: changeDescription },
583
+ );
584
+ }
585
+
586
+ async getSharedPatterns(projectA: string, projectB: string): Promise<any> {
587
+ const params = new URLSearchParams();
588
+ params.set("a", projectA);
589
+ params.set("b", projectB);
590
+ return this.request(
591
+ "GET",
592
+ `/v1/projects/shared-patterns?${params.toString()}`,
593
+ );
594
+ }
595
+
596
+ async getProjectContext(project: string): Promise<any> {
597
+ return this.request(
598
+ "GET",
599
+ `/v1/projects/${encodeURIComponent(project)}/context`,
600
+ );
601
+ }
602
+
603
+ // --------------------------------------------------------------------------
604
+ // Health & stats
605
+ // --------------------------------------------------------------------------
606
+
364
607
  async health(): Promise<{ status: string }> {
365
608
  return this.request<{ status: string }>("GET", "/v1/health");
366
609
  }
367
610
 
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)}`);
611
+ async stats(): Promise<Stats> {
612
+ const response = await this.request<{ data: Stats }>(
613
+ "GET",
614
+ `/v1/stats?agent_id=${encodeURIComponent(this.agentId)}`,
615
+ );
372
616
  return {
373
617
  total_memories: response.data?.total_memories ?? 0,
374
618
  last_updated: response.data?.last_updated,
375
619
  };
376
620
  }
621
+
622
+ /**
623
+ * Export all memories as JSON
624
+ */
625
+ async export(): Promise<Memory[]> {
626
+ const allMemories: Memory[] = [];
627
+ let offset = 0;
628
+ const limit = 100;
629
+
630
+ while (true) {
631
+ const batch = await this.list(limit, offset);
632
+ if (batch.length === 0) break;
633
+ allMemories.push(...batch);
634
+ offset += limit;
635
+ if (batch.length < limit) break;
636
+ }
637
+
638
+ return allMemories;
639
+ }
377
640
  }
378
641
 
379
642
  // ============================================================================
@@ -390,23 +653,10 @@ const CAPTURE_PATTERNS = [
390
653
  /(?:ssh|server|host|ip|port)(?:\s+is)?[:\s]/i,
391
654
  ];
392
655
 
393
- function shouldCapture(
394
- text: string,
395
- entityExtractionEnabled: boolean = true,
396
- ): boolean {
656
+ function shouldCapture(text: string): boolean {
397
657
  if (text.length < 20 || text.length > 2000) {
398
658
  return false;
399
659
  }
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
660
  return CAPTURE_PATTERNS.some((pattern) => pattern.test(text));
411
661
  }
412
662
 
@@ -417,82 +667,2509 @@ function shouldCapture(
417
667
  export default async function plugin(api: OpenClawPluginApi): Promise<void> {
418
668
  const cfg = api.pluginConfig as MemoryRelayConfig | undefined;
419
669
 
420
- if (!cfg?.apiKey) {
670
+ // Fall back to environment variables
671
+ const apiKey = cfg?.apiKey || process.env.MEMORYRELAY_API_KEY;
672
+ const agentId = cfg?.agentId || process.env.MEMORYRELAY_AGENT_ID || api.agentName;
673
+
674
+ if (!apiKey) {
421
675
  api.logger.error(
422
- "memory-memoryrelay: Missing API key in config.\n\n" +
676
+ "memory-memoryrelay: Missing API key in config or MEMORYRELAY_API_KEY env var.\n\n" +
423
677
  "REQUIRED: Add config after installation:\n\n" +
424
678
  'cat ~/.openclaw/openclaw.json | jq \'.plugins.entries."plugin-memoryrelay-ai".config = {\n' +
425
679
  ' "apiKey": "YOUR_API_KEY",\n' +
426
680
  ' "agentId": "YOUR_AGENT_ID"\n' +
427
681
  "}' > /tmp/config.json && mv /tmp/config.json ~/.openclaw/openclaw.json\n\n" +
682
+ "Or set environment variable:\n" +
683
+ 'export MEMORYRELAY_API_KEY="mem_prod_..."\n\n' +
428
684
  "Then restart: openclaw gateway restart\n\n" +
429
685
  "Get your API key from: https://memoryrelay.ai",
430
686
  );
431
687
  return;
432
688
  }
433
689
 
434
- if (!cfg.agentId) {
435
- api.logger.error("memory-memoryrelay: Missing agentId in config");
690
+ if (!agentId) {
691
+ api.logger.error("memory-memoryrelay: Missing agentId in config or MEMORYRELAY_AGENT_ID env var");
436
692
  return;
437
693
  }
438
694
 
439
- const apiUrl = cfg.apiUrl || DEFAULT_API_URL;
695
+ const apiUrl = cfg?.apiUrl || process.env.MEMORYRELAY_API_URL || DEFAULT_API_URL;
696
+ const defaultProject = cfg?.defaultProject || process.env.MEMORYRELAY_DEFAULT_PROJECT;
697
+ const client = new MemoryRelayClient(apiKey, agentId, apiUrl);
440
698
 
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
- };
699
+ // Verify connection on startup (with timeout)
700
+ try {
701
+ await client.health();
702
+ api.logger.info(`memory-memoryrelay: connected to ${apiUrl}`);
703
+ } catch (err) {
704
+ api.logger.error(`memory-memoryrelay: health check failed: ${String(err)}`);
705
+ // Continue loading plugin even if health check fails (will retry on first use)
706
+ }
707
+
708
+ // ========================================================================
709
+ // Status Reporting (for openclaw status command)
710
+ // ========================================================================
447
711
 
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,
712
+ api.registerGatewayMethod?.("memory.status", async ({ respond }) => {
713
+ try {
714
+ const health = await client.health();
715
+ let memoryCount = 0;
716
+
717
+ try {
718
+ const stats = await client.stats();
719
+ memoryCount = stats.total_memories;
720
+ } catch (statsErr) {
721
+ api.logger.debug?.(`memory-memoryrelay: stats endpoint unavailable: ${String(statsErr)}`);
722
+ }
723
+
724
+ const healthStatus = String(health.status).toLowerCase();
725
+ const isConnected = VALID_HEALTH_STATUSES.includes(healthStatus);
726
+
727
+ respond(true, {
728
+ available: true,
729
+ connected: isConnected,
730
+ endpoint: apiUrl,
731
+ memoryCount: memoryCount,
732
+ agentId: agentId,
733
+ vector: {
734
+ available: true,
735
+ enabled: true,
736
+ },
737
+ });
738
+ } catch (err) {
739
+ respond(true, {
740
+ available: false,
741
+ connected: false,
742
+ error: String(err),
743
+ endpoint: apiUrl,
744
+ agentId: agentId,
745
+ vector: {
746
+ available: false,
747
+ enabled: true,
748
+ },
749
+ });
750
+ }
751
+ });
752
+
753
+ // ========================================================================
754
+ // Helper to check if a tool is enabled (by group)
755
+ // ========================================================================
756
+
757
+ // Tool group mapping — matches MCP server's TOOL_GROUPS
758
+ const TOOL_GROUPS: Record<string, string[]> = {
759
+ memory: [
760
+ "memory_store", "memory_recall", "memory_forget", "memory_list",
761
+ "memory_get", "memory_update", "memory_batch_store", "memory_context",
762
+ "memory_promote",
763
+ ],
764
+ entity: ["entity_create", "entity_link", "entity_list", "entity_graph"],
765
+ agent: ["agent_list", "agent_create", "agent_get"],
766
+ session: ["session_start", "session_end", "session_recall", "session_list"],
767
+ decision: ["decision_record", "decision_list", "decision_supersede", "decision_check"],
768
+ pattern: ["pattern_create", "pattern_search", "pattern_adopt", "pattern_suggest"],
769
+ project: [
770
+ "project_register", "project_list", "project_info",
771
+ "project_add_relationship", "project_dependencies", "project_dependents",
772
+ "project_related", "project_impact", "project_shared_patterns", "project_context",
773
+ ],
774
+ health: ["memory_health"],
453
775
  };
454
776
 
455
- // Entity extraction config (default: enabled)
456
- const entityExtractionEnabled = cfg.entityExtraction?.enabled ?? true;
777
+ // Build a set of enabled tool names from group names
778
+ const enabledToolNames: Set<string> | null = (() => {
779
+ if (!cfg?.enabledTools) return null; // all enabled
780
+ const groups = cfg.enabledTools.split(",").map((s) => s.trim().toLowerCase());
781
+ if (groups.includes("all")) return null;
782
+ const enabled = new Set<string>();
783
+ for (const group of groups) {
784
+ const tools = TOOL_GROUPS[group];
785
+ if (tools) {
786
+ for (const tool of tools) {
787
+ enabled.add(tool);
788
+ }
789
+ }
790
+ }
791
+ return enabled;
792
+ })();
457
793
 
458
- // Query preprocessing config (default: enabled)
459
- const queryPreprocessingEnabled = cfg.queryPreprocessing?.enabled ?? true;
794
+ function isToolEnabled(name: string): boolean {
795
+ if (!enabledToolNames) return true;
796
+ return enabledToolNames.has(name);
797
+ }
460
798
 
461
- const client = new MemoryRelayClient(
462
- cfg.apiKey,
463
- cfg.agentId,
464
- apiUrl,
465
- retryConfig,
466
- circuitBreakerConfig,
467
- );
799
+ // ========================================================================
800
+ // Tools (39 total)
801
+ // ========================================================================
468
802
 
469
- // Verify connection on startup
470
- try {
471
- await client.health();
472
- api.logger.info(
473
- `memory-memoryrelay: connected to ${apiUrl} (v0.6.0 - enhanced)`,
803
+ // --------------------------------------------------------------------------
804
+ // 1. memory_store
805
+ // --------------------------------------------------------------------------
806
+ if (isToolEnabled("memory_store")) {
807
+ api.registerTool(
808
+ {
809
+ name: "memory_store",
810
+ description:
811
+ "Store a new memory in MemoryRelay. Use this to save important information, facts, preferences, or context that should be remembered for future conversations." +
812
+ (defaultProject ? ` Project defaults to '${defaultProject}' if not specified.` : "") +
813
+ " Set deduplicate=true to avoid storing near-duplicate memories.",
814
+ parameters: {
815
+ type: "object",
816
+ properties: {
817
+ content: {
818
+ type: "string",
819
+ description: "The memory content to store. Be specific and include relevant context.",
820
+ },
821
+ metadata: {
822
+ type: "object",
823
+ description: "Optional key-value metadata to attach to the memory",
824
+ additionalProperties: { type: "string" },
825
+ },
826
+ deduplicate: {
827
+ type: "boolean",
828
+ description: "If true, check for duplicate memories before storing. Default false.",
829
+ },
830
+ dedup_threshold: {
831
+ type: "number",
832
+ description: "Similarity threshold for deduplication (0-1). Default 0.9.",
833
+ },
834
+ project: {
835
+ type: "string",
836
+ description: "Project slug to associate with this memory.",
837
+ },
838
+ importance: {
839
+ type: "number",
840
+ description: "Importance score (0-1). Higher values are retained longer.",
841
+ },
842
+ tier: {
843
+ type: "string",
844
+ description: "Memory tier: hot, warm, or cold.",
845
+ enum: ["hot", "warm", "cold"],
846
+ },
847
+ },
848
+ required: ["content"],
849
+ },
850
+ execute: async (
851
+ _id,
852
+ args: {
853
+ content: string;
854
+ metadata?: Record<string, string>;
855
+ deduplicate?: boolean;
856
+ dedup_threshold?: number;
857
+ project?: string;
858
+ importance?: number;
859
+ tier?: string;
860
+ },
861
+ ) => {
862
+ try {
863
+ const { content, metadata, ...opts } = args;
864
+ if (!opts.project && defaultProject) opts.project = defaultProject;
865
+ const memory = await client.store(content, metadata, opts);
866
+ return {
867
+ content: [
868
+ {
869
+ type: "text",
870
+ text: `Memory stored successfully (id: ${memory.id.slice(0, 8)}...)`,
871
+ },
872
+ ],
873
+ details: { id: memory.id, stored: true },
874
+ };
875
+ } catch (err) {
876
+ return {
877
+ content: [{ type: "text", text: `Failed to store memory: ${String(err)}` }],
878
+ details: { error: String(err) },
879
+ };
880
+ }
881
+ },
882
+ },
883
+ { name: "memory_store" },
474
884
  );
475
- api.logger.info(
476
- `memory-memoryrelay: circuit breaker=${circuitBreakerConfig.enabled}, retry=${retryConfig.enabled}, entity extraction=${entityExtractionEnabled}`,
885
+ }
886
+
887
+ // --------------------------------------------------------------------------
888
+ // 2. memory_recall
889
+ // --------------------------------------------------------------------------
890
+ if (isToolEnabled("memory_recall")) {
891
+ api.registerTool(
892
+ {
893
+ name: "memory_recall",
894
+ description:
895
+ "Search memories using natural language. Returns the most relevant memories based on semantic similarity to the query." +
896
+ (defaultProject ? ` Results scoped to project '${defaultProject}' by default; pass project explicitly to override or omit to search all.` : ""),
897
+ parameters: {
898
+ type: "object",
899
+ properties: {
900
+ query: {
901
+ type: "string",
902
+ description: "Natural language search query",
903
+ },
904
+ limit: {
905
+ type: "number",
906
+ description: "Maximum results (1-50). Default 5.",
907
+ minimum: 1,
908
+ maximum: 50,
909
+ },
910
+ threshold: {
911
+ type: "number",
912
+ description: "Minimum similarity threshold (0-1). Default 0.3.",
913
+ },
914
+ project: {
915
+ type: "string",
916
+ description: "Filter by project slug.",
917
+ },
918
+ tier: {
919
+ type: "string",
920
+ description: "Filter by memory tier: hot, warm, or cold.",
921
+ enum: ["hot", "warm", "cold"],
922
+ },
923
+ min_importance: {
924
+ type: "number",
925
+ description: "Minimum importance score filter (0-1).",
926
+ },
927
+ compress: {
928
+ type: "boolean",
929
+ description: "If true, compress results for token efficiency.",
930
+ },
931
+ },
932
+ required: ["query"],
933
+ },
934
+ execute: async (
935
+ _id,
936
+ args: {
937
+ query: string;
938
+ limit?: number;
939
+ threshold?: number;
940
+ project?: string;
941
+ tier?: string;
942
+ min_importance?: number;
943
+ compress?: boolean;
944
+ },
945
+ ) => {
946
+ try {
947
+ const {
948
+ query,
949
+ limit = 5,
950
+ threshold,
951
+ project,
952
+ tier,
953
+ min_importance,
954
+ compress,
955
+ } = args;
956
+ const searchThreshold = threshold ?? cfg?.recallThreshold ?? 0.3;
957
+ const searchProject = project ?? defaultProject;
958
+ const results = await client.search(query, limit, searchThreshold, {
959
+ project: searchProject,
960
+ tier,
961
+ min_importance,
962
+ compress,
963
+ });
964
+
965
+ if (results.length === 0) {
966
+ return {
967
+ content: [{ type: "text", text: "No relevant memories found." }],
968
+ details: { count: 0 },
969
+ };
970
+ }
971
+
972
+ const formatted = results
973
+ .map(
974
+ (r) =>
975
+ `- [${r.score.toFixed(2)}] ${r.memory.content.slice(0, 200)}${
976
+ r.memory.content.length > 200 ? "..." : ""
977
+ }`,
978
+ )
979
+ .join("\n");
980
+
981
+ return {
982
+ content: [
983
+ {
984
+ type: "text",
985
+ text: `Found ${results.length} relevant memories:\n${formatted}`,
986
+ },
987
+ ],
988
+ details: {
989
+ count: results.length,
990
+ memories: results.map((r) => ({
991
+ id: r.memory.id,
992
+ content: r.memory.content,
993
+ score: r.score,
994
+ })),
995
+ },
996
+ };
997
+ } catch (err) {
998
+ return {
999
+ content: [{ type: "text", text: `Search failed: ${String(err)}` }],
1000
+ details: { error: String(err) },
1001
+ };
1002
+ }
1003
+ },
1004
+ },
1005
+ { name: "memory_recall" },
477
1006
  );
478
- } catch (err) {
479
- const errorType = classifyError(err);
480
- api.logger.error(
481
- `memory-memoryrelay: health check failed (${errorType}): ${String(err)}`,
1007
+ }
1008
+
1009
+ // --------------------------------------------------------------------------
1010
+ // 3. memory_forget
1011
+ // --------------------------------------------------------------------------
1012
+ if (isToolEnabled("memory_forget")) {
1013
+ api.registerTool(
1014
+ {
1015
+ name: "memory_forget",
1016
+ description: "Delete a memory by ID, or search by query to find candidates. Provide memoryId for direct deletion, or query to search first. A single high-confidence match (>0.9) is auto-deleted; otherwise candidates are listed for you to choose.",
1017
+ parameters: {
1018
+ type: "object",
1019
+ properties: {
1020
+ memoryId: {
1021
+ type: "string",
1022
+ description: "Memory ID to delete",
1023
+ },
1024
+ query: {
1025
+ type: "string",
1026
+ description: "Search query to find memory",
1027
+ },
1028
+ },
1029
+ },
1030
+ execute: async (_id, { memoryId, query }: { memoryId?: string; query?: string }) => {
1031
+ if (memoryId) {
1032
+ try {
1033
+ await client.delete(memoryId);
1034
+ return {
1035
+ content: [{ type: "text", text: `Memory ${memoryId.slice(0, 8)}... deleted.` }],
1036
+ details: { action: "deleted", id: memoryId },
1037
+ };
1038
+ } catch (err) {
1039
+ return {
1040
+ content: [{ type: "text", text: `Delete failed: ${String(err)}` }],
1041
+ details: { error: String(err) },
1042
+ };
1043
+ }
1044
+ }
1045
+
1046
+ if (query) {
1047
+ const results = await client.search(query, 5, 0.5, { project: defaultProject });
1048
+
1049
+ if (results.length === 0) {
1050
+ return {
1051
+ content: [{ type: "text", text: "No matching memories found." }],
1052
+ details: { count: 0 },
1053
+ };
1054
+ }
1055
+
1056
+ // If single high-confidence match, delete it
1057
+ if (results.length === 1 && results[0].score > 0.9) {
1058
+ await client.delete(results[0].memory.id);
1059
+ return {
1060
+ content: [
1061
+ { type: "text", text: `Forgotten: "${results[0].memory.content.slice(0, 60)}..."` },
1062
+ ],
1063
+ details: { action: "deleted", id: results[0].memory.id },
1064
+ };
1065
+ }
1066
+
1067
+ const list = results
1068
+ .map((r) => `- [${r.memory.id.slice(0, 8)}] ${r.memory.content.slice(0, 60)}...`)
1069
+ .join("\n");
1070
+
1071
+ return {
1072
+ content: [
1073
+ {
1074
+ type: "text",
1075
+ text: `Found ${results.length} candidates. Specify memoryId:\n${list}`,
1076
+ },
1077
+ ],
1078
+ details: { action: "candidates", count: results.length },
1079
+ };
1080
+ }
1081
+
1082
+ return {
1083
+ content: [{ type: "text", text: "Provide query or memoryId." }],
1084
+ details: { error: "missing_param" },
1085
+ };
1086
+ },
1087
+ },
1088
+ { name: "memory_forget" },
482
1089
  );
483
- if (errorType === ErrorType.AUTH) {
484
- api.logger.error(
485
- "memory-memoryrelay: Check your API key configuration",
486
- );
487
- }
488
- return;
489
1090
  }
490
1091
 
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
1092
+ // --------------------------------------------------------------------------
1093
+ // 4. memory_list
1094
+ // --------------------------------------------------------------------------
1095
+ if (isToolEnabled("memory_list")) {
1096
+ api.registerTool(
1097
+ {
1098
+ name: "memory_list",
1099
+ description: "List recent memories chronologically for this agent. Use to review what has been stored or to find memory IDs for update/delete operations.",
1100
+ parameters: {
1101
+ type: "object",
1102
+ properties: {
1103
+ limit: {
1104
+ type: "number",
1105
+ description: "Number of memories to return (1-100). Default 20.",
1106
+ minimum: 1,
1107
+ maximum: 100,
1108
+ },
1109
+ offset: {
1110
+ type: "number",
1111
+ description: "Offset for pagination. Default 0.",
1112
+ minimum: 0,
1113
+ },
1114
+ },
1115
+ },
1116
+ execute: async (_id, args: { limit?: number; offset?: number }) => {
1117
+ try {
1118
+ const memories = await client.list(args.limit ?? 20, args.offset ?? 0);
1119
+ if (memories.length === 0) {
1120
+ return {
1121
+ content: [{ type: "text", text: "No memories found." }],
1122
+ details: { count: 0 },
1123
+ };
1124
+ }
1125
+ const formatted = memories
1126
+ .map((m) => `- [${m.id.slice(0, 8)}] ${m.content.slice(0, 120)}`)
1127
+ .join("\n");
1128
+ return {
1129
+ content: [{ type: "text", text: `${memories.length} memories:\n${formatted}` }],
1130
+ details: { count: memories.length, memories },
1131
+ };
1132
+ } catch (err) {
1133
+ return {
1134
+ content: [{ type: "text", text: `Failed to list memories: ${String(err)}` }],
1135
+ details: { error: String(err) },
1136
+ };
1137
+ }
1138
+ },
1139
+ },
1140
+ { name: "memory_list" },
1141
+ );
1142
+ }
1143
+
1144
+ // --------------------------------------------------------------------------
1145
+ // 5. memory_get
1146
+ // --------------------------------------------------------------------------
1147
+ if (isToolEnabled("memory_get")) {
1148
+ api.registerTool(
1149
+ {
1150
+ name: "memory_get",
1151
+ description: "Retrieve a specific memory by its ID.",
1152
+ parameters: {
1153
+ type: "object",
1154
+ properties: {
1155
+ id: {
1156
+ type: "string",
1157
+ description: "The memory ID (UUID) to retrieve.",
1158
+ },
1159
+ },
1160
+ required: ["id"],
1161
+ },
1162
+ execute: async (_id, args: { id: string }) => {
1163
+ try {
1164
+ const memory = await client.get(args.id);
1165
+ return {
1166
+ content: [{ type: "text", text: JSON.stringify(memory, null, 2) }],
1167
+ details: { memory },
1168
+ };
1169
+ } catch (err) {
1170
+ return {
1171
+ content: [{ type: "text", text: `Failed to get memory: ${String(err)}` }],
1172
+ details: { error: String(err) },
1173
+ };
1174
+ }
1175
+ },
1176
+ },
1177
+ { name: "memory_get" },
1178
+ );
1179
+ }
1180
+
1181
+ // --------------------------------------------------------------------------
1182
+ // 6. memory_update
1183
+ // --------------------------------------------------------------------------
1184
+ if (isToolEnabled("memory_update")) {
1185
+ api.registerTool(
1186
+ {
1187
+ name: "memory_update",
1188
+ description: "Update the content of an existing memory. Use to correct or expand stored information.",
1189
+ parameters: {
1190
+ type: "object",
1191
+ properties: {
1192
+ id: {
1193
+ type: "string",
1194
+ description: "The memory ID (UUID) to update.",
1195
+ },
1196
+ content: {
1197
+ type: "string",
1198
+ description: "The new content to replace the existing memory.",
1199
+ },
1200
+ metadata: {
1201
+ type: "object",
1202
+ description: "Updated metadata (replaces existing).",
1203
+ additionalProperties: { type: "string" },
1204
+ },
1205
+ },
1206
+ required: ["id", "content"],
1207
+ },
1208
+ execute: async (_id, args: { id: string; content: string; metadata?: Record<string, string> }) => {
1209
+ try {
1210
+ const memory = await client.update(args.id, args.content, args.metadata);
1211
+ return {
1212
+ content: [{ type: "text", text: `Memory ${args.id.slice(0, 8)}... updated.` }],
1213
+ details: { id: memory.id, updated: true },
1214
+ };
1215
+ } catch (err) {
1216
+ return {
1217
+ content: [{ type: "text", text: `Failed to update memory: ${String(err)}` }],
1218
+ details: { error: String(err) },
1219
+ };
1220
+ }
1221
+ },
1222
+ },
1223
+ { name: "memory_update" },
1224
+ );
1225
+ }
1226
+
1227
+ // --------------------------------------------------------------------------
1228
+ // 7. memory_batch_store
1229
+ // --------------------------------------------------------------------------
1230
+ if (isToolEnabled("memory_batch_store")) {
1231
+ api.registerTool(
1232
+ {
1233
+ name: "memory_batch_store",
1234
+ description: "Store multiple memories at once. More efficient than individual calls for bulk storage.",
1235
+ parameters: {
1236
+ type: "object",
1237
+ properties: {
1238
+ memories: {
1239
+ type: "array",
1240
+ description: "Array of memories to store.",
1241
+ items: {
1242
+ type: "object",
1243
+ properties: {
1244
+ content: { type: "string", description: "Memory content." },
1245
+ metadata: {
1246
+ type: "object",
1247
+ description: "Optional metadata.",
1248
+ additionalProperties: { type: "string" },
1249
+ },
1250
+ },
1251
+ required: ["content"],
1252
+ },
1253
+ },
1254
+ },
1255
+ required: ["memories"],
1256
+ },
1257
+ execute: async (
1258
+ _id,
1259
+ args: { memories: Array<{ content: string; metadata?: Record<string, string> }> },
1260
+ ) => {
1261
+ try {
1262
+ const result = await client.batchStore(args.memories);
1263
+ return {
1264
+ content: [
1265
+ {
1266
+ type: "text",
1267
+ text: `Batch stored ${args.memories.length} memories successfully.`,
1268
+ },
1269
+ ],
1270
+ details: { count: args.memories.length, result },
1271
+ };
1272
+ } catch (err) {
1273
+ return {
1274
+ content: [{ type: "text", text: `Batch store failed: ${String(err)}` }],
1275
+ details: { error: String(err) },
1276
+ };
1277
+ }
1278
+ },
1279
+ },
1280
+ { name: "memory_batch_store" },
1281
+ );
1282
+ }
1283
+
1284
+ // --------------------------------------------------------------------------
1285
+ // 8. memory_context
1286
+ // --------------------------------------------------------------------------
1287
+ if (isToolEnabled("memory_context")) {
1288
+ api.registerTool(
1289
+ {
1290
+ name: "memory_context",
1291
+ description:
1292
+ "Build a context window from relevant memories, optimized for injecting into agent prompts with token budget awareness." +
1293
+ (defaultProject ? ` Project defaults to '${defaultProject}' if not specified.` : ""),
1294
+ parameters: {
1295
+ type: "object",
1296
+ properties: {
1297
+ query: {
1298
+ type: "string",
1299
+ description: "The query to build context around.",
1300
+ },
1301
+ limit: {
1302
+ type: "number",
1303
+ description: "Maximum number of memories to include.",
1304
+ },
1305
+ threshold: {
1306
+ type: "number",
1307
+ description: "Minimum similarity threshold (0-1).",
1308
+ },
1309
+ max_tokens: {
1310
+ type: "number",
1311
+ description: "Maximum token budget for the context.",
1312
+ },
1313
+ project: {
1314
+ type: "string",
1315
+ description: "Project slug to scope the context.",
1316
+ },
1317
+ },
1318
+ required: ["query"],
1319
+ },
1320
+ execute: async (
1321
+ _id,
1322
+ args: { query: string; limit?: number; threshold?: number; max_tokens?: number; project?: string },
1323
+ ) => {
1324
+ try {
1325
+ const project = args.project ?? defaultProject;
1326
+ const result = await client.buildContext(
1327
+ args.query,
1328
+ args.limit,
1329
+ args.threshold,
1330
+ args.max_tokens,
1331
+ project,
1332
+ );
1333
+ return {
1334
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
1335
+ details: { result },
1336
+ };
1337
+ } catch (err) {
1338
+ return {
1339
+ content: [{ type: "text", text: `Context build failed: ${String(err)}` }],
1340
+ details: { error: String(err) },
1341
+ };
1342
+ }
1343
+ },
1344
+ },
1345
+ { name: "memory_context" },
1346
+ );
1347
+ }
1348
+
1349
+ // --------------------------------------------------------------------------
1350
+ // 9. memory_promote
1351
+ // --------------------------------------------------------------------------
1352
+ if (isToolEnabled("memory_promote")) {
1353
+ api.registerTool(
1354
+ {
1355
+ name: "memory_promote",
1356
+ description:
1357
+ "Promote a memory by updating its importance score and/or tier. Use to ensure critical memories are retained longer.",
1358
+ parameters: {
1359
+ type: "object",
1360
+ properties: {
1361
+ memory_id: {
1362
+ type: "string",
1363
+ description: "The memory ID to promote.",
1364
+ },
1365
+ importance: {
1366
+ type: "number",
1367
+ description: "New importance score (0-1).",
1368
+ minimum: 0,
1369
+ maximum: 1,
1370
+ },
1371
+ tier: {
1372
+ type: "string",
1373
+ description: "Target tier: hot, warm, or cold.",
1374
+ enum: ["hot", "warm", "cold"],
1375
+ },
1376
+ },
1377
+ required: ["memory_id", "importance"],
1378
+ },
1379
+ execute: async (_id, args: { memory_id: string; importance: number; tier?: string }) => {
1380
+ try {
1381
+ const result = await client.promote(args.memory_id, args.importance, args.tier);
1382
+ return {
1383
+ content: [
1384
+ {
1385
+ type: "text",
1386
+ text: `Memory ${args.memory_id.slice(0, 8)}... promoted (importance: ${args.importance}${args.tier ? `, tier: ${args.tier}` : ""}).`,
1387
+ },
1388
+ ],
1389
+ details: { result },
1390
+ };
1391
+ } catch (err) {
1392
+ return {
1393
+ content: [{ type: "text", text: `Promote failed: ${String(err)}` }],
1394
+ details: { error: String(err) },
1395
+ };
1396
+ }
1397
+ },
1398
+ },
1399
+ { name: "memory_promote" },
1400
+ );
1401
+ }
1402
+
1403
+ // --------------------------------------------------------------------------
1404
+ // 10. entity_create
1405
+ // --------------------------------------------------------------------------
1406
+ if (isToolEnabled("entity_create")) {
1407
+ api.registerTool(
1408
+ {
1409
+ name: "entity_create",
1410
+ description:
1411
+ "Create a named entity (person, place, organization, project, concept) for the knowledge graph. Entities help organize and connect memories.",
1412
+ parameters: {
1413
+ type: "object",
1414
+ properties: {
1415
+ name: {
1416
+ type: "string",
1417
+ description: "Entity name (1-200 characters).",
1418
+ },
1419
+ type: {
1420
+ type: "string",
1421
+ description: "Entity type classification.",
1422
+ enum: ["person", "place", "organization", "project", "concept", "other"],
1423
+ },
1424
+ metadata: {
1425
+ type: "object",
1426
+ description: "Optional key-value metadata.",
1427
+ additionalProperties: { type: "string" },
1428
+ },
1429
+ },
1430
+ required: ["name", "type"],
1431
+ },
1432
+ execute: async (
1433
+ _id,
1434
+ args: { name: string; type: string; metadata?: Record<string, string> },
1435
+ ) => {
1436
+ try {
1437
+ const result = await client.createEntity(args.name, args.type, args.metadata);
1438
+ return {
1439
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
1440
+ details: { result },
1441
+ };
1442
+ } catch (err) {
1443
+ return {
1444
+ content: [{ type: "text", text: `Failed to create entity: ${String(err)}` }],
1445
+ details: { error: String(err) },
1446
+ };
1447
+ }
1448
+ },
1449
+ },
1450
+ { name: "entity_create" },
1451
+ );
1452
+ }
1453
+
1454
+ // --------------------------------------------------------------------------
1455
+ // 11. entity_link
1456
+ // --------------------------------------------------------------------------
1457
+ if (isToolEnabled("entity_link")) {
1458
+ api.registerTool(
1459
+ {
1460
+ name: "entity_link",
1461
+ description: "Link an entity to a memory to establish relationships in the knowledge graph.",
1462
+ parameters: {
1463
+ type: "object",
1464
+ properties: {
1465
+ entity_id: {
1466
+ type: "string",
1467
+ description: "Entity UUID.",
1468
+ },
1469
+ memory_id: {
1470
+ type: "string",
1471
+ description: "Memory UUID.",
1472
+ },
1473
+ relationship: {
1474
+ type: "string",
1475
+ description:
1476
+ 'Relationship type (e.g., "mentioned_in", "created_by", "relates_to"). Default "mentioned_in".',
1477
+ },
1478
+ },
1479
+ required: ["entity_id", "memory_id"],
1480
+ },
1481
+ execute: async (
1482
+ _id,
1483
+ args: { entity_id: string; memory_id: string; relationship?: string },
1484
+ ) => {
1485
+ try {
1486
+ const result = await client.linkEntity(
1487
+ args.entity_id,
1488
+ args.memory_id,
1489
+ args.relationship,
1490
+ );
1491
+ return {
1492
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
1493
+ details: { result },
1494
+ };
1495
+ } catch (err) {
1496
+ return {
1497
+ content: [{ type: "text", text: `Failed to link entity: ${String(err)}` }],
1498
+ details: { error: String(err) },
1499
+ };
1500
+ }
1501
+ },
1502
+ },
1503
+ { name: "entity_link" },
1504
+ );
1505
+ }
1506
+
1507
+ // --------------------------------------------------------------------------
1508
+ // 12. entity_list
1509
+ // --------------------------------------------------------------------------
1510
+ if (isToolEnabled("entity_list")) {
1511
+ api.registerTool(
1512
+ {
1513
+ name: "entity_list",
1514
+ description: "List entities in the knowledge graph.",
1515
+ parameters: {
1516
+ type: "object",
1517
+ properties: {
1518
+ limit: {
1519
+ type: "number",
1520
+ description: "Maximum entities to return. Default 20.",
1521
+ minimum: 1,
1522
+ maximum: 100,
1523
+ },
1524
+ offset: {
1525
+ type: "number",
1526
+ description: "Offset for pagination. Default 0.",
1527
+ minimum: 0,
1528
+ },
1529
+ },
1530
+ },
1531
+ execute: async (_id, args: { limit?: number; offset?: number }) => {
1532
+ try {
1533
+ const result = await client.listEntities(args.limit, args.offset);
1534
+ return {
1535
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
1536
+ details: { result },
1537
+ };
1538
+ } catch (err) {
1539
+ return {
1540
+ content: [{ type: "text", text: `Failed to list entities: ${String(err)}` }],
1541
+ details: { error: String(err) },
1542
+ };
1543
+ }
1544
+ },
1545
+ },
1546
+ { name: "entity_list" },
1547
+ );
1548
+ }
1549
+
1550
+ // --------------------------------------------------------------------------
1551
+ // 13. entity_graph
1552
+ // --------------------------------------------------------------------------
1553
+ if (isToolEnabled("entity_graph")) {
1554
+ api.registerTool(
1555
+ {
1556
+ name: "entity_graph",
1557
+ description:
1558
+ "Explore the knowledge graph around an entity. Returns the entity and its neighborhood of connected entities and memories.",
1559
+ parameters: {
1560
+ type: "object",
1561
+ properties: {
1562
+ entity_id: {
1563
+ type: "string",
1564
+ description: "Entity UUID to explore from.",
1565
+ },
1566
+ depth: {
1567
+ type: "number",
1568
+ description: "How many hops to traverse. Default 2.",
1569
+ minimum: 1,
1570
+ maximum: 5,
1571
+ },
1572
+ max_neighbors: {
1573
+ type: "number",
1574
+ description: "Maximum neighbors per node. Default 10.",
1575
+ minimum: 1,
1576
+ maximum: 50,
1577
+ },
1578
+ },
1579
+ required: ["entity_id"],
1580
+ },
1581
+ execute: async (
1582
+ _id,
1583
+ args: { entity_id: string; depth?: number; max_neighbors?: number },
1584
+ ) => {
1585
+ try {
1586
+ const result = await client.entityGraph(
1587
+ args.entity_id,
1588
+ args.depth,
1589
+ args.max_neighbors,
1590
+ );
1591
+ return {
1592
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
1593
+ details: { result },
1594
+ };
1595
+ } catch (err) {
1596
+ return {
1597
+ content: [{ type: "text", text: `Failed to get entity graph: ${String(err)}` }],
1598
+ details: { error: String(err) },
1599
+ };
1600
+ }
1601
+ },
1602
+ },
1603
+ { name: "entity_graph" },
1604
+ );
1605
+ }
1606
+
1607
+ // --------------------------------------------------------------------------
1608
+ // 14. agent_list
1609
+ // --------------------------------------------------------------------------
1610
+ if (isToolEnabled("agent_list")) {
1611
+ api.registerTool(
1612
+ {
1613
+ name: "agent_list",
1614
+ description: "List available agents.",
1615
+ parameters: {
1616
+ type: "object",
1617
+ properties: {
1618
+ limit: {
1619
+ type: "number",
1620
+ description: "Maximum agents to return. Default 20.",
1621
+ minimum: 1,
1622
+ maximum: 100,
1623
+ },
1624
+ },
1625
+ },
1626
+ execute: async (_id, args: { limit?: number }) => {
1627
+ try {
1628
+ const result = await client.listAgents(args.limit);
1629
+ return {
1630
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
1631
+ details: { result },
1632
+ };
1633
+ } catch (err) {
1634
+ return {
1635
+ content: [{ type: "text", text: `Failed to list agents: ${String(err)}` }],
1636
+ details: { error: String(err) },
1637
+ };
1638
+ }
1639
+ },
1640
+ },
1641
+ { name: "agent_list" },
1642
+ );
1643
+ }
1644
+
1645
+ // --------------------------------------------------------------------------
1646
+ // 15. agent_create
1647
+ // --------------------------------------------------------------------------
1648
+ if (isToolEnabled("agent_create")) {
1649
+ api.registerTool(
1650
+ {
1651
+ name: "agent_create",
1652
+ description: "Create a new agent. Agents serve as memory namespaces and isolation boundaries.",
1653
+ parameters: {
1654
+ type: "object",
1655
+ properties: {
1656
+ name: {
1657
+ type: "string",
1658
+ description: "Agent name.",
1659
+ },
1660
+ description: {
1661
+ type: "string",
1662
+ description: "Optional agent description.",
1663
+ },
1664
+ },
1665
+ required: ["name"],
1666
+ },
1667
+ execute: async (_id, args: { name: string; description?: string }) => {
1668
+ try {
1669
+ const result = await client.createAgent(args.name, args.description);
1670
+ return {
1671
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
1672
+ details: { result },
1673
+ };
1674
+ } catch (err) {
1675
+ return {
1676
+ content: [{ type: "text", text: `Failed to create agent: ${String(err)}` }],
1677
+ details: { error: String(err) },
1678
+ };
1679
+ }
1680
+ },
1681
+ },
1682
+ { name: "agent_create" },
1683
+ );
1684
+ }
1685
+
1686
+ // --------------------------------------------------------------------------
1687
+ // 16. agent_get
1688
+ // --------------------------------------------------------------------------
1689
+ if (isToolEnabled("agent_get")) {
1690
+ api.registerTool(
1691
+ {
1692
+ name: "agent_get",
1693
+ description: "Get details about a specific agent by ID.",
1694
+ parameters: {
1695
+ type: "object",
1696
+ properties: {
1697
+ id: {
1698
+ type: "string",
1699
+ description: "Agent UUID.",
1700
+ },
1701
+ },
1702
+ required: ["id"],
1703
+ },
1704
+ execute: async (_id, args: { id: string }) => {
1705
+ try {
1706
+ const result = await client.getAgent(args.id);
1707
+ return {
1708
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
1709
+ details: { result },
1710
+ };
1711
+ } catch (err) {
1712
+ return {
1713
+ content: [{ type: "text", text: `Failed to get agent: ${String(err)}` }],
1714
+ details: { error: String(err) },
1715
+ };
1716
+ }
1717
+ },
1718
+ },
1719
+ { name: "agent_get" },
1720
+ );
1721
+ }
1722
+
1723
+ // --------------------------------------------------------------------------
1724
+ // 17. session_start
1725
+ // --------------------------------------------------------------------------
1726
+ if (isToolEnabled("session_start")) {
1727
+ api.registerTool(
1728
+ {
1729
+ name: "session_start",
1730
+ description:
1731
+ "Start a new work session. Sessions track the lifecycle of a task or conversation for later review. Call this early in your workflow and save the returned session ID for session_end later." +
1732
+ (defaultProject ? ` Project defaults to '${defaultProject}' if not specified.` : ""),
1733
+ parameters: {
1734
+ type: "object",
1735
+ properties: {
1736
+ title: {
1737
+ type: "string",
1738
+ description: "Session title describing the goal or task.",
1739
+ },
1740
+ project: {
1741
+ type: "string",
1742
+ description: "Project slug to associate this session with.",
1743
+ },
1744
+ metadata: {
1745
+ type: "object",
1746
+ description: "Optional key-value metadata.",
1747
+ additionalProperties: { type: "string" },
1748
+ },
1749
+ },
1750
+ },
1751
+ execute: async (
1752
+ _id,
1753
+ args: { title?: string; project?: string; metadata?: Record<string, string> },
1754
+ ) => {
1755
+ try {
1756
+ const project = args.project ?? defaultProject;
1757
+ const result = await client.startSession(args.title, project, args.metadata);
1758
+ return {
1759
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
1760
+ details: { result },
1761
+ };
1762
+ } catch (err) {
1763
+ return {
1764
+ content: [{ type: "text", text: `Failed to start session: ${String(err)}` }],
1765
+ details: { error: String(err) },
1766
+ };
1767
+ }
1768
+ },
1769
+ },
1770
+ { name: "session_start" },
1771
+ );
1772
+ }
1773
+
1774
+ // --------------------------------------------------------------------------
1775
+ // 18. session_end
1776
+ // --------------------------------------------------------------------------
1777
+ if (isToolEnabled("session_end")) {
1778
+ api.registerTool(
1779
+ {
1780
+ name: "session_end",
1781
+ description: "End an active session with a summary of what was accomplished. Always include a meaningful summary — it serves as the historical record of the session.",
1782
+ parameters: {
1783
+ type: "object",
1784
+ properties: {
1785
+ id: {
1786
+ type: "string",
1787
+ description: "Session ID to end.",
1788
+ },
1789
+ summary: {
1790
+ type: "string",
1791
+ description: "Summary of what was accomplished during this session.",
1792
+ },
1793
+ },
1794
+ required: ["id"],
1795
+ },
1796
+ execute: async (_id, args: { id: string; summary?: string }) => {
1797
+ try {
1798
+ const result = await client.endSession(args.id, args.summary);
1799
+ return {
1800
+ content: [{ type: "text", text: `Session ${args.id.slice(0, 8)}... ended.` }],
1801
+ details: { result },
1802
+ };
1803
+ } catch (err) {
1804
+ return {
1805
+ content: [{ type: "text", text: `Failed to end session: ${String(err)}` }],
1806
+ details: { error: String(err) },
1807
+ };
1808
+ }
1809
+ },
1810
+ },
1811
+ { name: "session_end" },
1812
+ );
1813
+ }
1814
+
1815
+ // --------------------------------------------------------------------------
1816
+ // 19. session_recall
1817
+ // --------------------------------------------------------------------------
1818
+ if (isToolEnabled("session_recall")) {
1819
+ api.registerTool(
1820
+ {
1821
+ name: "session_recall",
1822
+ description: "Retrieve details of a specific session including its timeline and associated memories.",
1823
+ parameters: {
1824
+ type: "object",
1825
+ properties: {
1826
+ id: {
1827
+ type: "string",
1828
+ description: "Session ID to retrieve.",
1829
+ },
1830
+ },
1831
+ required: ["id"],
1832
+ },
1833
+ execute: async (_id, args: { id: string }) => {
1834
+ try {
1835
+ const result = await client.getSession(args.id);
1836
+ return {
1837
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
1838
+ details: { result },
1839
+ };
1840
+ } catch (err) {
1841
+ return {
1842
+ content: [{ type: "text", text: `Failed to recall session: ${String(err)}` }],
1843
+ details: { error: String(err) },
1844
+ };
1845
+ }
1846
+ },
1847
+ },
1848
+ { name: "session_recall" },
1849
+ );
1850
+ }
1851
+
1852
+ // --------------------------------------------------------------------------
1853
+ // 20. session_list
1854
+ // --------------------------------------------------------------------------
1855
+ if (isToolEnabled("session_list")) {
1856
+ api.registerTool(
1857
+ {
1858
+ name: "session_list",
1859
+ description: "List sessions, optionally filtered by project or status." +
1860
+ (defaultProject ? ` Scoped to project '${defaultProject}' by default.` : ""),
1861
+ parameters: {
1862
+ type: "object",
1863
+ properties: {
1864
+ limit: {
1865
+ type: "number",
1866
+ description: "Maximum sessions to return. Default 20.",
1867
+ minimum: 1,
1868
+ maximum: 100,
1869
+ },
1870
+ project: {
1871
+ type: "string",
1872
+ description: "Filter by project slug.",
1873
+ },
1874
+ status: {
1875
+ type: "string",
1876
+ description: "Filter by status (active, ended).",
1877
+ enum: ["active", "ended"],
1878
+ },
1879
+ },
1880
+ },
1881
+ execute: async (
1882
+ _id,
1883
+ args: { limit?: number; project?: string; status?: string },
1884
+ ) => {
1885
+ try {
1886
+ const project = args.project ?? defaultProject;
1887
+ const result = await client.listSessions(args.limit, project, args.status);
1888
+ return {
1889
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
1890
+ details: { result },
1891
+ };
1892
+ } catch (err) {
1893
+ return {
1894
+ content: [{ type: "text", text: `Failed to list sessions: ${String(err)}` }],
1895
+ details: { error: String(err) },
1896
+ };
1897
+ }
1898
+ },
1899
+ },
1900
+ { name: "session_list" },
1901
+ );
1902
+ }
1903
+
1904
+ // --------------------------------------------------------------------------
1905
+ // 21. decision_record
1906
+ // --------------------------------------------------------------------------
1907
+ if (isToolEnabled("decision_record")) {
1908
+ api.registerTool(
1909
+ {
1910
+ name: "decision_record",
1911
+ description:
1912
+ "Record an architectural or design decision. Captures the rationale and alternatives considered for future reference. Always check existing decisions with decision_check first to avoid contradictions." +
1913
+ (defaultProject ? ` Project defaults to '${defaultProject}' if not specified.` : ""),
1914
+ parameters: {
1915
+ type: "object",
1916
+ properties: {
1917
+ title: {
1918
+ type: "string",
1919
+ description: "Short title summarizing the decision.",
1920
+ },
1921
+ rationale: {
1922
+ type: "string",
1923
+ description: "Why this decision was made. Include context and reasoning.",
1924
+ },
1925
+ alternatives: {
1926
+ type: "string",
1927
+ description: "What alternatives were considered and why they were rejected.",
1928
+ },
1929
+ project: {
1930
+ type: "string",
1931
+ description: "Project slug this decision applies to.",
1932
+ },
1933
+ tags: {
1934
+ type: "array",
1935
+ description: "Tags for categorizing the decision.",
1936
+ items: { type: "string" },
1937
+ },
1938
+ status: {
1939
+ type: "string",
1940
+ description: "Decision status.",
1941
+ enum: ["active", "experimental"],
1942
+ },
1943
+ },
1944
+ required: ["title", "rationale"],
1945
+ },
1946
+ execute: async (
1947
+ _id,
1948
+ args: {
1949
+ title: string;
1950
+ rationale: string;
1951
+ alternatives?: string;
1952
+ project?: string;
1953
+ tags?: string[];
1954
+ status?: string;
1955
+ },
1956
+ ) => {
1957
+ try {
1958
+ const project = args.project ?? defaultProject;
1959
+ const result = await client.recordDecision(
1960
+ args.title,
1961
+ args.rationale,
1962
+ args.alternatives,
1963
+ project,
1964
+ args.tags,
1965
+ args.status,
1966
+ );
1967
+ return {
1968
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
1969
+ details: { result },
1970
+ };
1971
+ } catch (err) {
1972
+ return {
1973
+ content: [{ type: "text", text: `Failed to record decision: ${String(err)}` }],
1974
+ details: { error: String(err) },
1975
+ };
1976
+ }
1977
+ },
1978
+ },
1979
+ { name: "decision_record" },
1980
+ );
1981
+ }
1982
+
1983
+ // --------------------------------------------------------------------------
1984
+ // 22. decision_list
1985
+ // --------------------------------------------------------------------------
1986
+ if (isToolEnabled("decision_list")) {
1987
+ api.registerTool(
1988
+ {
1989
+ name: "decision_list",
1990
+ description: "List recorded decisions, optionally filtered by project, status, or tags." +
1991
+ (defaultProject ? ` Scoped to project '${defaultProject}' by default.` : ""),
1992
+ parameters: {
1993
+ type: "object",
1994
+ properties: {
1995
+ limit: {
1996
+ type: "number",
1997
+ description: "Maximum decisions to return. Default 20.",
1998
+ minimum: 1,
1999
+ maximum: 100,
2000
+ },
2001
+ project: {
2002
+ type: "string",
2003
+ description: "Filter by project slug.",
2004
+ },
2005
+ status: {
2006
+ type: "string",
2007
+ description: "Filter by status.",
2008
+ enum: ["active", "superseded", "reverted", "experimental"],
2009
+ },
2010
+ tags: {
2011
+ type: "string",
2012
+ description: "Comma-separated tags to filter by.",
2013
+ },
2014
+ },
2015
+ },
2016
+ execute: async (
2017
+ _id,
2018
+ args: { limit?: number; project?: string; status?: string; tags?: string },
2019
+ ) => {
2020
+ try {
2021
+ const project = args.project ?? defaultProject;
2022
+ const result = await client.listDecisions(args.limit, project, args.status, args.tags);
2023
+ return {
2024
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2025
+ details: { result },
2026
+ };
2027
+ } catch (err) {
2028
+ return {
2029
+ content: [{ type: "text", text: `Failed to list decisions: ${String(err)}` }],
2030
+ details: { error: String(err) },
2031
+ };
2032
+ }
2033
+ },
2034
+ },
2035
+ { name: "decision_list" },
2036
+ );
2037
+ }
2038
+
2039
+ // --------------------------------------------------------------------------
2040
+ // 23. decision_supersede
2041
+ // --------------------------------------------------------------------------
2042
+ if (isToolEnabled("decision_supersede")) {
2043
+ api.registerTool(
2044
+ {
2045
+ name: "decision_supersede",
2046
+ description:
2047
+ "Supersede an existing decision with a new one. The old decision is marked as superseded and linked to the replacement.",
2048
+ parameters: {
2049
+ type: "object",
2050
+ properties: {
2051
+ id: {
2052
+ type: "string",
2053
+ description: "ID of the decision to supersede.",
2054
+ },
2055
+ title: {
2056
+ type: "string",
2057
+ description: "Title of the new replacement decision.",
2058
+ },
2059
+ rationale: {
2060
+ type: "string",
2061
+ description: "Why the previous decision is being replaced.",
2062
+ },
2063
+ alternatives: {
2064
+ type: "string",
2065
+ description: "Alternatives considered for the new decision.",
2066
+ },
2067
+ tags: {
2068
+ type: "array",
2069
+ description: "Tags for the new decision.",
2070
+ items: { type: "string" },
2071
+ },
2072
+ },
2073
+ required: ["id", "title", "rationale"],
2074
+ },
2075
+ execute: async (
2076
+ _id,
2077
+ args: {
2078
+ id: string;
2079
+ title: string;
2080
+ rationale: string;
2081
+ alternatives?: string;
2082
+ tags?: string[];
2083
+ },
2084
+ ) => {
2085
+ try {
2086
+ const result = await client.supersedeDecision(
2087
+ args.id,
2088
+ args.title,
2089
+ args.rationale,
2090
+ args.alternatives,
2091
+ args.tags,
2092
+ );
2093
+ return {
2094
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2095
+ details: { result },
2096
+ };
2097
+ } catch (err) {
2098
+ return {
2099
+ content: [{ type: "text", text: `Failed to supersede decision: ${String(err)}` }],
2100
+ details: { error: String(err) },
2101
+ };
2102
+ }
2103
+ },
2104
+ },
2105
+ { name: "decision_supersede" },
2106
+ );
2107
+ }
2108
+
2109
+ // --------------------------------------------------------------------------
2110
+ // 24. decision_check
2111
+ // --------------------------------------------------------------------------
2112
+ if (isToolEnabled("decision_check")) {
2113
+ api.registerTool(
2114
+ {
2115
+ name: "decision_check",
2116
+ description:
2117
+ "Check if there are existing decisions relevant to a topic. ALWAYS call this before making architectural choices to avoid contradicting past decisions." +
2118
+ (defaultProject ? ` Scoped to project '${defaultProject}' by default.` : ""),
2119
+ parameters: {
2120
+ type: "object",
2121
+ properties: {
2122
+ query: {
2123
+ type: "string",
2124
+ description: "Natural language description of the topic or decision area.",
2125
+ },
2126
+ project: {
2127
+ type: "string",
2128
+ description: "Project slug to scope the search.",
2129
+ },
2130
+ limit: {
2131
+ type: "number",
2132
+ description: "Maximum results. Default 5.",
2133
+ },
2134
+ threshold: {
2135
+ type: "number",
2136
+ description: "Minimum similarity threshold (0-1). Default 0.3.",
2137
+ },
2138
+ include_superseded: {
2139
+ type: "boolean",
2140
+ description: "Include superseded decisions in results. Default false.",
2141
+ },
2142
+ },
2143
+ required: ["query"],
2144
+ },
2145
+ execute: async (
2146
+ _id,
2147
+ args: {
2148
+ query: string;
2149
+ project?: string;
2150
+ limit?: number;
2151
+ threshold?: number;
2152
+ include_superseded?: boolean;
2153
+ },
2154
+ ) => {
2155
+ try {
2156
+ const project = args.project ?? defaultProject;
2157
+ const result = await client.checkDecisions(
2158
+ args.query,
2159
+ project,
2160
+ args.limit,
2161
+ args.threshold,
2162
+ args.include_superseded,
2163
+ );
2164
+ return {
2165
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2166
+ details: { result },
2167
+ };
2168
+ } catch (err) {
2169
+ return {
2170
+ content: [{ type: "text", text: `Failed to check decisions: ${String(err)}` }],
2171
+ details: { error: String(err) },
2172
+ };
2173
+ }
2174
+ },
2175
+ },
2176
+ { name: "decision_check" },
2177
+ );
2178
+ }
2179
+
2180
+ // --------------------------------------------------------------------------
2181
+ // 25. pattern_create
2182
+ // --------------------------------------------------------------------------
2183
+ if (isToolEnabled("pattern_create")) {
2184
+ api.registerTool(
2185
+ {
2186
+ name: "pattern_create",
2187
+ description:
2188
+ "Create a reusable pattern (coding convention, architecture pattern, or best practice) that can be shared across projects. Include example_code for maximum usefulness." +
2189
+ (defaultProject ? ` Source project defaults to '${defaultProject}' if not specified.` : ""),
2190
+ parameters: {
2191
+ type: "object",
2192
+ properties: {
2193
+ title: {
2194
+ type: "string",
2195
+ description: "Pattern title.",
2196
+ },
2197
+ description: {
2198
+ type: "string",
2199
+ description: "Detailed description of the pattern, when to use it, and why.",
2200
+ },
2201
+ category: {
2202
+ type: "string",
2203
+ description: "Category (e.g., architecture, testing, error-handling, naming).",
2204
+ },
2205
+ example_code: {
2206
+ type: "string",
2207
+ description: "Example code demonstrating the pattern.",
2208
+ },
2209
+ scope: {
2210
+ type: "string",
2211
+ description: "Scope: global (visible to all projects) or project (visible to source project only).",
2212
+ enum: ["global", "project"],
2213
+ },
2214
+ tags: {
2215
+ type: "array",
2216
+ description: "Tags for categorization.",
2217
+ items: { type: "string" },
2218
+ },
2219
+ source_project: {
2220
+ type: "string",
2221
+ description: "Project slug where this pattern originated.",
2222
+ },
2223
+ },
2224
+ required: ["title", "description"],
2225
+ },
2226
+ execute: async (
2227
+ _id,
2228
+ args: {
2229
+ title: string;
2230
+ description: string;
2231
+ category?: string;
2232
+ example_code?: string;
2233
+ scope?: string;
2234
+ tags?: string[];
2235
+ source_project?: string;
2236
+ },
2237
+ ) => {
2238
+ try {
2239
+ const sourceProject = args.source_project ?? defaultProject;
2240
+ const result = await client.createPattern(
2241
+ args.title,
2242
+ args.description,
2243
+ args.category,
2244
+ args.example_code,
2245
+ args.scope,
2246
+ args.tags,
2247
+ sourceProject,
2248
+ );
2249
+ return {
2250
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2251
+ details: { result },
2252
+ };
2253
+ } catch (err) {
2254
+ return {
2255
+ content: [{ type: "text", text: `Failed to create pattern: ${String(err)}` }],
2256
+ details: { error: String(err) },
2257
+ };
2258
+ }
2259
+ },
2260
+ },
2261
+ { name: "pattern_create" },
2262
+ );
2263
+ }
2264
+
2265
+ // --------------------------------------------------------------------------
2266
+ // 26. pattern_search
2267
+ // --------------------------------------------------------------------------
2268
+ if (isToolEnabled("pattern_search")) {
2269
+ api.registerTool(
2270
+ {
2271
+ name: "pattern_search",
2272
+ description: "Search for established patterns by natural language query. Call this before writing code to find and follow existing conventions." +
2273
+ (defaultProject ? ` Scoped to project '${defaultProject}' by default.` : ""),
2274
+ parameters: {
2275
+ type: "object",
2276
+ properties: {
2277
+ query: {
2278
+ type: "string",
2279
+ description: "Natural language search query.",
2280
+ },
2281
+ category: {
2282
+ type: "string",
2283
+ description: "Filter by category.",
2284
+ },
2285
+ project: {
2286
+ type: "string",
2287
+ description: "Filter by project slug.",
2288
+ },
2289
+ limit: {
2290
+ type: "number",
2291
+ description: "Maximum results. Default 10.",
2292
+ },
2293
+ threshold: {
2294
+ type: "number",
2295
+ description: "Minimum similarity threshold (0-1). Default 0.3.",
2296
+ },
2297
+ },
2298
+ required: ["query"],
2299
+ },
2300
+ execute: async (
2301
+ _id,
2302
+ args: {
2303
+ query: string;
2304
+ category?: string;
2305
+ project?: string;
2306
+ limit?: number;
2307
+ threshold?: number;
2308
+ },
2309
+ ) => {
2310
+ try {
2311
+ const project = args.project ?? defaultProject;
2312
+ const result = await client.searchPatterns(
2313
+ args.query,
2314
+ args.category,
2315
+ project,
2316
+ args.limit,
2317
+ args.threshold,
2318
+ );
2319
+ return {
2320
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2321
+ details: { result },
2322
+ };
2323
+ } catch (err) {
2324
+ return {
2325
+ content: [{ type: "text", text: `Failed to search patterns: ${String(err)}` }],
2326
+ details: { error: String(err) },
2327
+ };
2328
+ }
2329
+ },
2330
+ },
2331
+ { name: "pattern_search" },
2332
+ );
2333
+ }
2334
+
2335
+ // --------------------------------------------------------------------------
2336
+ // 27. pattern_adopt
2337
+ // --------------------------------------------------------------------------
2338
+ if (isToolEnabled("pattern_adopt")) {
2339
+ api.registerTool(
2340
+ {
2341
+ name: "pattern_adopt",
2342
+ description: "Adopt an existing pattern for use in a project. Creates a link between the pattern and the project.",
2343
+ parameters: {
2344
+ type: "object",
2345
+ properties: {
2346
+ id: {
2347
+ type: "string",
2348
+ description: "Pattern ID to adopt.",
2349
+ },
2350
+ project: {
2351
+ type: "string",
2352
+ description: "Project slug adopting the pattern.",
2353
+ },
2354
+ },
2355
+ required: ["id", "project"],
2356
+ },
2357
+ execute: async (_id, args: { id: string; project: string }) => {
2358
+ try {
2359
+ const result = await client.adoptPattern(args.id, args.project);
2360
+ return {
2361
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2362
+ details: { result },
2363
+ };
2364
+ } catch (err) {
2365
+ return {
2366
+ content: [{ type: "text", text: `Failed to adopt pattern: ${String(err)}` }],
2367
+ details: { error: String(err) },
2368
+ };
2369
+ }
2370
+ },
2371
+ },
2372
+ { name: "pattern_adopt" },
2373
+ );
2374
+ }
2375
+
2376
+ // --------------------------------------------------------------------------
2377
+ // 28. pattern_suggest
2378
+ // --------------------------------------------------------------------------
2379
+ if (isToolEnabled("pattern_suggest")) {
2380
+ api.registerTool(
2381
+ {
2382
+ name: "pattern_suggest",
2383
+ description:
2384
+ "Get pattern suggestions for a project based on its stack and existing patterns from related projects.",
2385
+ parameters: {
2386
+ type: "object",
2387
+ properties: {
2388
+ project: {
2389
+ type: "string",
2390
+ description: "Project slug to get suggestions for.",
2391
+ },
2392
+ limit: {
2393
+ type: "number",
2394
+ description: "Maximum suggestions. Default 10.",
2395
+ },
2396
+ },
2397
+ required: ["project"],
2398
+ },
2399
+ execute: async (_id, args: { project: string; limit?: number }) => {
2400
+ try {
2401
+ const result = await client.suggestPatterns(args.project, args.limit);
2402
+ return {
2403
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2404
+ details: { result },
2405
+ };
2406
+ } catch (err) {
2407
+ return {
2408
+ content: [{ type: "text", text: `Failed to suggest patterns: ${String(err)}` }],
2409
+ details: { error: String(err) },
2410
+ };
2411
+ }
2412
+ },
2413
+ },
2414
+ { name: "pattern_suggest" },
2415
+ );
2416
+ }
2417
+
2418
+ // --------------------------------------------------------------------------
2419
+ // 29. project_register
2420
+ // --------------------------------------------------------------------------
2421
+ if (isToolEnabled("project_register")) {
2422
+ api.registerTool(
2423
+ {
2424
+ name: "project_register",
2425
+ description: "Register a new project in MemoryRelay. Projects organize memories, decisions, patterns, and sessions.",
2426
+ parameters: {
2427
+ type: "object",
2428
+ properties: {
2429
+ slug: {
2430
+ type: "string",
2431
+ description: "URL-friendly project identifier (e.g., 'my-api', 'frontend-app').",
2432
+ },
2433
+ name: {
2434
+ type: "string",
2435
+ description: "Human-readable project name.",
2436
+ },
2437
+ description: {
2438
+ type: "string",
2439
+ description: "Project description.",
2440
+ },
2441
+ stack: {
2442
+ type: "object",
2443
+ description: "Technology stack details (e.g., {language: 'python', framework: 'fastapi'}).",
2444
+ },
2445
+ repo_url: {
2446
+ type: "string",
2447
+ description: "Repository URL.",
2448
+ },
2449
+ },
2450
+ required: ["slug", "name"],
2451
+ },
2452
+ execute: async (
2453
+ _id,
2454
+ args: {
2455
+ slug: string;
2456
+ name: string;
2457
+ description?: string;
2458
+ stack?: Record<string, unknown>;
2459
+ repo_url?: string;
2460
+ },
2461
+ ) => {
2462
+ try {
2463
+ const result = await client.registerProject(
2464
+ args.slug,
2465
+ args.name,
2466
+ args.description,
2467
+ args.stack,
2468
+ args.repo_url,
2469
+ );
2470
+ return {
2471
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2472
+ details: { result },
2473
+ };
2474
+ } catch (err) {
2475
+ return {
2476
+ content: [{ type: "text", text: `Failed to register project: ${String(err)}` }],
2477
+ details: { error: String(err) },
2478
+ };
2479
+ }
2480
+ },
2481
+ },
2482
+ { name: "project_register" },
2483
+ );
2484
+ }
2485
+
2486
+ // --------------------------------------------------------------------------
2487
+ // 30. project_list
2488
+ // --------------------------------------------------------------------------
2489
+ if (isToolEnabled("project_list")) {
2490
+ api.registerTool(
2491
+ {
2492
+ name: "project_list",
2493
+ description: "List all registered projects.",
2494
+ parameters: {
2495
+ type: "object",
2496
+ properties: {
2497
+ limit: {
2498
+ type: "number",
2499
+ description: "Maximum projects to return. Default 20.",
2500
+ minimum: 1,
2501
+ maximum: 100,
2502
+ },
2503
+ },
2504
+ },
2505
+ execute: async (_id, args: { limit?: number }) => {
2506
+ try {
2507
+ const result = await client.listProjects(args.limit);
2508
+ return {
2509
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2510
+ details: { result },
2511
+ };
2512
+ } catch (err) {
2513
+ return {
2514
+ content: [{ type: "text", text: `Failed to list projects: ${String(err)}` }],
2515
+ details: { error: String(err) },
2516
+ };
2517
+ }
2518
+ },
2519
+ },
2520
+ { name: "project_list" },
2521
+ );
2522
+ }
2523
+
2524
+ // --------------------------------------------------------------------------
2525
+ // 31. project_info
2526
+ // --------------------------------------------------------------------------
2527
+ if (isToolEnabled("project_info")) {
2528
+ api.registerTool(
2529
+ {
2530
+ name: "project_info",
2531
+ description: "Get detailed information about a specific project.",
2532
+ parameters: {
2533
+ type: "object",
2534
+ properties: {
2535
+ slug: {
2536
+ type: "string",
2537
+ description: "Project slug.",
2538
+ },
2539
+ },
2540
+ required: ["slug"],
2541
+ },
2542
+ execute: async (_id, args: { slug: string }) => {
2543
+ try {
2544
+ const result = await client.getProject(args.slug);
2545
+ return {
2546
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2547
+ details: { result },
2548
+ };
2549
+ } catch (err) {
2550
+ return {
2551
+ content: [{ type: "text", text: `Failed to get project: ${String(err)}` }],
2552
+ details: { error: String(err) },
2553
+ };
2554
+ }
2555
+ },
2556
+ },
2557
+ { name: "project_info" },
2558
+ );
2559
+ }
2560
+
2561
+ // --------------------------------------------------------------------------
2562
+ // 32. project_add_relationship
2563
+ // --------------------------------------------------------------------------
2564
+ if (isToolEnabled("project_add_relationship")) {
2565
+ api.registerTool(
2566
+ {
2567
+ name: "project_add_relationship",
2568
+ description:
2569
+ "Add a relationship between two projects (e.g., depends_on, api_consumer, shares_schema, shares_infra, pattern_source, forked_from).",
2570
+ parameters: {
2571
+ type: "object",
2572
+ properties: {
2573
+ from: {
2574
+ type: "string",
2575
+ description: "Source project slug.",
2576
+ },
2577
+ to: {
2578
+ type: "string",
2579
+ description: "Target project slug.",
2580
+ },
2581
+ type: {
2582
+ type: "string",
2583
+ description: "Relationship type (e.g., depends_on, api_consumer, shares_schema, shares_infra, pattern_source, forked_from).",
2584
+ },
2585
+ metadata: {
2586
+ type: "object",
2587
+ description: "Optional metadata about the relationship.",
2588
+ },
2589
+ },
2590
+ required: ["from", "to", "type"],
2591
+ },
2592
+ execute: async (
2593
+ _id,
2594
+ args: { from: string; to: string; type: string; metadata?: Record<string, unknown> },
2595
+ ) => {
2596
+ try {
2597
+ const result = await client.addProjectRelationship(
2598
+ args.from,
2599
+ args.to,
2600
+ args.type,
2601
+ args.metadata,
2602
+ );
2603
+ return {
2604
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2605
+ details: { result },
2606
+ };
2607
+ } catch (err) {
2608
+ return {
2609
+ content: [{ type: "text", text: `Failed to add relationship: ${String(err)}` }],
2610
+ details: { error: String(err) },
2611
+ };
2612
+ }
2613
+ },
2614
+ },
2615
+ { name: "project_add_relationship" },
2616
+ );
2617
+ }
2618
+
2619
+ // --------------------------------------------------------------------------
2620
+ // 33. project_dependencies
2621
+ // --------------------------------------------------------------------------
2622
+ if (isToolEnabled("project_dependencies")) {
2623
+ api.registerTool(
2624
+ {
2625
+ name: "project_dependencies",
2626
+ description: "List projects that a given project depends on.",
2627
+ parameters: {
2628
+ type: "object",
2629
+ properties: {
2630
+ project: {
2631
+ type: "string",
2632
+ description: "Project slug.",
2633
+ },
2634
+ },
2635
+ required: ["project"],
2636
+ },
2637
+ execute: async (_id, args: { project: string }) => {
2638
+ try {
2639
+ const result = await client.getProjectDependencies(args.project);
2640
+ return {
2641
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2642
+ details: { result },
2643
+ };
2644
+ } catch (err) {
2645
+ return {
2646
+ content: [{ type: "text", text: `Failed to get dependencies: ${String(err)}` }],
2647
+ details: { error: String(err) },
2648
+ };
2649
+ }
2650
+ },
2651
+ },
2652
+ { name: "project_dependencies" },
2653
+ );
2654
+ }
2655
+
2656
+ // --------------------------------------------------------------------------
2657
+ // 34. project_dependents
2658
+ // --------------------------------------------------------------------------
2659
+ if (isToolEnabled("project_dependents")) {
2660
+ api.registerTool(
2661
+ {
2662
+ name: "project_dependents",
2663
+ description: "List projects that depend on a given project.",
2664
+ parameters: {
2665
+ type: "object",
2666
+ properties: {
2667
+ project: {
2668
+ type: "string",
2669
+ description: "Project slug.",
2670
+ },
2671
+ },
2672
+ required: ["project"],
2673
+ },
2674
+ execute: async (_id, args: { project: string }) => {
2675
+ try {
2676
+ const result = await client.getProjectDependents(args.project);
2677
+ return {
2678
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2679
+ details: { result },
2680
+ };
2681
+ } catch (err) {
2682
+ return {
2683
+ content: [{ type: "text", text: `Failed to get dependents: ${String(err)}` }],
2684
+ details: { error: String(err) },
2685
+ };
2686
+ }
2687
+ },
2688
+ },
2689
+ { name: "project_dependents" },
2690
+ );
2691
+ }
2692
+
2693
+ // --------------------------------------------------------------------------
2694
+ // 35. project_related
2695
+ // --------------------------------------------------------------------------
2696
+ if (isToolEnabled("project_related")) {
2697
+ api.registerTool(
2698
+ {
2699
+ name: "project_related",
2700
+ description: "List all projects related to a given project (any relationship direction).",
2701
+ parameters: {
2702
+ type: "object",
2703
+ properties: {
2704
+ project: {
2705
+ type: "string",
2706
+ description: "Project slug.",
2707
+ },
2708
+ },
2709
+ required: ["project"],
2710
+ },
2711
+ execute: async (_id, args: { project: string }) => {
2712
+ try {
2713
+ const result = await client.getProjectRelated(args.project);
2714
+ return {
2715
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2716
+ details: { result },
2717
+ };
2718
+ } catch (err) {
2719
+ return {
2720
+ content: [{ type: "text", text: `Failed to get related projects: ${String(err)}` }],
2721
+ details: { error: String(err) },
2722
+ };
2723
+ }
2724
+ },
2725
+ },
2726
+ { name: "project_related" },
2727
+ );
2728
+ }
2729
+
2730
+ // --------------------------------------------------------------------------
2731
+ // 36. project_impact
2732
+ // --------------------------------------------------------------------------
2733
+ if (isToolEnabled("project_impact")) {
2734
+ api.registerTool(
2735
+ {
2736
+ name: "project_impact",
2737
+ description:
2738
+ "Analyze the impact of a proposed change on a project and its dependents. Helps understand blast radius before making changes.",
2739
+ parameters: {
2740
+ type: "object",
2741
+ properties: {
2742
+ project: {
2743
+ type: "string",
2744
+ description: "Project slug to analyze.",
2745
+ },
2746
+ change_description: {
2747
+ type: "string",
2748
+ description: "Description of the proposed change.",
2749
+ },
2750
+ },
2751
+ required: ["project", "change_description"],
2752
+ },
2753
+ execute: async (_id, args: { project: string; change_description: string }) => {
2754
+ try {
2755
+ const result = await client.projectImpact(args.project, args.change_description);
2756
+ return {
2757
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2758
+ details: { result },
2759
+ };
2760
+ } catch (err) {
2761
+ return {
2762
+ content: [{ type: "text", text: `Failed to analyze impact: ${String(err)}` }],
2763
+ details: { error: String(err) },
2764
+ };
2765
+ }
2766
+ },
2767
+ },
2768
+ { name: "project_impact" },
2769
+ );
2770
+ }
2771
+
2772
+ // --------------------------------------------------------------------------
2773
+ // 37. project_shared_patterns
2774
+ // --------------------------------------------------------------------------
2775
+ if (isToolEnabled("project_shared_patterns")) {
2776
+ api.registerTool(
2777
+ {
2778
+ name: "project_shared_patterns",
2779
+ description: "Find patterns shared between two projects. Useful for maintaining consistency across related projects.",
2780
+ parameters: {
2781
+ type: "object",
2782
+ properties: {
2783
+ project_a: {
2784
+ type: "string",
2785
+ description: "First project slug.",
2786
+ },
2787
+ project_b: {
2788
+ type: "string",
2789
+ description: "Second project slug.",
2790
+ },
2791
+ },
2792
+ required: ["project_a", "project_b"],
2793
+ },
2794
+ execute: async (_id, args: { project_a: string; project_b: string }) => {
2795
+ try {
2796
+ const result = await client.getSharedPatterns(args.project_a, args.project_b);
2797
+ return {
2798
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2799
+ details: { result },
2800
+ };
2801
+ } catch (err) {
2802
+ return {
2803
+ content: [{ type: "text", text: `Failed to get shared patterns: ${String(err)}` }],
2804
+ details: { error: String(err) },
2805
+ };
2806
+ }
2807
+ },
2808
+ },
2809
+ { name: "project_shared_patterns" },
2810
+ );
2811
+ }
2812
+
2813
+ // --------------------------------------------------------------------------
2814
+ // 38. project_context
2815
+ // --------------------------------------------------------------------------
2816
+ if (isToolEnabled("project_context")) {
2817
+ api.registerTool(
2818
+ {
2819
+ name: "project_context",
2820
+ description:
2821
+ "Load full project context including hot-tier memories, active decisions, adopted patterns, and recent sessions. Call this FIRST when starting work on a project to understand existing context before making changes.",
2822
+ parameters: {
2823
+ type: "object",
2824
+ properties: {
2825
+ project: {
2826
+ type: "string",
2827
+ description: "Project slug.",
2828
+ },
2829
+ },
2830
+ required: ["project"],
2831
+ },
2832
+ execute: async (_id, args: { project: string }) => {
2833
+ try {
2834
+ const result = await client.getProjectContext(args.project);
2835
+ return {
2836
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2837
+ details: { result },
2838
+ };
2839
+ } catch (err) {
2840
+ return {
2841
+ content: [{ type: "text", text: `Failed to load project context: ${String(err)}` }],
2842
+ details: { error: String(err) },
2843
+ };
2844
+ }
2845
+ },
2846
+ },
2847
+ { name: "project_context" },
2848
+ );
2849
+ }
2850
+
2851
+ // --------------------------------------------------------------------------
2852
+ // 39. memory_health
2853
+ // --------------------------------------------------------------------------
2854
+ if (isToolEnabled("memory_health")) {
2855
+ api.registerTool(
2856
+ {
2857
+ name: "memory_health",
2858
+ description: "Check the MemoryRelay API connectivity and health status.",
2859
+ parameters: {
2860
+ type: "object",
2861
+ properties: {},
2862
+ },
2863
+ execute: async () => {
2864
+ try {
2865
+ const health = await client.health();
2866
+ return {
2867
+ content: [{ type: "text", text: JSON.stringify(health, null, 2) }],
2868
+ details: { health },
2869
+ };
2870
+ } catch (err) {
2871
+ return {
2872
+ content: [{ type: "text", text: `Health check failed: ${String(err)}` }],
2873
+ details: { error: String(err) },
2874
+ };
2875
+ }
2876
+ },
2877
+ },
2878
+ { name: "memory_health" },
2879
+ );
2880
+ }
2881
+
2882
+ // ========================================================================
2883
+ // CLI Commands
2884
+ // ========================================================================
2885
+
2886
+ api.registerCli(
2887
+ ({ program }) => {
2888
+ const mem = program.command("memoryrelay").description("MemoryRelay memory plugin commands");
2889
+
2890
+ mem
2891
+ .command("status")
2892
+ .description("Check MemoryRelay connection status")
2893
+ .action(async () => {
2894
+ try {
2895
+ const health = await client.health();
2896
+ const stats = await client.stats();
2897
+ console.log(`Status: ${health.status}`);
2898
+ console.log(`Agent ID: ${agentId}`);
2899
+ console.log(`API: ${apiUrl}`);
2900
+ console.log(`Total Memories: ${stats.total_memories}`);
2901
+ if (stats.last_updated) {
2902
+ console.log(`Last Updated: ${new Date(stats.last_updated).toLocaleString()}`);
2903
+ }
2904
+ } catch (err) {
2905
+ console.error(`Connection failed: ${String(err)}`);
2906
+ }
2907
+ });
2908
+
2909
+ mem
2910
+ .command("stats")
2911
+ .description("Show agent statistics")
2912
+ .action(async () => {
2913
+ try {
2914
+ const stats = await client.stats();
2915
+ console.log(`Total Memories: ${stats.total_memories}`);
2916
+ if (stats.last_updated) {
2917
+ console.log(`Last Updated: ${new Date(stats.last_updated).toLocaleString()}`);
2918
+ }
2919
+ } catch (err) {
2920
+ console.error(`Failed to fetch stats: ${String(err)}`);
2921
+ }
2922
+ });
2923
+
2924
+ mem
2925
+ .command("list")
2926
+ .description("List recent memories")
2927
+ .option("--limit <n>", "Max results", "10")
2928
+ .action(async (opts) => {
2929
+ try {
2930
+ const memories = await client.list(parseInt(opts.limit));
2931
+ for (const m of memories) {
2932
+ console.log(`[${m.id.slice(0, 8)}] ${m.content.slice(0, 80)}...`);
2933
+ }
2934
+ console.log(`\nTotal: ${memories.length} memories`);
2935
+ } catch (err) {
2936
+ console.error(`Failed to list memories: ${String(err)}`);
2937
+ }
2938
+ });
2939
+
2940
+ mem
2941
+ .command("search")
2942
+ .description("Search memories")
2943
+ .argument("<query>", "Search query")
2944
+ .option("--limit <n>", "Max results", "5")
2945
+ .action(async (query, opts) => {
2946
+ try {
2947
+ const results = await client.search(query, parseInt(opts.limit));
2948
+ for (const r of results) {
2949
+ console.log(`[${r.score.toFixed(2)}] ${r.memory.content.slice(0, 80)}...`);
2950
+ }
2951
+ } catch (err) {
2952
+ console.error(`Search failed: ${String(err)}`);
2953
+ }
2954
+ });
2955
+
2956
+ mem
2957
+ .command("delete")
2958
+ .description("Delete a memory by ID")
2959
+ .argument("<id>", "Memory ID")
2960
+ .action(async (id) => {
2961
+ try {
2962
+ await client.delete(id);
2963
+ console.log(`Memory ${id.slice(0, 8)}... deleted.`);
2964
+ } catch (err) {
2965
+ console.error(`Delete failed: ${String(err)}`);
2966
+ }
2967
+ });
2968
+
2969
+ mem
2970
+ .command("export")
2971
+ .description("Export all memories to JSON file")
2972
+ .option("--output <path>", "Output file path", "memories-export.json")
2973
+ .action(async (opts) => {
2974
+ try {
2975
+ console.log("Exporting memories...");
2976
+ const memories = await client.export();
2977
+ const fs = await import("fs/promises");
2978
+ await fs.writeFile(opts.output, JSON.stringify(memories, null, 2));
2979
+ console.log(`Exported ${memories.length} memories to ${opts.output}`);
2980
+ } catch (err) {
2981
+ console.error(`Export failed: ${String(err)}`);
2982
+ }
2983
+ });
2984
+ },
2985
+ { commands: ["memoryrelay"] },
2986
+ );
2987
+
2988
+ // ========================================================================
2989
+ // Lifecycle Hooks
2990
+ // ========================================================================
2991
+
2992
+ // Workflow instructions + auto-recall: always inject workflow guidance,
2993
+ // optionally recall relevant memories if autoRecall is enabled
2994
+ api.on("before_agent_start", async (event) => {
2995
+ if (!event.prompt || event.prompt.length < 10) {
2996
+ return;
2997
+ }
2998
+
2999
+ // Check if current channel is excluded
3000
+ if (cfg?.excludeChannels && event.channel) {
3001
+ const channelId = String(event.channel);
3002
+ if (cfg.excludeChannels.some((excluded) => channelId.includes(excluded))) {
3003
+ api.logger.debug?.(
3004
+ `memory-memoryrelay: skipping for excluded channel: ${channelId}`,
3005
+ );
3006
+ return;
3007
+ }
3008
+ }
3009
+
3010
+ // Build workflow instructions dynamically based on enabled tools
3011
+ const lines: string[] = [
3012
+ "You have MemoryRelay tools available for persistent memory across sessions.",
3013
+ ];
3014
+
3015
+ if (defaultProject) {
3016
+ lines.push(`Default project: \`${defaultProject}\` (auto-applied when you omit the project parameter).`);
3017
+ }
3018
+
3019
+ lines.push("", "## Recommended Workflow", "");
3020
+
3021
+ // Starting work section — only include steps for enabled tools
3022
+ const startSteps: string[] = [];
3023
+ if (isToolEnabled("project_context")) {
3024
+ startSteps.push(`**Load context**: Call \`project_context(${defaultProject ? `"${defaultProject}"` : "project"})\` to load hot-tier memories, active decisions, and adopted patterns`);
3025
+ }
3026
+ if (isToolEnabled("session_start")) {
3027
+ startSteps.push(`**Start session**: Call \`session_start(title${defaultProject ? "" : ", project"})\` to begin tracking your work`);
3028
+ }
3029
+ if (isToolEnabled("decision_check")) {
3030
+ startSteps.push(`**Check decisions**: Call \`decision_check(query${defaultProject ? "" : ", project"})\` before making architectural choices`);
3031
+ }
3032
+ if (isToolEnabled("pattern_search")) {
3033
+ startSteps.push("**Find patterns**: Call `pattern_search(query)` to find established conventions before writing code");
3034
+ }
3035
+
3036
+ if (startSteps.length > 0) {
3037
+ lines.push("When starting work on a project:");
3038
+ startSteps.forEach((step, i) => lines.push(`${i + 1}. ${step}`));
3039
+ lines.push("");
3040
+ }
3041
+
3042
+ // While working section
3043
+ const workSteps: string[] = [];
3044
+ if (isToolEnabled("memory_store")) {
3045
+ workSteps.push("**Store findings**: Call `memory_store(content, metadata)` for important information worth remembering");
3046
+ }
3047
+ if (isToolEnabled("decision_record")) {
3048
+ workSteps.push(`**Record decisions**: Call \`decision_record(title, rationale${defaultProject ? "" : ", project"})\` when making significant architectural choices`);
3049
+ }
3050
+ if (isToolEnabled("pattern_create")) {
3051
+ workSteps.push("**Create patterns**: Call `pattern_create(title, description)` when establishing reusable conventions");
3052
+ }
3053
+
3054
+ if (workSteps.length > 0) {
3055
+ lines.push("While working:");
3056
+ const offset = startSteps.length;
3057
+ workSteps.forEach((step, i) => lines.push(`${offset + i + 1}. ${step}`));
3058
+ lines.push("");
3059
+ }
3060
+
3061
+ // When done section
3062
+ if (isToolEnabled("session_end")) {
3063
+ const offset = startSteps.length + workSteps.length;
3064
+ lines.push("When done:");
3065
+ lines.push(`${offset + 1}. **End session**: Call \`session_end(session_id, summary)\` with a summary of what was accomplished`);
3066
+ lines.push("");
3067
+ }
3068
+
3069
+ // First-time setup — only if project tools are enabled
3070
+ if (isToolEnabled("project_register")) {
3071
+ lines.push("## First-Time Setup", "");
3072
+ lines.push("If the project is not yet registered, start with:");
3073
+ lines.push("1. `project_register(slug, name, description, stack)` to register the project");
3074
+ lines.push("2. Then follow the workflow above");
3075
+ lines.push("");
3076
+ if (isToolEnabled("project_list")) {
3077
+ lines.push("Use `project_list()` to see existing projects before registering a new one.");
3078
+ }
3079
+ }
3080
+
3081
+ // Memory-only fallback — if no session/decision/project tools are enabled
3082
+ if (startSteps.length === 0 && workSteps.length === 0) {
3083
+ lines.push("Use `memory_store(content)` to save important information and `memory_recall(query)` to find relevant memories.");
3084
+ }
3085
+
3086
+ const workflowInstructions = lines.join("\n");
3087
+
3088
+ let prependContext = `<memoryrelay-workflow>\n${workflowInstructions}\n</memoryrelay-workflow>`;
3089
+
3090
+ // Auto-recall: search and inject relevant memories
3091
+ if (cfg?.autoRecall) {
3092
+ try {
3093
+ const results = await client.search(
3094
+ event.prompt,
3095
+ cfg.recallLimit || 5,
3096
+ cfg.recallThreshold || 0.3,
3097
+ );
3098
+
3099
+ if (results.length > 0) {
3100
+ const memoryContext = results.map((r) => `- ${r.memory.content}`).join("\n");
3101
+
3102
+ api.logger.info?.(
3103
+ `memory-memoryrelay: injecting ${results.length} memories into context`,
3104
+ );
3105
+
3106
+ prependContext +=
3107
+ `\n\n<relevant-memories>\nThe following memories from MemoryRelay may be relevant:\n${memoryContext}\n</relevant-memories>`;
3108
+ }
3109
+ } catch (err) {
3110
+ api.logger.warn?.(`memory-memoryrelay: recall failed: ${String(err)}`);
3111
+ }
3112
+ }
3113
+
3114
+ return { prependContext };
3115
+ });
3116
+
3117
+ // Auto-capture: analyze and store important information after agent ends
3118
+ if (cfg?.autoCapture) {
3119
+ api.on("agent_end", async (event) => {
3120
+ if (!event.success || !event.messages || event.messages.length === 0) {
3121
+ return;
3122
+ }
3123
+
3124
+ try {
3125
+ const texts: string[] = [];
3126
+ for (const msg of event.messages) {
3127
+ if (!msg || typeof msg !== "object") continue;
3128
+ const msgObj = msg as Record<string, unknown>;
3129
+ const role = msgObj.role;
3130
+ if (role !== "user" && role !== "assistant") continue;
3131
+
3132
+ const content = msgObj.content;
3133
+ if (typeof content === "string") {
3134
+ texts.push(content);
3135
+ } else if (Array.isArray(content)) {
3136
+ for (const block of content) {
3137
+ if (
3138
+ block &&
3139
+ typeof block === "object" &&
3140
+ "type" in block &&
3141
+ (block as Record<string, unknown>).type === "text" &&
3142
+ "text" in block
3143
+ ) {
3144
+ texts.push((block as Record<string, unknown>).text as string);
3145
+ }
3146
+ }
3147
+ }
3148
+ }
3149
+
3150
+ const toCapture = texts.filter((text) => text && shouldCapture(text));
3151
+ if (toCapture.length === 0) return;
3152
+
3153
+ let stored = 0;
3154
+ for (const text of toCapture.slice(0, 3)) {
3155
+ // Check for duplicates via search
3156
+ const existing = await client.search(text, 1, 0.95);
3157
+ if (existing.length > 0) continue;
3158
+
3159
+ await client.store(text, { source: "auto-capture" });
3160
+ stored++;
3161
+ }
3162
+
3163
+ if (stored > 0) {
3164
+ api.logger.info?.(`memory-memoryrelay: auto-captured ${stored} memories`);
3165
+ }
3166
+ } catch (err) {
3167
+ api.logger.warn?.(`memory-memoryrelay: capture failed: ${String(err)}`);
3168
+ }
3169
+ });
3170
+ }
494
3171
 
495
3172
  api.logger.info?.(
496
- `memory-memoryrelay: plugin loaded (autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture})`,
3173
+ `memory-memoryrelay: plugin v0.7.0 loaded (39 tools, autoRecall: ${cfg?.autoRecall}, autoCapture: ${cfg?.autoCapture})`,
497
3174
  );
498
3175
  }