@memoryrelay/plugin-memoryrelay-ai 0.15.6 → 0.16.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 +2 -3
- package/index.ts +472 -4849
- package/openclaw.plugin.json +41 -3
- package/package.json +1 -1
- package/skills/decision-tracking/SKILL.md +1 -1
- package/skills/entity-and-context/SKILL.md +1 -1
- package/skills/memory-workflow/SKILL.md +1 -1
- package/src/client/memoryrelay-client.ts +816 -0
- package/src/context/namespace-router.ts +19 -0
- package/src/context/request-context.ts +39 -0
- package/src/context/session-resolver.ts +93 -0
- package/src/filters/content-patterns.ts +32 -0
- package/src/filters/noise-patterns.ts +33 -0
- package/src/filters/non-interactive.ts +30 -0
- package/src/hooks/activity.ts +51 -0
- package/src/hooks/agent-end.ts +48 -0
- package/src/hooks/before-agent-start.ts +109 -0
- package/src/hooks/before-prompt-build.ts +46 -0
- package/src/hooks/compaction.ts +51 -0
- package/src/hooks/privacy.ts +44 -0
- package/src/hooks/session-lifecycle.ts +47 -0
- package/src/hooks/subagent.ts +62 -0
- package/src/pipelines/capture/content-strip.ts +14 -0
- package/src/pipelines/capture/dedup.ts +17 -0
- package/src/pipelines/capture/index.ts +13 -0
- package/src/pipelines/capture/message-filter.ts +16 -0
- package/src/pipelines/capture/store.ts +33 -0
- package/src/pipelines/capture/trigger-gate.ts +21 -0
- package/src/pipelines/capture/truncate.ts +16 -0
- package/src/pipelines/recall/format.ts +30 -0
- package/src/pipelines/recall/index.ts +12 -0
- package/src/pipelines/recall/rank.ts +40 -0
- package/src/pipelines/recall/scope-resolver.ts +20 -0
- package/src/pipelines/recall/search.ts +43 -0
- package/src/pipelines/recall/trigger-gate.ts +17 -0
- package/src/pipelines/runner.ts +25 -0
- package/src/pipelines/types.ts +157 -0
- package/src/tools/agent-tools.ts +127 -0
- package/src/tools/decision-tools.ts +309 -0
- package/src/tools/entity-tools.ts +215 -0
- package/src/tools/health-tools.ts +42 -0
- package/src/tools/memory-tools.ts +690 -0
- package/src/tools/pattern-tools.ts +250 -0
- package/src/tools/project-tools.ts +444 -0
- package/src/tools/session-tools.ts +195 -0
- package/src/tools/v2-tools.ts +228 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// src/context/namespace-router.ts
|
|
2
|
+
export interface NamespaceConfig {
|
|
3
|
+
isolateAgents?: boolean;
|
|
4
|
+
subagentPolicy?: "inherit" | "isolate" | "skip";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const DEFAULTS: Required<NamespaceConfig> = {
|
|
8
|
+
isolateAgents: false,
|
|
9
|
+
subagentPolicy: "inherit",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function resolveNamespace(
|
|
13
|
+
agentId: string | null,
|
|
14
|
+
nsConfig: NamespaceConfig | undefined,
|
|
15
|
+
): string {
|
|
16
|
+
const config = { ...DEFAULTS, ...nsConfig };
|
|
17
|
+
if (!config.isolateAgents || !agentId) return "default";
|
|
18
|
+
return `agent:${agentId}`;
|
|
19
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// src/context/request-context.ts
|
|
2
|
+
import type { RequestContext, PluginConfig } from "../pipelines/types.js";
|
|
3
|
+
import { resolveNamespace } from "./namespace-router.js";
|
|
4
|
+
|
|
5
|
+
export interface HookEvent {
|
|
6
|
+
ctx?: {
|
|
7
|
+
sessionKey?: string;
|
|
8
|
+
trigger?: string;
|
|
9
|
+
};
|
|
10
|
+
sessionId?: string;
|
|
11
|
+
channel?: string | number;
|
|
12
|
+
prompt?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function buildRequestContext(event: HookEvent, config: PluginConfig): RequestContext {
|
|
16
|
+
const sessionKey = event.ctx?.sessionKey ?? event.sessionId ?? "";
|
|
17
|
+
const subagentMatch = sessionKey.match(/^agent:([^:]+):subagent:(.+)$/);
|
|
18
|
+
const agentMatch = sessionKey.match(/^agent:([^:]+):(.+)$/);
|
|
19
|
+
|
|
20
|
+
const isSubagent = !!subagentMatch;
|
|
21
|
+
const agentId = subagentMatch?.[1] ?? agentMatch?.[1] ?? config.agentId ?? null;
|
|
22
|
+
// Convention: subagent key format is agent:<agentId>:subagent:<parentSessionSuffix>
|
|
23
|
+
// The parent key is reconstructed by removing the :subagent: segment
|
|
24
|
+
const parentSessionKey = isSubagent
|
|
25
|
+
? sessionKey.replace(/:subagent:[^:]+$/, `:${subagentMatch![2]}`)
|
|
26
|
+
: null;
|
|
27
|
+
|
|
28
|
+
return Object.freeze({
|
|
29
|
+
sessionKey,
|
|
30
|
+
agentId,
|
|
31
|
+
channel: event.channel != null ? String(event.channel) : null,
|
|
32
|
+
trigger: event.ctx?.trigger ?? null,
|
|
33
|
+
prompt: event.prompt?.trim() ?? "",
|
|
34
|
+
isSubagent,
|
|
35
|
+
parentSessionKey,
|
|
36
|
+
namespace: resolveNamespace(agentId, config.namespace),
|
|
37
|
+
timestamp: Date.now(),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// src/context/session-resolver.ts
|
|
2
|
+
import type { MemoryRelayClient, PluginConfig, RequestContext } from "../pipelines/types.js";
|
|
3
|
+
|
|
4
|
+
const MAX_CACHE_SIZE = 1000;
|
|
5
|
+
|
|
6
|
+
export interface SessionEntry {
|
|
7
|
+
readonly sessionId: string;
|
|
8
|
+
readonly externalId: string;
|
|
9
|
+
readonly createdAt: number;
|
|
10
|
+
lastActivityAt: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class SessionResolver {
|
|
14
|
+
private readonly cache = new Map<string, SessionEntry>();
|
|
15
|
+
private readonly pending = new Map<string, Promise<SessionEntry>>();
|
|
16
|
+
private readonly client: MemoryRelayClient;
|
|
17
|
+
private readonly timeoutMs: number;
|
|
18
|
+
|
|
19
|
+
constructor(client: MemoryRelayClient, config: PluginConfig) {
|
|
20
|
+
this.client = client;
|
|
21
|
+
this.timeoutMs = (config.sessionTimeoutMinutes ?? 120) * 60_000;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async resolve(requestCtx: RequestContext): Promise<SessionEntry> {
|
|
25
|
+
const key = requestCtx.sessionKey;
|
|
26
|
+
const cached = this.cache.get(key);
|
|
27
|
+
if (cached && !this.isStale(cached)) {
|
|
28
|
+
cached.lastActivityAt = Date.now();
|
|
29
|
+
return cached;
|
|
30
|
+
}
|
|
31
|
+
const inflight = this.pending.get(key);
|
|
32
|
+
if (inflight) return inflight;
|
|
33
|
+
const promise = this.createSession(requestCtx);
|
|
34
|
+
this.pending.set(key, promise);
|
|
35
|
+
try {
|
|
36
|
+
const entry = await promise;
|
|
37
|
+
this.cache.set(key, entry);
|
|
38
|
+
if (this.cache.size > MAX_CACHE_SIZE) {
|
|
39
|
+
// Evict the entry with the oldest lastActivityAt
|
|
40
|
+
let oldestKey: string | undefined;
|
|
41
|
+
let oldestTime = Infinity;
|
|
42
|
+
for (const [k, e] of this.cache) {
|
|
43
|
+
if (e.lastActivityAt < oldestTime) {
|
|
44
|
+
oldestTime = e.lastActivityAt;
|
|
45
|
+
oldestKey = k;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (oldestKey !== undefined) {
|
|
49
|
+
this.cache.delete(oldestKey);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return entry;
|
|
53
|
+
} finally {
|
|
54
|
+
this.pending.delete(key);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private async createSession(ctx: RequestContext): Promise<SessionEntry> {
|
|
59
|
+
const result = await this.client.getOrCreateSession(
|
|
60
|
+
ctx.sessionKey,
|
|
61
|
+
ctx.agentId ?? undefined,
|
|
62
|
+
undefined,
|
|
63
|
+
undefined,
|
|
64
|
+
{ namespace: ctx.namespace },
|
|
65
|
+
);
|
|
66
|
+
return {
|
|
67
|
+
sessionId: result.id,
|
|
68
|
+
externalId: ctx.sessionKey,
|
|
69
|
+
createdAt: Date.now(),
|
|
70
|
+
lastActivityAt: Date.now(),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private isStale(entry: SessionEntry): boolean {
|
|
75
|
+
return (Date.now() - entry.lastActivityAt) > this.timeoutMs;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async endSession(key: string, summary?: string): Promise<void> {
|
|
79
|
+
const entry = this.cache.get(key);
|
|
80
|
+
if (entry) {
|
|
81
|
+
await this.client.endSession(entry.sessionId, summary);
|
|
82
|
+
this.cache.delete(key);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async cleanupStale(): Promise<void> {
|
|
87
|
+
for (const [key, entry] of this.cache) {
|
|
88
|
+
if (this.isStale(entry)) {
|
|
89
|
+
await this.endSession(key).catch(() => {});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// src/filters/content-patterns.ts
|
|
2
|
+
|
|
3
|
+
const STRIP_PATTERNS = [
|
|
4
|
+
{ pattern: () => /<memoryrelay-workflow>[\s\S]*?<\/memoryrelay-workflow>/g, name: "workflow-blocks" },
|
|
5
|
+
{ pattern: () => /<relevant-memories>[\s\S]*?<\/relevant-memories>/g, name: "recall-blocks" },
|
|
6
|
+
{ pattern: () => /<compaction-summary>[\s\S]*?<\/compaction-summary>/g, name: "compaction-blocks" },
|
|
7
|
+
{ pattern: () => /<system-reminder>[\s\S]*?<\/system-reminder>/g, name: "system-reminders" },
|
|
8
|
+
{ pattern: () => /\[(?:image|file|attachment):.*?\]/g, name: "media-refs" },
|
|
9
|
+
{ pattern: () => /```[\s\S]{500,}?```/g, name: "large-code-blocks" },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export function stripContent(content: string): string {
|
|
13
|
+
let result = content;
|
|
14
|
+
for (const { pattern } of STRIP_PATTERNS) {
|
|
15
|
+
result = result.replace(pattern(), "");
|
|
16
|
+
}
|
|
17
|
+
result = result.replace(/\n{3,}/g, "\n\n").trim();
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const LONG_TERM_SIGNALS = [
|
|
22
|
+
/(?:always|never|prefer|don't like|my name is|i work at)/i,
|
|
23
|
+
/(?:remember|important|note that|keep in mind)/i,
|
|
24
|
+
/(?:api key|endpoint|server|credentials|config)/i,
|
|
25
|
+
/(?:decision|chose|decided|agreed|approved)/i,
|
|
26
|
+
/(?:pattern|convention|standard|rule)/i,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export function resolveScope(content: string): "session" | "long-term" {
|
|
30
|
+
if (LONG_TERM_SIGNALS.some(p => p.test(content))) return "long-term";
|
|
31
|
+
return "session";
|
|
32
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ConversationMessage } from "../pipelines/types.js";
|
|
2
|
+
|
|
3
|
+
const DROP_PATTERNS = {
|
|
4
|
+
systemTriggers: /^(HEARTBEAT_OK|NO_REPLY|HEALTH_CHECK|PING)$/,
|
|
5
|
+
timestamps: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/,
|
|
6
|
+
acks: /^(ok|okay|sure|done|yes|no|thanks|thank you|got it|right|yep|nope|k|ty|thx|np|ack|fine|cool|great|perfect)\.?$/i,
|
|
7
|
+
routingBlocks: /^<(?:system-reminder|routing|metadata|tool-result)>/,
|
|
8
|
+
bareToolCalls: /^<tool_call>[\s\S]*<\/tool_call>$/,
|
|
9
|
+
compactionLogs: /^<compaction-audit>/,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function shouldDropMessage(message: ConversationMessage): boolean {
|
|
13
|
+
const text = message.content.trim();
|
|
14
|
+
if (text.length < 10) return true;
|
|
15
|
+
return Object.values(DROP_PATTERNS).some(p => p.test(text));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const BOILERPLATE_SIGNALS = [
|
|
19
|
+
/^(I see|I understand|Got it|Sure|Let me|I'll|I can|Here's what)/i,
|
|
20
|
+
/how can I help/i,
|
|
21
|
+
/let me know if/i,
|
|
22
|
+
/is there anything else/i,
|
|
23
|
+
/happy to help/i,
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export function isAssistantBoilerplate(message: ConversationMessage): boolean {
|
|
27
|
+
if (message.role !== "assistant") return false;
|
|
28
|
+
const text = message.content.trim();
|
|
29
|
+
if (text.length > 300) return false;
|
|
30
|
+
const signalCount = BOILERPLATE_SIGNALS.filter(p => p.test(text)).length;
|
|
31
|
+
const density = signalCount / (text.length / 100);
|
|
32
|
+
return density > 1.5;
|
|
33
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// src/filters/non-interactive.ts
|
|
2
|
+
|
|
3
|
+
export interface TriggerSignals {
|
|
4
|
+
trigger: string | null;
|
|
5
|
+
sessionKey: string;
|
|
6
|
+
prompt: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const NON_INTERACTIVE_TRIGGERS = new Set([
|
|
10
|
+
"cron", "heartbeat", "schedule", "automation", "health_check",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const NON_INTERACTIVE_SESSION_PATTERNS = [
|
|
14
|
+
/:cron:/,
|
|
15
|
+
/:heartbeat:/,
|
|
16
|
+
/:schedule:/,
|
|
17
|
+
/:automation:/,
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const EMPTY_PROMPTS = new Set([
|
|
21
|
+
"HEARTBEAT_OK", "NO_REPLY", "HEALTH_CHECK", "PING",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
export function isNonInteractive(signals: TriggerSignals): boolean {
|
|
25
|
+
if (signals.trigger && NON_INTERACTIVE_TRIGGERS.has(signals.trigger)) return true;
|
|
26
|
+
if (NON_INTERACTIVE_SESSION_PATTERNS.some(p => p.test(signals.sessionKey))) return true;
|
|
27
|
+
if (!signals.prompt || signals.prompt.length < 5) return true;
|
|
28
|
+
if (EMPTY_PROMPTS.has(signals.prompt)) return true;
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// src/hooks/activity.ts
|
|
2
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
3
|
+
import type { SessionResolver } from "../context/session-resolver.js";
|
|
4
|
+
|
|
5
|
+
export interface DebugLoggerLike {
|
|
6
|
+
log(entry: {
|
|
7
|
+
timestamp: string;
|
|
8
|
+
tool: string;
|
|
9
|
+
method: string;
|
|
10
|
+
path: string;
|
|
11
|
+
duration: number;
|
|
12
|
+
status: string;
|
|
13
|
+
error?: unknown;
|
|
14
|
+
}): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function registerActivityHooks(
|
|
18
|
+
api: OpenClawPluginApi,
|
|
19
|
+
sessionResolver: SessionResolver,
|
|
20
|
+
debugLogger?: DebugLoggerLike,
|
|
21
|
+
): void {
|
|
22
|
+
// Tool observation: no-op, registered for future extensibility
|
|
23
|
+
api.on("before_tool_call", (_event, _ctx) => {
|
|
24
|
+
// Reserved for future: tool blocking, param injection, audit
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Tool observation: update session activity + log metrics
|
|
28
|
+
api.on("after_tool_call", (event, _ctx) => {
|
|
29
|
+
// Log to debug logger if enabled
|
|
30
|
+
if (debugLogger) {
|
|
31
|
+
debugLogger.log({
|
|
32
|
+
timestamp: new Date().toISOString(),
|
|
33
|
+
tool: event.toolName,
|
|
34
|
+
method: "tool_call",
|
|
35
|
+
path: "",
|
|
36
|
+
duration: event.durationMs || 0,
|
|
37
|
+
status: event.error ? "error" : "success",
|
|
38
|
+
error: event.error,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Message processing hooks: activity tracking
|
|
44
|
+
api.on("message_received", (_event, _ctx) => {
|
|
45
|
+
// Activity tracking handled by session resolver
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
api.on("message_sending", (_event, _ctx) => {
|
|
49
|
+
// No-op: registered for future extensibility
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// src/hooks/agent-end.ts
|
|
2
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
3
|
+
import type { PluginConfig, MemoryRelayClient, ConversationMessage, SessionResolverLike } from "../pipelines/types.js";
|
|
4
|
+
import { buildRequestContext } from "../context/request-context.js";
|
|
5
|
+
import { runPipeline } from "../pipelines/runner.js";
|
|
6
|
+
import { capturePipeline } from "../pipelines/capture/index.js";
|
|
7
|
+
|
|
8
|
+
export function registerAgentEnd(
|
|
9
|
+
api: OpenClawPluginApi,
|
|
10
|
+
config: PluginConfig,
|
|
11
|
+
client: MemoryRelayClient,
|
|
12
|
+
sessionResolver?: SessionResolverLike,
|
|
13
|
+
): void {
|
|
14
|
+
if (!config.autoCapture?.enabled) return;
|
|
15
|
+
|
|
16
|
+
api.on("agent_end", async (event) => {
|
|
17
|
+
if (!event.success || !event.messages || event.messages.length === 0) return;
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const messages: ConversationMessage[] = [];
|
|
21
|
+
for (const msg of event.messages) {
|
|
22
|
+
if (!msg || typeof msg !== "object") continue;
|
|
23
|
+
const msgObj = msg as Record<string, unknown>;
|
|
24
|
+
const role = msgObj.role as string;
|
|
25
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
26
|
+
|
|
27
|
+
const content = msgObj.content;
|
|
28
|
+
if (typeof content === "string") {
|
|
29
|
+
messages.push({ role: role as "user" | "assistant", content });
|
|
30
|
+
} else if (Array.isArray(content)) {
|
|
31
|
+
for (const block of content) {
|
|
32
|
+
if (block && typeof block === "object" && (block as any).type === "text" && (block as any).text) {
|
|
33
|
+
messages.push({ role: role as "user" | "assistant", content: (block as any).text });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (messages.length === 0) return;
|
|
40
|
+
|
|
41
|
+
const requestCtx = buildRequestContext(event, config);
|
|
42
|
+
const pipelineCtx = { requestCtx, config, client, sessionResolver };
|
|
43
|
+
await runPipeline(capturePipeline, { messages }, pipelineCtx);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
api.logger.warn?.(`memory-memoryrelay: capture failed: ${String(err)}`);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// src/hooks/before-agent-start.ts
|
|
2
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
3
|
+
import type { PluginConfig } from "../pipelines/types.js";
|
|
4
|
+
|
|
5
|
+
export function registerBeforeAgentStart(
|
|
6
|
+
api: OpenClawPluginApi,
|
|
7
|
+
config: PluginConfig,
|
|
8
|
+
isToolEnabled: (name: string) => boolean,
|
|
9
|
+
defaultProject: string | undefined,
|
|
10
|
+
): void {
|
|
11
|
+
api.on("before_agent_start", async (event) => {
|
|
12
|
+
if (!event.prompt || event.prompt.length < 10) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Check if current channel is excluded
|
|
17
|
+
if (config?.excludeChannels && event.channel) {
|
|
18
|
+
const channelId = String(event.channel);
|
|
19
|
+
if (config.excludeChannels.some((excluded) => channelId.includes(excluded))) {
|
|
20
|
+
api.logger.debug?.(
|
|
21
|
+
`memory-memoryrelay: skipping for excluded channel: ${channelId}`,
|
|
22
|
+
);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Build workflow instructions dynamically based on enabled tools
|
|
28
|
+
const lines: string[] = [
|
|
29
|
+
"You have MemoryRelay tools available for persistent memory across sessions.",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
if (defaultProject) {
|
|
33
|
+
lines.push(`Default project: \`${defaultProject}\` (auto-applied when you omit the project parameter).`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
lines.push("", "## Recommended Workflow", "");
|
|
37
|
+
|
|
38
|
+
// Starting work section — only include steps for enabled tools
|
|
39
|
+
const startSteps: string[] = [];
|
|
40
|
+
if (isToolEnabled("project_context")) {
|
|
41
|
+
startSteps.push(`**Load context**: Call \`project_context(${defaultProject ? `"${defaultProject}"` : "project"})\` to load hot-tier memories, active decisions, and adopted patterns`);
|
|
42
|
+
}
|
|
43
|
+
if (isToolEnabled("session_start")) {
|
|
44
|
+
startSteps.push(`**Start session**: Call \`session_start(title${defaultProject ? "" : ", project"})\` to begin tracking your work`);
|
|
45
|
+
}
|
|
46
|
+
if (isToolEnabled("decision_check")) {
|
|
47
|
+
startSteps.push(`**Check decisions**: Call \`decision_check(query${defaultProject ? "" : ", project"})\` before making architectural choices`);
|
|
48
|
+
}
|
|
49
|
+
if (isToolEnabled("pattern_search")) {
|
|
50
|
+
startSteps.push("**Find patterns**: Call `pattern_search(query)` to find established conventions before writing code");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (startSteps.length > 0) {
|
|
54
|
+
lines.push("When starting work on a project:");
|
|
55
|
+
startSteps.forEach((step, i) => lines.push(`${i + 1}. ${step}`));
|
|
56
|
+
lines.push("");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// While working section
|
|
60
|
+
const workSteps: string[] = [];
|
|
61
|
+
if (isToolEnabled("memory_store")) {
|
|
62
|
+
workSteps.push("**Store findings**: Call `memory_store(content, metadata)` for important information worth remembering");
|
|
63
|
+
}
|
|
64
|
+
if (isToolEnabled("decision_record")) {
|
|
65
|
+
workSteps.push(`**Record decisions**: Call \`decision_record(title, rationale${defaultProject ? "" : ", project"})\` when making significant architectural choices`);
|
|
66
|
+
}
|
|
67
|
+
if (isToolEnabled("pattern_create")) {
|
|
68
|
+
workSteps.push("**Create patterns**: Call `pattern_create(title, description)` when establishing reusable conventions");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (workSteps.length > 0) {
|
|
72
|
+
lines.push("While working:");
|
|
73
|
+
const offset = startSteps.length;
|
|
74
|
+
workSteps.forEach((step, i) => lines.push(`${offset + i + 1}. ${step}`));
|
|
75
|
+
lines.push("");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// When done section
|
|
79
|
+
if (isToolEnabled("session_end")) {
|
|
80
|
+
const offset = startSteps.length + workSteps.length;
|
|
81
|
+
lines.push("When done:");
|
|
82
|
+
lines.push(`${offset + 1}. **End session**: Call \`session_end(session_id, summary)\` with a summary of what was accomplished`);
|
|
83
|
+
lines.push("");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// First-time setup — only if project tools are enabled
|
|
87
|
+
if (isToolEnabled("project_register")) {
|
|
88
|
+
lines.push("## First-Time Setup", "");
|
|
89
|
+
lines.push("If the project is not yet registered, start with:");
|
|
90
|
+
lines.push("1. `project_register(slug, name, description, stack)` to register the project");
|
|
91
|
+
lines.push("2. Then follow the workflow above");
|
|
92
|
+
lines.push("");
|
|
93
|
+
if (isToolEnabled("project_list")) {
|
|
94
|
+
lines.push("Use `project_list()` to see existing projects before registering a new one.");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Memory-only fallback — if no session/decision/project tools are enabled
|
|
99
|
+
if (startSteps.length === 0 && workSteps.length === 0) {
|
|
100
|
+
lines.push("Use `memory_store(content)` to save important information and `memory_recall(query)` to find relevant memories.");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const workflowInstructions = lines.join("\n");
|
|
104
|
+
|
|
105
|
+
const prependContext = `<memoryrelay-workflow>\n${workflowInstructions}\n</memoryrelay-workflow>`;
|
|
106
|
+
|
|
107
|
+
return { prependContext };
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// src/hooks/before-prompt-build.ts
|
|
2
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
3
|
+
import type { PluginConfig, MemoryRelayClient, SessionResolverLike } from "../pipelines/types.js";
|
|
4
|
+
import { buildRequestContext } from "../context/request-context.js";
|
|
5
|
+
import { runPipeline } from "../pipelines/runner.js";
|
|
6
|
+
import { recallPipeline } from "../pipelines/recall/index.js";
|
|
7
|
+
|
|
8
|
+
export function registerBeforePromptBuild(
|
|
9
|
+
api: OpenClawPluginApi,
|
|
10
|
+
config: PluginConfig,
|
|
11
|
+
client: MemoryRelayClient,
|
|
12
|
+
sessionResolver?: SessionResolverLike,
|
|
13
|
+
): void {
|
|
14
|
+
api.on("before_prompt_build", async (event) => {
|
|
15
|
+
if (!config.autoRecall) return;
|
|
16
|
+
|
|
17
|
+
if (!event.prompt || event.prompt.length < 10) return;
|
|
18
|
+
|
|
19
|
+
// Check if current channel is excluded
|
|
20
|
+
if (config.excludeChannels && event.channel) {
|
|
21
|
+
const channelId = String(event.channel);
|
|
22
|
+
if (config.excludeChannels.some((excluded: string) => channelId.includes(excluded))) {
|
|
23
|
+
api.logger.debug?.(
|
|
24
|
+
`memory-memoryrelay: skipping recall for excluded channel: ${channelId}`,
|
|
25
|
+
);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const requestCtx = buildRequestContext(event, config);
|
|
32
|
+
const pipelineCtx = { requestCtx, config, client, sessionResolver };
|
|
33
|
+
const result = await runPipeline(recallPipeline, {
|
|
34
|
+
prompt: requestCtx.prompt, memories: [], scope: "all" as const,
|
|
35
|
+
}, pipelineCtx);
|
|
36
|
+
|
|
37
|
+
if (!result || !result.formatted) return;
|
|
38
|
+
|
|
39
|
+
api.logger.info?.(`memory-memoryrelay: injecting memories into context`);
|
|
40
|
+
|
|
41
|
+
return { prependContext: result.formatted };
|
|
42
|
+
} catch (err) {
|
|
43
|
+
api.logger.warn?.(`memory-memoryrelay: recall failed: ${String(err)}`);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// src/hooks/compaction.ts
|
|
2
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
3
|
+
import type { MemoryRelayClient } from "../pipelines/types.js";
|
|
4
|
+
|
|
5
|
+
export function registerCompactionHooks(
|
|
6
|
+
api: OpenClawPluginApi,
|
|
7
|
+
client: MemoryRelayClient,
|
|
8
|
+
agentId: string,
|
|
9
|
+
blocklist: string[],
|
|
10
|
+
extractRescueContent: (messages: unknown[], blocklist: string[]) => string[],
|
|
11
|
+
): void {
|
|
12
|
+
// Compaction rescue: save key context before it's lost
|
|
13
|
+
api.on("before_compaction", async (event, _ctx) => {
|
|
14
|
+
if (!event.messages || event.messages.length === 0) return;
|
|
15
|
+
try {
|
|
16
|
+
const rescued = extractRescueContent(event.messages, blocklist);
|
|
17
|
+
for (const content of rescued) {
|
|
18
|
+
await client.store(content, {
|
|
19
|
+
category: "compaction-rescue",
|
|
20
|
+
source: "auto-compaction",
|
|
21
|
+
agent: agentId,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
if (rescued.length > 0) {
|
|
25
|
+
api.logger.info?.(`memory-memoryrelay: rescued ${rescued.length} memories before compaction`);
|
|
26
|
+
}
|
|
27
|
+
} catch (err) {
|
|
28
|
+
api.logger.warn?.(`memory-memoryrelay: compaction rescue failed: ${String(err)}`);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Session reset rescue: save key context before session is cleared
|
|
33
|
+
api.on("before_reset", async (event, _ctx) => {
|
|
34
|
+
if (!event.messages || event.messages.length === 0) return;
|
|
35
|
+
try {
|
|
36
|
+
const rescued = extractRescueContent(event.messages, blocklist);
|
|
37
|
+
for (const content of rescued) {
|
|
38
|
+
await client.store(content, {
|
|
39
|
+
category: "session-reset-rescue",
|
|
40
|
+
source: "auto-reset",
|
|
41
|
+
agent: agentId,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
if (rescued.length > 0) {
|
|
45
|
+
api.logger.info?.(`memory-memoryrelay: rescued ${rescued.length} memories before reset`);
|
|
46
|
+
}
|
|
47
|
+
} catch (err) {
|
|
48
|
+
api.logger.warn?.(`memory-memoryrelay: reset rescue failed: ${String(err)}`);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// src/hooks/privacy.ts
|
|
2
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
3
|
+
|
|
4
|
+
export function registerPrivacyHooks(
|
|
5
|
+
api: OpenClawPluginApi,
|
|
6
|
+
blocklist: string[],
|
|
7
|
+
isBlocklisted: (content: string, blocklist: string[]) => boolean,
|
|
8
|
+
redactSensitive: (content: string, blocklist: string[]) => string,
|
|
9
|
+
): void {
|
|
10
|
+
api.on("before_message_write", (event, _ctx) => {
|
|
11
|
+
if (blocklist.length === 0) return;
|
|
12
|
+
|
|
13
|
+
const msg = event.message;
|
|
14
|
+
if (!msg || typeof msg !== "object") return;
|
|
15
|
+
|
|
16
|
+
const m = msg as Record<string, unknown>;
|
|
17
|
+
if (typeof m.content === "string" && isBlocklisted(m.content, blocklist)) {
|
|
18
|
+
return {
|
|
19
|
+
message: {
|
|
20
|
+
...msg,
|
|
21
|
+
content: redactSensitive(m.content as string, blocklist),
|
|
22
|
+
} as typeof msg,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Tool result redaction: apply privacy blocklist before persistence
|
|
28
|
+
api.on("tool_result_persist", (event, _ctx) => {
|
|
29
|
+
if (blocklist.length === 0) return;
|
|
30
|
+
|
|
31
|
+
const msg = event.message;
|
|
32
|
+
if (!msg || typeof msg !== "object") return;
|
|
33
|
+
|
|
34
|
+
const m = msg as Record<string, unknown>;
|
|
35
|
+
if (typeof m.content === "string" && isBlocklisted(m.content, blocklist)) {
|
|
36
|
+
return {
|
|
37
|
+
message: {
|
|
38
|
+
...msg,
|
|
39
|
+
content: redactSensitive(m.content as string, blocklist),
|
|
40
|
+
} as typeof msg,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// src/hooks/session-lifecycle.ts
|
|
2
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
3
|
+
import type { PluginConfig, MemoryRelayClient } from "../pipelines/types.js";
|
|
4
|
+
import type { SessionResolver } from "../context/session-resolver.js";
|
|
5
|
+
|
|
6
|
+
export function registerSessionLifecycle(
|
|
7
|
+
api: OpenClawPluginApi,
|
|
8
|
+
config: PluginConfig,
|
|
9
|
+
client: MemoryRelayClient,
|
|
10
|
+
agentId: string,
|
|
11
|
+
defaultProject: string | undefined,
|
|
12
|
+
sessionResolver: SessionResolver,
|
|
13
|
+
): void {
|
|
14
|
+
// Session sync: auto-create MemoryRelay session when OpenClaw session starts
|
|
15
|
+
api.on("session_start", async (event, _ctx) => {
|
|
16
|
+
try {
|
|
17
|
+
const externalId = event.sessionKey || event.sessionId;
|
|
18
|
+
if (!externalId) return;
|
|
19
|
+
|
|
20
|
+
const response = await client.getOrCreateSession(
|
|
21
|
+
externalId,
|
|
22
|
+
agentId,
|
|
23
|
+
`OpenClaw session ${externalId}`,
|
|
24
|
+
defaultProject || undefined,
|
|
25
|
+
{ source: "openclaw-plugin", agent: agentId, trigger: "session_start_hook" },
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
api.logger.debug?.(`memory-memoryrelay: auto-created session ${response.id} for OpenClaw session ${externalId}`);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
api.logger.warn?.(`memory-memoryrelay: session_start hook failed: ${String(err)}`);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Session sync: auto-end MemoryRelay session when OpenClaw session ends
|
|
35
|
+
api.on("session_end", async (event, _ctx) => {
|
|
36
|
+
try {
|
|
37
|
+
const externalId = event.sessionKey || event.sessionId;
|
|
38
|
+
if (!externalId) return;
|
|
39
|
+
|
|
40
|
+
await sessionResolver.endSession(externalId, `Session ended after ${event.messageCount} messages`);
|
|
41
|
+
|
|
42
|
+
api.logger.debug?.(`memory-memoryrelay: auto-ended session for ${externalId}`);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
api.logger.warn?.(`memory-memoryrelay: session_end hook failed: ${String(err)}`);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|