@memoryrelay/plugin-memoryrelay-ai 0.12.11 → 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
  },
@@ -1020,6 +1059,7 @@ class MemoryRelayClient {
1020
1059
  project?: string,
1021
1060
  tags?: string[],
1022
1061
  status?: string,
1062
+ metadata?: Record<string, string>,
1023
1063
  ): Promise<any> {
1024
1064
  return this.request("POST", "/v1/decisions", {
1025
1065
  title,
@@ -1028,6 +1068,7 @@ class MemoryRelayClient {
1028
1068
  project_slug: project,
1029
1069
  tags,
1030
1070
  status,
1071
+ metadata,
1031
1072
  agent_id: this.agentId,
1032
1073
  });
1033
1074
  }
@@ -1314,7 +1355,9 @@ export default async function plugin(api: OpenClawPluginApi): Promise<void> {
1314
1355
  const verboseEnabled = cfg?.verbose || false;
1315
1356
  const logFile = cfg?.logFile;
1316
1357
  const maxLogEntries = cfg?.maxLogEntries || 100;
1317
-
1358
+ const sessionTimeoutMs = ((cfg?.sessionTimeoutMinutes as number) || 120) * 60 * 1000;
1359
+ const sessionCleanupIntervalMs = ((cfg?.sessionCleanupIntervalMinutes as number) || 30) * 60 * 1000;
1360
+
1318
1361
  let debugLogger: DebugLogger | undefined;
1319
1362
  let statusReporter: StatusReporter | undefined;
1320
1363
 
@@ -1333,14 +1376,14 @@ export default async function plugin(api: OpenClawPluginApi): Promise<void> {
1333
1376
  const client = new MemoryRelayClient(apiKey, agentId, apiUrl, debugLogger, statusReporter);
1334
1377
 
1335
1378
  // ========================================================================
1336
- // Session Cache for External Session IDs (v0.12.11)
1379
+ // Session Cache for External Session IDs (v0.13.0)
1337
1380
  // ========================================================================
1338
1381
 
1339
1382
  /**
1340
1383
  * Cache mapping: external_id → MemoryRelay session_id
1341
1384
  * Enables multi-agent collaboration and conversation-spanning sessions
1342
1385
  */
1343
- const sessionCache = new Map<string, string>();
1386
+ const sessionCache = new Map<string, { sessionId: string; lastActivityAt: number }>();
1344
1387
 
1345
1388
  /**
1346
1389
  * Get or create MemoryRelay session for current workspace/project context.
@@ -1369,10 +1412,9 @@ export default async function plugin(api: OpenClawPluginApi): Promise<void> {
1369
1412
 
1370
1413
  // Check cache first
1371
1414
  if (sessionCache.has(externalId)) {
1372
- if (debugLogger) {
1373
- debugLogger.log(`Session: Cache hit for external_id="${externalId}"`, "info");
1374
- }
1375
- return sessionCache.get(externalId)!;
1415
+ api.logger.debug?.(`Session: Cache hit for external_id="${externalId}"`);
1416
+ touchSession(externalId);
1417
+ return sessionCache.get(externalId)!.sessionId;
1376
1418
  }
1377
1419
 
1378
1420
  try {
@@ -1386,24 +1428,26 @@ export default async function plugin(api: OpenClawPluginApi): Promise<void> {
1386
1428
  );
1387
1429
 
1388
1430
  // Cache the mapping
1389
- sessionCache.set(externalId, response.id);
1431
+ sessionCache.set(externalId, { sessionId: response.id, lastActivityAt: Date.now() });
1390
1432
 
1391
- if (debugLogger) {
1392
- debugLogger.log(
1393
- `Session: ${response.created ? 'Created' : 'Retrieved'} session ${response.id} for external_id="${externalId}"`,
1394
- "info"
1395
- );
1396
- }
1433
+ api.logger.debug?.(
1434
+ `Session: ${response.created ? 'Created' : 'Retrieved'} session ${response.id} for external_id="${externalId}"`
1435
+ );
1397
1436
 
1398
1437
  return response.id;
1399
1438
  } catch (err) {
1400
- if (debugLogger) {
1401
- debugLogger.log(`Session: Failed to get-or-create session for ${externalId}: ${String(err)}`, "error");
1402
- }
1439
+ api.logger.debug?.(`Session: Failed to get-or-create session for ${externalId}: ${String(err)}`);
1403
1440
  return null;
1404
1441
  }
1405
1442
  }
1406
1443
 
1444
+ function touchSession(externalId: string): void {
1445
+ const entry = sessionCache.get(externalId);
1446
+ if (entry) {
1447
+ entry.lastActivityAt = Date.now();
1448
+ }
1449
+ }
1450
+
1407
1451
  // Verify connection on startup (with timeout)
1408
1452
  try {
1409
1453
  await client.health();
@@ -1632,8 +1676,14 @@ export default async function plugin(api: OpenClawPluginApi): Promise<void> {
1632
1676
  },
1633
1677
  ) => {
1634
1678
  try {
1635
- const { content, metadata, session_id: explicitSessionId, ...opts } = args;
1636
-
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
+
1637
1687
  // Apply defaultProject fallback before session resolution
1638
1688
  if (!opts.project && defaultProject) opts.project = defaultProject;
1639
1689
 
@@ -2051,6 +2101,17 @@ export default async function plugin(api: OpenClawPluginApi): Promise<void> {
2051
2101
  args: { memories: Array<{ content: string; metadata?: Record<string, string> }> },
2052
2102
  ) => {
2053
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
+
2054
2115
  const result = await client.batchStore(args.memories);
2055
2116
  return {
2056
2117
  content: [
@@ -2732,6 +2793,11 @@ export default async function plugin(api: OpenClawPluginApi): Promise<void> {
2732
2793
  description: "Decision status.",
2733
2794
  enum: ["active", "experimental"],
2734
2795
  },
2796
+ metadata: {
2797
+ type: "object",
2798
+ description: "Optional key-value metadata to attach to the decision.",
2799
+ additionalProperties: { type: "string" },
2800
+ },
2735
2801
  },
2736
2802
  required: ["title", "rationale"],
2737
2803
  },
@@ -2744,10 +2810,18 @@ export default async function plugin(api: OpenClawPluginApi): Promise<void> {
2744
2810
  project?: string;
2745
2811
  tags?: string[];
2746
2812
  status?: string;
2813
+ metadata?: Record<string, string>;
2747
2814
  },
2748
2815
  ) => {
2749
2816
  try {
2750
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
+
2751
2825
  const result = await client.recordDecision(
2752
2826
  args.title,
2753
2827
  args.rationale,
@@ -2755,6 +2829,7 @@ export default async function plugin(api: OpenClawPluginApi): Promise<void> {
2755
2829
  project,
2756
2830
  args.tags,
2757
2831
  args.status,
2832
+ Object.keys(metadata).length > 0 ? metadata : undefined,
2758
2833
  );
2759
2834
  return {
2760
2835
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
@@ -3969,8 +4044,199 @@ export default async function plugin(api: OpenClawPluginApi): Promise<void> {
3969
4044
  });
3970
4045
  }
3971
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
+
3972
4238
  api.logger.info?.(
3973
- `memory-memoryrelay: plugin v0.12.11 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})`,
3974
4240
  );
3975
4241
 
3976
4242
  // ========================================================================
@@ -4030,7 +4296,9 @@ export default async function plugin(api: OpenClawPluginApi): Promise<void> {
4030
4296
  logs = debugLogger.getRecentLogs(limit);
4031
4297
  }
4032
4298
 
4033
- 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");
4034
4302
  respond(true, {
4035
4303
  logs,
4036
4304
  formatted,
@@ -4316,4 +4584,256 @@ export default async function plugin(api: OpenClawPluginApi): Promise<void> {
4316
4584
  respond(false, { error: String(err) });
4317
4585
  }
4318
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
+ });
4319
4839
  }