@memoryrelay/plugin-memoryrelay-ai 0.12.10 → 0.13.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,12 +1,13 @@
1
1
  /**
2
2
  * OpenClaw Memory Plugin - MemoryRelay
3
- * Version: 0.12.8 (Session Context Integration)
3
+ * Version: 0.13.0 (SDK Enhancements)
4
4
  *
5
5
  * Long-term memory with vector search using MemoryRelay API.
6
6
  * Provides auto-recall and auto-capture via lifecycle hooks.
7
7
  * Includes: memories, entities, agents, sessions, decisions, patterns, projects.
8
- * New in v0.12.6: OpenClaw session context integration for session tracking
9
- * New in v0.12.3: Smart auto-capture, daily stats, CLI commands, onboarding
8
+ * New in v0.13.0: External session IDs, get-or-create sessions, multi-agent collaboration
9
+ * New in v0.12.7: OpenClaw session context integration for session tracking
10
+ * New in v0.12.0: Smart auto-capture, daily stats, CLI commands, onboarding
10
11
  *
11
12
  * API: https://api.memoryrelay.net
12
13
  * Docs: https://memoryrelay.ai
@@ -583,6 +584,44 @@ function isBlocklisted(content: string, blocklist: string[]): boolean {
583
584
  });
584
585
  }
585
586
 
587
+ /**
588
+ * Redact sensitive patterns from content using the blocklist.
589
+ * Returns the content with matches replaced by [REDACTED].
590
+ */
591
+ function redactSensitive(content: string, blocklist: string[]): string {
592
+ let redacted = content;
593
+ for (const pattern of blocklist) {
594
+ try {
595
+ redacted = redacted.replace(new RegExp(pattern, "gi"), "[REDACTED]");
596
+ } catch {
597
+ // Invalid regex, skip
598
+ }
599
+ }
600
+ return redacted;
601
+ }
602
+
603
+ /**
604
+ * Extract storable content from messages about to be lost (compaction/reset).
605
+ * Only keeps assistant messages longer than 200 chars.
606
+ * Respects the privacy blocklist.
607
+ */
608
+ function extractRescueContent(
609
+ messages: unknown[],
610
+ blocklist: string[]
611
+ ): string[] {
612
+ const rescued: string[] = [];
613
+ for (const msg of messages) {
614
+ if (!msg || typeof msg !== "object") continue;
615
+ const m = msg as Record<string, unknown>;
616
+ if (m.role !== "assistant") continue;
617
+ const content = typeof m.content === "string" ? m.content : "";
618
+ if (content.length < 200) continue;
619
+ if (isBlocklisted(content, blocklist)) continue;
620
+ rescued.push(content.slice(0, 500));
621
+ }
622
+ return rescued.slice(0, 3);
623
+ }
624
+
586
625
  /**
587
626
  * Mask sensitive data in content (API keys, tokens, etc.)
588
627
  */
@@ -670,7 +709,7 @@ class MemoryRelayClient {
670
709
  headers: {
671
710
  "Content-Type": "application/json",
672
711
  Authorization: `Bearer ${this.apiKey}`,
673
- "User-Agent": "openclaw-memory-memoryrelay/0.8.0",
712
+ "User-Agent": "openclaw-memory-memoryrelay/0.13.0",
674
713
  },
675
714
  body: body ? JSON.stringify(body) : undefined,
676
715
  },
@@ -974,6 +1013,22 @@ class MemoryRelayClient {
974
1013
  });
975
1014
  }
976
1015
 
1016
+ async getOrCreateSession(
1017
+ external_id: string,
1018
+ agent_id?: string,
1019
+ title?: string,
1020
+ project?: string,
1021
+ metadata?: Record<string, string>,
1022
+ ): Promise<any> {
1023
+ return this.request("POST", "/v1/sessions/get-or-create", {
1024
+ external_id,
1025
+ agent_id: agent_id || this.agentId,
1026
+ title,
1027
+ project,
1028
+ metadata,
1029
+ });
1030
+ }
1031
+
977
1032
  async endSession(id: string, summary?: string): Promise<any> {
978
1033
  return this.request("PUT", `/v1/sessions/${id}/end`, { summary });
979
1034
  }
