@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/README.md +71 -546
- package/index.ts +642 -16
- package/openclaw.plugin.json +28 -2
- package/package.json +3 -2
- package/skills/codebase-navigation/SKILL.md +105 -0
- package/skills/decision-tracking/SKILL.md +50 -0
- package/skills/entity-and-context/SKILL.md +62 -0
- package/skills/memory-workflow/SKILL.md +84 -0
- package/skills/pattern-management/SKILL.md +57 -0
- package/skills/project-orchestration/SKILL.md +72 -0
- package/skills/release-process/SKILL.md +83 -0
- package/skills/testing-memoryrelay/SKILL.md +87 -0
package/index.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* OpenClaw Memory Plugin - MemoryRelay
|
|
3
|
-
* Version: 0.
|
|
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.
|
|
9
|
-
* New in v0.12.
|
|
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.
|
|
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
|
-
//
|
|
1545
|
-
const
|
|
1546
|
-
...
|
|
1547
|
-
...(
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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
|
}
|