@@ -1004,6 +1059,7 @@ class MemoryRelayClient {
1004
1059
  project?: string,
1005
1060
  tags?: string[],
1006
1061
  status?: string,
1062
+ metadata?: Record<string, string>,
1007
1063
  ): Promise<any> {
1008
1064
  return this.request("POST", "/v1/decisions", {
1009
1065
  title,
@@ -1012,6 +1068,7 @@ class MemoryRelayClient {
1012
1068
  project_slug: project,
1013
1069
  tags,
1014
1070
  status,
1071
+ metadata,
1015
1072
  agent_id: this.agentId,
1016
1073
  });
1017
1074
  }
@@ -1298,7 +1355,9 @@ export default async function plugin(api: OpenClawPluginApi): Promise<void> {
1298
1355
  const verboseEnabled = cfg?.verbose || false;
1299
1356
  const logFile = cfg?.logFile;
1300
1357
  const maxLogEntries = cfg?.maxLogEntries || 100;
1301
-
1358
+ const sessionTimeoutMs = ((cfg?.sessionTimeoutMinutes as number) || 120) * 60 * 1000;
1359
+ const sessionCleanupIntervalMs = ((cfg?.sessionCleanupIntervalMinutes as number) || 30) * 60 * 1000;
1360
+
1302
1361
  let debugLogger: DebugLogger | undefined;
1303
1362
  let statusReporter: StatusReporter | undefined;
1304
1363
 
@@ -1316,6 +1375,79 @@ export default async function plugin(api: OpenClawPluginApi): Promise<void> {
1316
1375
 
1317
1376
  const client = new MemoryRelayClient(apiKey, agentId, apiUrl, debugLogger, statusReporter);
1318
1377
 
1378
+ // ========================================================================
1379
+ // Session Cache for External Session IDs (v0.13.0)
1380
+ // ========================================================================
1381
+
1382
+ /**
1383
+ * Cache mapping: external_id → MemoryRelay session_id
1384
+ * Enables multi-agent collaboration and conversation-spanning sessions
1385
+ */
1386
+ const sessionCache = new Map<string, { sessionId: string; lastActivityAt: number }>();
1387
+
1388
+ /**
1389
+ * Get or create MemoryRelay session for current workspace/project context.
1390
+ * Uses external_id as semantic key for multi-agent collaboration.
1391
+ *
1392
+ * @param project - Project slug (from context or user args)
1393
+ * @param workspaceDir - Workspace directory path
1394
+ * @returns MemoryRelay session UUID, or null if session creation disabled
1395
+ */
1396
+ async function getContextSession(
1397
+ project?: string,
1398
+ workspaceDir?: string
1399
+ ): Promise<string | null> {
1400
+ // If no project context, don't auto-create session
1401
+ if (!project && !workspaceDir) {
1402
+ return null;
1403
+ }
1404
+
1405
+ // Generate external_id from project or workspace
1406
+ const externalId = project ||
1407
+ (workspaceDir ? `workspace-${workspaceDir.split(/[/\\]/).pop()}` : null);
1408
+
1409
+ if (!externalId) {
1410
+ return null;
1411
+ }
1412
+
1413
+ // Check cache first
1414
+ if (sessionCache.has(externalId)) {
1415
+ api.logger.debug?.(`Session: Cache hit for external_id="${externalId}"`);
1416
+ touchSession(externalId);
1417
+ return sessionCache.get(externalId)!.sessionId;
1418
+ }
1419
+
1420
+ try {
1421
+ // Get or create session via new API endpoint
1422
+ const response = await client.getOrCreateSession(
1423
+ externalId,
1424
+ agentId,
1425
+ project ? `${project} work session` : `Workspace ${externalId}`,
1426
+ project,
1427
+ { source: "openclaw-plugin", agent: agentId }
1428
+ );
1429
+
1430
+ // Cache the mapping
1431
+ sessionCache.set(externalId, { sessionId: response.id, lastActivityAt: Date.now() });
1432
+
1433
+ api.logger.debug?.(
1434
+ `Session: ${response.created ? 'Created' : 'Retrieved'} session ${response.id} for external_id="${externalId}"`
1435
+ );
1436
+
1437
+ return response.id;
1438
+ } catch (err) {
1439
+ api.logger.debug?.(`Session: Failed to get-or-create session for ${externalId}: ${String(err)}`);
1440
+ return null;
1441
+ }
1442
+ }
1443
+
1444
+ function touchSession(externalId: string): void {
1445
+ const entry = sessionCache.get(externalId);
1446
+ if (entry) {
1447
+ entry.lastActivityAt = Date.now();
1448
+ }
1449
+ }
1450
+
1319
1451
  // Verify connection on startup (with timeout)
1320
1452
  try {
1321
1453
  await client.health();
@@ -1523,6 +1655,10 @@ export default async function plugin(api: OpenClawPluginApi): Promise<void> {
1523
1655
  description: "Memory tier: hot, warm, or cold.",
1524
1656
  enum: ["hot", "warm", "cold"],
1525
1657
  },
1658
+ session_id: {
1659
+ type: "string",
1660
+ description: "Optional MemoryRelay session UUID to associate this memory with. If omitted and project is set, plugin auto-creates session via external_id.",
1661
+ },
1526
1662
  },
1527
1663
  required: ["content"],
1528
1664
  },
@@ -1536,27 +1672,47 @@ export default async function plugin(api: OpenClawPluginApi): Promise<void> {
1536
1672
  project?: string;
1537
1673
  importance?: number;
1538
1674
  tier?: string;
1675
+ session_id?: string; // Allow explicit session_id
1539
1676
  },
1540
1677
  ) => {
1541
1678
  try {
1542
- const { content, metadata, ...opts } = args;
1679
+ const { content, metadata: rawMetadata, session_id: explicitSessionId, ...opts } = args;
1680
+
1681
+ // Auto-tag with sender identity from tool context
1682
+ const metadata = rawMetadata || {};
1683
+ if (ctx.requesterSenderId && !metadata.sender_id) {
1684
+ metadata.sender_id = ctx.requesterSenderId;
1685
+ }
1686
+
1687
+ // Apply defaultProject fallback before session resolution
1688
+ if (!opts.project && defaultProject) opts.project = defaultProject;
1689
+
1690
+ // Get session_id from cache if project context available
1691
+ // Priority: explicit session_id > context session > no session
1692
+ let sessionId: string | undefined = explicitSessionId;
1693
+
1694
+ if (!sessionId && (opts.project || ctx.workspaceDir)) {
1695
+ const contextSessionId = await getContextSession(opts.project, ctx.workspaceDir);
1696
+ if (contextSessionId) {
1697
+ sessionId = contextSessionId;
1698
+ }
1699
+ }
1543
1700
 
1544
- // Inject sessionId from OpenClaw context into metadata
1545
- const enrichedMetadata = {
1546
- ...metadata,
1547
- ...(ctx.sessionId && { session_id: ctx.sessionId }),
1701
+ // Build request options with session_id as top-level parameter
1702
+ const storeOpts = {
1703
+ ...opts,
1704
+ ...(sessionId && { session_id: sessionId }),
1548
1705
  };
1549
1706
 
1550
- if (!opts.project && defaultProject) opts.project = defaultProject;
1551
- const memory = await client.store(content, enrichedMetadata, opts);
1707
+ const memory = await client.store(content, metadata, storeOpts);
1552
1708
  return {
1553
1709
  content: [
1554
1710
  {
1555
1711
  type: "text",
1556
- text: `Memory stored successfully (id: ${memory.id.slice(0, 8)}...)`,
1712
+ text: `Memory stored successfully (id: ${memory.id.slice(0, 8)}...)${sessionId ? ` in session ${sessionId.slice(0, 8)}...` : ''}`,
1557
1713
  },
1558
1714
  ],
1559
- details: { id: memory.id, stored: true },
1715
+ details: { id: memory.id, stored: true, session_id: sessionId },
1560
1716
  };
1561
1717
  } catch (err) {
1562
1718
  return {
@@ -1945,6 +2101,17 @@ export default async function plugin(api: OpenClawPluginApi): Promise<void> {
1945
2101
  args: { memories: Array<{ content: string; metadata?: Record<string, string> }> },
1946
2102
  ) => {
1947
2103
  try {
2104
+ // Auto-tag each memory with sender identity from tool context
2105
+ if (ctx.requesterSenderId) {
2106
+ for (const mem of args.memories) {
2107
+ const metadata = mem.metadata || {};
2108
+ if (!metadata.sender_id) {
2109
+ metadata.sender_id = ctx.requesterSenderId;
2110
+ }
2111
+ mem.metadata = metadata;
2112
+ }
2113
+ }
2114
+
1948
2115
  const result = await client.batchStore(args.memories);
1949
2116
  return {
1950
2117
  content: [
@@ -2626,6 +2793,11 @@ export default async function plugin(api: OpenClawPluginApi): Promise<void> {
2626
2793
  description: "Decision status.",
2627
2794
  enum: ["active", "experimental"],
2628
2795
  },
2796
+ metadata: {
2797
+ type: "object",
2798
+ description: "Optional key-value metadata to attach to the decision.",
2799
+ additionalProperties: { type: "string" },
2800
+ },
2629
2801
  },
2630
2802
  required: ["title", "rationale"],
2631
2803
  },
@@ -2638,10 +2810,18 @@ export default async function plugin(api: OpenClawPluginApi): Promise<void> {
2638
2810
  project?: string;
2639
2811
  tags?: string[];
2640
2812
  status?: string;
2813
+ metadata?: Record<string, string>;
2641
2814
  },
2642
2815
  ) => {
2643
2816
  try {
2644
2817
  const project = args.project ?? defaultProject;
2818
+
2819
+ // Merge user-provided metadata with sender identity from tool context
2820
+ const metadata: Record<string, string> = { ...(args.metadata ?? {}) };
2821
+ if (ctx.requesterSenderId) {
2822
+ metadata.sender_id = ctx.requesterSenderId;
2823
+ }
2824
+
2645
2825
  const result = await client.recordDecision(
2646
2826
  args.title,
2647
2827
  args.rationale,
@@ -2649,6 +2829,7 @@ export default async function plugin(api: OpenClawPluginApi): Promise<void> {
2649
2829
  project,
2650
2830
  args.tags,
2651
2831
  args.status,
2832
+ Object.keys(metadata).length > 0 ? metadata : undefined,
2652
2833
  );
2653
2834
  return {
2654
2835
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
@@ -3863,8 +4044,199 @@ export default async function plugin(api: OpenClawPluginApi): Promise<void> {
3863
4044
  });
3864
4045
  }
3865
4046
 
4047
+ // Session sync: auto-create MemoryRelay session when OpenClaw session starts
4048
+ api.on("session_start", async (event, _ctx) => {
4049
+ try {
4050
+ const externalId = event.sessionKey || event.sessionId;
4051
+ if (!externalId) return;
4052
+
4053
+ const response = await client.getOrCreateSession(
4054
+ externalId,
4055
+ agentId,
4056
+ `OpenClaw session ${externalId}`,
4057
+ defaultProject || undefined,
4058
+ { source: "openclaw-plugin", agent: agentId, trigger: "session_start_hook" },
4059
+ );
4060
+
4061
+ sessionCache.set(externalId, {
4062
+ sessionId: response.id,
4063
+ lastActivityAt: Date.now(),
4064
+ });
4065
+
4066
+ api.logger.debug?.(`memory-memoryrelay: auto-created session ${response.id} for OpenClaw session ${externalId}`);
4067
+ } catch (err) {
4068
+ api.logger.warn?.(`memory-memoryrelay: session_start hook failed: ${String(err)}`);
4069
+ }
4070
+ });
4071
+
4072
+ // Session sync: auto-end MemoryRelay session when OpenClaw session ends
4073
+ api.on("session_end", async (event, _ctx) => {
4074
+ try {
4075
+ const externalId = event.sessionKey || event.sessionId;
4076
+ if (!externalId) return;
4077
+
4078
+ const entry = sessionCache.get(externalId);
4079
+ if (!entry) return;
4080
+
4081
+ await client.endSession(entry.sessionId, `Session ended after ${event.messageCount} messages`);
4082
+ sessionCache.delete(externalId);
4083
+
4084
+ api.logger.debug?.(`memory-memoryrelay: auto-ended session ${entry.sessionId}`);
4085
+ } catch (err) {
4086
+ api.logger.warn?.(`memory-memoryrelay: session_end hook failed: ${String(err)}`);
4087
+ }
4088
+ });
4089
+
4090
+ // ==========================================================================
4091
+ // Tool Observation Hooks
4092
+ // ==========================================================================
4093
+
4094
+ // Tool observation: no-op, registered for future extensibility
4095
+ api.on("before_tool_call", (_event, _ctx) => {
4096
+ // Reserved for future: tool blocking, param injection, audit
4097
+ });
4098
+
4099
+ // Tool observation: update session activity + log metrics
4100
+ api.on("after_tool_call", (event, _ctx) => {
4101
+ // Update activity timestamp on all active sessions
4102
+ for (const entry of sessionCache.values()) {
4103
+ entry.lastActivityAt = Date.now();
4104
+ }
4105
+
4106
+ // Log to debug logger if enabled
4107
+ if (debugLogger) {
4108
+ debugLogger.log({
4109
+ timestamp: new Date().toISOString(),
4110
+ tool: event.toolName,
4111
+ method: "tool_call",
4112
+ path: "",
4113
+ duration: event.durationMs || 0,
4114
+ status: event.error ? "error" : "success",
4115
+ error: event.error,
4116
+ });
4117
+ }
4118
+ });
4119
+
4120
+ // Compaction rescue: save key context before it's lost
4121
+ api.on("before_compaction", async (event, _ctx) => {
4122
+ if (!event.messages || event.messages.length === 0) return;
4123
+ try {
4124
+ const rescued = extractRescueContent(event.messages, autoCaptureConfig.blocklist || []);
4125
+ for (const content of rescued) {
4126
+ await client.store(content, {
4127
+ category: "compaction-rescue",
4128
+ source: "auto-compaction",
4129
+ agent: agentId,
4130
+ });
4131
+ }
4132
+ if (rescued.length > 0) {
4133
+ api.logger.info?.(`memory-memoryrelay: rescued ${rescued.length} memories before compaction`);
4134
+ }
4135
+ } catch (err) {
4136
+ api.logger.warn?.(`memory-memoryrelay: compaction rescue failed: ${String(err)}`);
4137
+ }
4138
+ });
4139
+
4140
+ // Session reset rescue: save key context before session is cleared
4141
+ api.on("before_reset", async (event, _ctx) => {
4142
+ if (!event.messages || event.messages.length === 0) return;
4143
+ try {
4144
+ const rescued = extractRescueContent(event.messages, autoCaptureConfig.blocklist || []);
4145
+ for (const content of rescued) {
4146
+ await client.store(content, {
4147
+ category: "session-reset-rescue",
4148
+ source: "auto-reset",
4149
+ agent: agentId,
4150
+ });
4151
+ }
4152
+ if (rescued.length > 0) {
4153
+ api.logger.info?.(`memory-memoryrelay: rescued ${rescued.length} memories before reset`);
4154
+ }
4155
+ } catch (err) {
4156
+ api.logger.warn?.(`memory-memoryrelay: reset rescue failed: ${String(err)}`);
4157
+ }
4158
+ });
4159
+
4160
+ // Message processing hooks: activity tracking and privacy redaction
4161
+ api.on("message_received", (_event, _ctx) => {
4162
+ // Update activity timestamps on active sessions
4163
+ for (const entry of sessionCache.values()) {
4164
+ entry.lastActivityAt = Date.now();
4165
+ }
4166
+ });
4167
+
4168
+ api.on("message_sending", (_event, _ctx) => {
4169
+ // No-op: registered for future extensibility
4170
+ });
4171
+
4172
+ api.on("before_message_write", (event, _ctx) => {
4173
+ const blocklist = autoCaptureConfig.blocklist || [];
4174
+ if (blocklist.length === 0) return;
4175
+
4176
+ const msg = event.message;
4177
+ if (!msg || typeof msg !== "object") return;
4178
+
4179
+ const m = msg as Record<string, unknown>;
4180
+ if (typeof m.content === "string" && isBlocklisted(m.content, blocklist)) {
4181
+ return {
4182
+ message: {
4183
+ ...msg,
4184
+ content: redactSensitive(m.content as string, blocklist),
4185
+ } as typeof msg,
4186
+ };
4187
+ }
4188
+ });
4189
+
4190
+ // Subagent lifecycle hooks: track multi-agent collaboration
4191
+ api.on("subagent_spawned", async (event, _ctx) => {
4192
+ try {
4193
+ api.logger.debug?.(
4194
+ `memory-memoryrelay: subagent spawned: ${event.agentId} (session: ${event.childSessionKey}, label: ${event.label || "none"})`
4195
+ );
4196
+ } catch (err) {
4197
+ api.logger.warn?.(`memory-memoryrelay: subagent_spawned hook failed: ${String(err)}`);
4198
+ }
4199
+ });
4200
+
4201
+ api.on("subagent_ended", async (event, _ctx) => {
4202
+ try {
4203
+ const outcome = event.outcome || "unknown";
4204
+ const summary = `Subagent ${event.targetSessionKey} ended: ${event.reason} (outcome: ${outcome})`;
4205
+
4206
+ await client.store(summary, {
4207
+ category: "subagent-activity",
4208
+ source: "subagent_ended_hook",
4209
+ agent: agentId,
4210
+ outcome,
4211
+ });
4212
+
4213
+ api.logger.debug?.(`memory-memoryrelay: stored subagent completion: ${summary}`);
4214
+ } catch (err) {
4215
+ api.logger.warn?.(`memory-memoryrelay: subagent_ended hook failed: ${String(err)}`);
4216
+ }
4217
+ });
4218
+
4219
+ // Tool result redaction: apply privacy blocklist before persistence
4220
+ api.on("tool_result_persist", (event, _ctx) => {
4221
+ const blocklist = autoCaptureConfig.blocklist || [];
4222
+ if (blocklist.length === 0) return;
4223
+
4224
+ const msg = event.message;
4225
+ if (!msg || typeof msg !== "object") return;
4226
+
4227
+ const m = msg as Record<string, unknown>;
4228
+ if (typeof m.content === "string" && isBlocklisted(m.content, blocklist)) {
4229
+ return {
4230
+ message: {
4231
+ ...msg,
4232
+ content: redactSensitive(m.content as string, blocklist),
4233
+ } as typeof msg,
4234
+ };
4235
+ }
4236
+ });
4237
+
3866
4238
  api.logger.info?.(
3867
- `memory-memoryrelay: plugin v0.12.10 loaded (39 tools, autoRecall: ${cfg?.autoRecall}, autoCapture: ${autoCaptureConfig.enabled ? autoCaptureConfig.tier : 'off'}, debug: ${debugEnabled})`,
4239
+ `memory-memoryrelay: plugin v0.13.0 loaded (39 tools, autoRecall: ${cfg?.autoRecall}, autoCapture: ${autoCaptureConfig.enabled ? autoCaptureConfig.tier : 'off'}, debug: ${debugEnabled})`,
3868
4240
  );
3869
4241
 
3870
4242
  // ========================================================================
@@ -3924,7 +4296,9 @@ export default async function plugin(api: OpenClawPluginApi): Promise<void> {
3924
4296
  logs = debugLogger.getRecentLogs(limit);
3925
4297
  }
3926
4298
 
3927
- const formatted = DebugLogger.formatTable(logs);
4299
+ const formatted = logs.map((l) =>
4300
+ `[${new Date(l.timestamp).toISOString()}] ${l.level.toUpperCase()} ${l.tool ?? "-"}: ${l.message}`
4301
+ ).join("\n");
3928
4302
  respond(true, {
3929
4303
  logs,
3930
4304
  formatted,
@@ -4210,4 +4584,256 @@ export default async function plugin(api: OpenClawPluginApi): Promise<void> {
4210
4584
  respond(false, { error: String(err) });
4211
4585
  }
4212
4586
  });
4587
+
4588
+ // ========================================================================
4589
+ // Direct Commands (5 total) — bypass LLM, execute immediately
4590
+ // ========================================================================
4591
+
4592
+ // /memory-status — Show full plugin status report
4593
+ api.registerCommand?.({
4594
+ name: "memory-status",
4595
+ description: "Show MemoryRelay connection status, tool counts, and memory stats",
4596
+ requireAuth: true,
4597
+ handler: async (_ctx) => {
4598
+ try {
4599
+ // Get connection status via health check
4600
+ const startTime = Date.now();
4601
+ const healthResult = await client.health();
4602
+ const responseTime = Date.now() - startTime;
4603
+
4604
+ const healthStatus = String(healthResult.status).toLowerCase();
4605
+ const isConnected = VALID_HEALTH_STATUSES.includes(healthStatus);
4606
+
4607
+ const connectionStatus = {
4608
+ status: isConnected ? "connected" as const : "disconnected" as const,
4609
+ endpoint: apiUrl,
4610
+ lastCheck: new Date().toISOString(),
4611
+ responseTime,
4612
+ };
4613
+
4614
+ // Get memory stats
4615
+ let memoryCount = 0;
4616
+ try {
4617
+ const stats = await client.stats();
4618
+ memoryCount = stats.total_memories;
4619
+ } catch (_statsErr) {
4620
+ // stats endpoint may be unavailable
4621
+ }
4622
+
4623
+ const memoryStats = { total_memories: memoryCount };
4624
+
4625
+ const pluginConfig = {
4626
+ agentId,
4627
+ autoRecall: cfg?.autoRecall ?? true,
4628
+ autoCapture: autoCaptureConfig,
4629
+ recallLimit: cfg?.recallLimit ?? 5,
4630
+ recallThreshold: cfg?.recallThreshold ?? 0.3,
4631
+ excludeChannels: cfg?.excludeChannels ?? [],
4632
+ defaultProject,
4633
+ };
4634
+
4635
+ if (statusReporter) {
4636
+ const report = statusReporter.buildReport(
4637
+ connectionStatus,
4638
+ pluginConfig,
4639
+ memoryStats,
4640
+ TOOL_GROUPS,
4641
+ );
4642
+ const formatted = StatusReporter.formatReport(report);
4643
+ return { text: formatted };
4644
+ }
4645
+
4646
+ // Fallback: simple text status
4647
+ return {
4648
+ text: `MemoryRelay: ${isConnected ? "connected" : "disconnected"} | Endpoint: ${apiUrl} | Memories: ${memoryCount} | Agent: ${agentId}`,
4649
+ };
4650
+ } catch (err) {
4651
+ return { text: `Error: ${String(err)}`, isError: true };
4652
+ }
4653
+ },
4654
+ });
4655
+
4656
+ // /memory-stats — Show daily memory statistics
4657
+ api.registerCommand?.({
4658
+ name: "memory-stats",
4659
+ description: "Show daily memory statistics (total, today, weekly growth, top categories)",
4660
+ requireAuth: true,
4661
+ handler: async (_ctx) => {
4662
+ try {
4663
+ const memories = await client.list(1000);
4664
+ const stats = await calculateStats(
4665
+ async () => memories,
4666
+ () => 0,
4667
+ );
4668
+ const formatted = formatStatsForDisplay(stats);
4669
+ return { text: formatted };
4670
+ } catch (err) {
4671
+ return { text: `Error: ${String(err)}`, isError: true };
4672
+ }
4673
+ },
4674
+ });
4675
+
4676
+ // /memory-health — Quick health check with response time
4677
+ api.registerCommand?.({
4678
+ name: "memory-health",
4679
+ description: "Check MemoryRelay API health and response time",
4680
+ requireAuth: true,
4681
+ handler: async (_ctx) => {
4682
+ try {
4683
+ const startTime = Date.now();
4684
+ const healthResult = await client.health();
4685
+ const responseTime = Date.now() - startTime;
4686
+
4687
+ const healthStatus = String(healthResult.status).toLowerCase();
4688
+ const isHealthy = VALID_HEALTH_STATUSES.includes(healthStatus);
4689
+ const symbol = isHealthy ? "OK" : "DEGRADED";
4690
+
4691
+ return {
4692
+ text: `MemoryRelay Health: ${symbol}\n Status: ${healthResult.status}\n Response Time: ${responseTime}ms\n Endpoint: ${apiUrl}`,
4693
+ };
4694
+ } catch (err) {
4695
+ return { text: `MemoryRelay Health: UNREACHABLE\n Error: ${String(err)}`, isError: true };
4696
+ }
4697
+ },
4698
+ });
4699
+
4700
+ // /memory-logs — Show recent debug log entries
4701
+ api.registerCommand?.({
4702
+ name: "memory-logs",
4703
+ description: "Show recent MemoryRelay debug log entries",
4704
+ requireAuth: true,
4705
+ handler: async (_ctx) => {
4706
+ try {
4707
+ if (!debugLogger) {
4708
+ return { text: "Debug logging is disabled. Enable it with debug: true in plugin config." };
4709
+ }
4710
+
4711
+ const logs = debugLogger.getRecentLogs(10);
4712
+ if (logs.length === 0) {
4713
+ return { text: "No recent log entries." };
4714
+ }
4715
+
4716
+ const lines: string[] = ["Recent MemoryRelay Logs", "━".repeat(50)];
4717
+ for (const entry of logs) {
4718
+ const statusSymbol = entry.status === "success" ? "OK" : "ERR";
4719
+ lines.push(
4720
+ `[${entry.timestamp}] ${statusSymbol} ${entry.method} ${entry.path} (${entry.duration}ms)` +
4721
+ (entry.error ? ` - ${entry.error}` : ""),
4722
+ );
4723
+ }
4724
+ return { text: lines.join("\n") };
4725
+ } catch (err) {
4726
+ return { text: `Error: ${String(err)}`, isError: true };
4727
+ }
4728
+ },
4729
+ });
4730
+
4731
+ // /memory-metrics — Show per-tool performance metrics
4732
+ api.registerCommand?.({
4733
+ name: "memory-metrics",
4734
+ description: "Show per-tool call counts, success rates, and latency metrics",
4735
+ requireAuth: true,
4736
+ handler: async (_ctx) => {
4737
+ try {
4738
+ if (!debugLogger) {
4739
+ return { text: "Debug logging is disabled. Enable it with debug: true in plugin config." };
4740
+ }
4741
+
4742
+ const allLogs = debugLogger.getAllLogs();
4743
+ if (allLogs.length === 0) {
4744
+ return { text: "No metrics data available yet." };
4745
+ }
4746
+
4747
+ // Build per-tool metrics
4748
+ const toolMetrics = new Map<string, { calls: number; successes: number; durations: number[] }>();
4749
+ for (const entry of allLogs) {
4750
+ let metrics = toolMetrics.get(entry.tool);
4751
+ if (!metrics) {
4752
+ metrics = { calls: 0, successes: 0, durations: [] };
4753
+ toolMetrics.set(entry.tool, metrics);
4754
+ }
4755
+ metrics.calls++;
4756
+ if (entry.status === "success") metrics.successes++;
4757
+ metrics.durations.push(entry.duration);
4758
+ }
4759
+
4760
+ // Format table
4761
+ const lines: string[] = [
4762
+ "MemoryRelay Tool Metrics",
4763
+ "━".repeat(65),
4764
+ `${"Tool".padEnd(22)} ${"Calls".padStart(6)} ${"Success%".padStart(9)} ${"Avg(ms)".padStart(8)} ${"P95(ms)".padStart(8)}`,
4765
+ "─".repeat(65),
4766
+ ];
4767
+
4768
+ for (const [tool, m] of Array.from(toolMetrics.entries()).sort((a, b) => b[1].calls - a[1].calls)) {
4769
+ const successRate = m.calls > 0 ? ((m.successes / m.calls) * 100).toFixed(1) : "0.0";
4770
+ const avg = m.durations.length > 0
4771
+ ? Math.round(m.durations.reduce((s, d) => s + d, 0) / m.durations.length)
4772
+ : 0;
4773
+ const sorted = [...m.durations].sort((a, b) => a - b);
4774
+ const p95idx = Math.min(Math.ceil(sorted.length * 0.95) - 1, sorted.length - 1);
4775
+ const p95 = sorted.length > 0 ? sorted[Math.max(0, p95idx)] : 0;
4776
+
4777
+ lines.push(
4778
+ `${tool.padEnd(22)} ${String(m.calls).padStart(6)} ${(successRate + "%").padStart(9)} ${String(avg).padStart(8)} ${String(p95).padStart(8)}`,
4779
+ );
4780
+ }
4781
+
4782
+ lines.push("─".repeat(65));
4783
+ lines.push(`Total entries: ${allLogs.length}`);
4784
+
4785
+ return { text: lines.join("\n") };
4786
+ } catch (err) {
4787
+ return { text: `Error: ${String(err)}`, isError: true };
4788
+ }
4789
+ },
4790
+ });
4791
+
4792
+ // ========================================================================
4793
+ // Stale Session Cleanup Service (v0.13.0)
4794
+ // ========================================================================
4795
+
4796
+ let sessionCleanupInterval: ReturnType<typeof setInterval> | null = null;
4797
+
4798
+ api.registerService({
4799
+ id: "memoryrelay-session-cleanup",
4800
+ start: async (_ctx) => {
4801
+ sessionCleanupInterval = setInterval(async () => {
4802
+ const now = Date.now();
4803
+ const staleEntries: string[] = [];
4804
+
4805
+ for (const [externalId, entry] of sessionCache.entries()) {
4806
+ if (now - entry.lastActivityAt > sessionTimeoutMs) {
4807
+ staleEntries.push(externalId);
4808
+ }
4809
+ }
4810
+
4811
+ for (const externalId of staleEntries) {
4812
+ const entry = sessionCache.get(externalId);
4813
+ if (!entry) continue;
4814
+
4815
+ try {
4816
+ await client.endSession(
4817
+ entry.sessionId,
4818
+ `Auto-closed: inactive for >${Math.round(sessionTimeoutMs / 60000)} minutes`
4819
+ );
4820
+ sessionCache.delete(externalId);
4821
+ api.logger.info?.(
4822
+ `memory-memoryrelay: auto-closed stale session ${entry.sessionId} (external: ${externalId})`
4823
+ );
4824
+ } catch (err) {
4825
+ api.logger.warn?.(
4826
+ `memory-memoryrelay: failed to auto-close session ${entry.sessionId}: ${String(err)}`
4827
+ );
4828
+ }
4829
+ }
4830
+ }, sessionCleanupIntervalMs);
4831
+ },
4832
+ stop: async (_ctx) => {
4833
+ if (sessionCleanupInterval) {
4834
+ clearInterval(sessionCleanupInterval);
4835
+ sessionCleanupInterval = null;
4836
+ }
4837
+ },
4838
+ });
4213
4839
  }