Files
claude-mem/openclaw/src/index.ts
T
Alex Newman db207807cb MAESTRO: Address PR review feedback — fix connection lifecycle, lazy channel access, buffer safety
- Move sseAbortController/connectionState from module globals into closure for multi-instance safety
- Make start() idempotent by aborting existing connection before creating a new one
- Track connectionPromise and await it on stop() for proper cleanup
- Guard channel API access lazily to prevent crash when integrations are missing
- Add 1MB MAX_SSE_BUFFER_SIZE to prevent unbounded buffer growth
- Log malformed JSON parse errors instead of silently ignoring
- Replace error: any with proper instanceof Error type narrowing
- Remove hardcoded user paths from TESTING.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 20:06:29 -05:00

266 lines
8.1 KiB
TypeScript

interface OpenClawPluginApi {
getConfig: () => Record<string, any>;
log: (message: string) => void;
registerService: (service: {
id: string;
start: (ctx: any) => Promise<void>;
stop: (ctx: any) => Promise<void>;
}) => void;
registerCommand: (command: {
name: string;
description: string;
handler: (args: string[], ctx: any) => Promise<string>;
}) => void;
runtime: {
channel: Record<string, Record<string, (to: string, text: string) => Promise<any>>>;
};
}
interface ObservationSSEPayload {
id: number;
memory_session_id: string;
session_id: string;
type: string;
title: string | null;
subtitle: string | null;
text: string | null;
narrative: string | null;
facts: string | null;
concepts: string | null;
files_read: string | null;
files_modified: string | null;
project: string;
prompt_number: number;
created_at_epoch: number;
}
interface SSENewObservationEvent {
type: "new_observation";
observation: ObservationSSEPayload;
timestamp: number;
}
type ConnectionState = "disconnected" | "connected" | "reconnecting";
const MAX_SSE_BUFFER_SIZE = 1024 * 1024; // 1MB
function formatObservationMessage(observation: ObservationSSEPayload): string {
const title = observation.title || "Untitled";
let message = `🧠 Claude-Mem Observation\n**${title}**`;
if (observation.subtitle) {
message += `\n${observation.subtitle}`;
}
return message;
}
function sendToChannel(
api: OpenClawPluginApi,
channel: string,
to: string,
text: string
): Promise<void> {
const channelApi = api.runtime.channel[channel];
if (!channelApi) {
api.log(`[claude-mem] Unknown channel type: ${channel}`);
return Promise.resolve();
}
const sendFunctionName = `sendMessage${channel.charAt(0).toUpperCase()}${channel.slice(1)}`;
const senderFunction = channelApi[sendFunctionName];
if (!senderFunction) {
api.log(`[claude-mem] Channel "${channel}" has no ${sendFunctionName} function`);
return Promise.resolve();
}
return senderFunction(to, text).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
api.log(`[claude-mem] Failed to send to ${channel}: ${message}`);
});
}
async function connectToSSEStream(
api: OpenClawPluginApi,
port: number,
channel: string,
to: string,
abortController: AbortController,
setConnectionState: (state: ConnectionState) => void
): Promise<void> {
let backoffMs = 1000;
const maxBackoffMs = 30000;
while (!abortController.signal.aborted) {
try {
setConnectionState("reconnecting");
api.log(`[claude-mem] Connecting to SSE stream at http://localhost:${port}/stream`);
const response = await fetch(`http://localhost:${port}/stream`, {
signal: abortController.signal,
headers: { Accept: "text/event-stream" },
});
if (!response.ok) {
throw new Error(`SSE stream returned HTTP ${response.status}`);
}
if (!response.body) {
throw new Error("SSE stream response has no body");
}
setConnectionState("connected");
backoffMs = 1000;
api.log("[claude-mem] Connected to SSE stream");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
if (buffer.length > MAX_SSE_BUFFER_SIZE) {
api.log("[claude-mem] SSE buffer overflow, clearing buffer");
buffer = "";
}
const frames = buffer.split("\n\n");
buffer = frames.pop() || "";
for (const frame of frames) {
const dataLine = frame
.split("\n")
.find((line) => line.startsWith("data:"));
if (!dataLine) continue;
const jsonStr = dataLine.slice(5).trim();
if (!jsonStr) continue;
try {
const parsed = JSON.parse(jsonStr);
if (parsed.type === "new_observation") {
const event = parsed as SSENewObservationEvent;
const message = formatObservationMessage(event.observation);
await sendToChannel(api, channel, to, message);
}
} catch (parseError: unknown) {
const errorMessage = parseError instanceof Error ? parseError.message : String(parseError);
api.log(`[claude-mem] Failed to parse SSE frame: ${errorMessage}`);
}
}
}
} catch (error: unknown) {
if (abortController.signal.aborted) {
break;
}
setConnectionState("reconnecting");
const errorMessage = error instanceof Error ? error.message : String(error);
api.log(`[claude-mem] SSE stream error: ${errorMessage}. Reconnecting in ${backoffMs / 1000}s`);
}
if (abortController.signal.aborted) break;
await new Promise((resolve) => setTimeout(resolve, backoffMs));
backoffMs = Math.min(backoffMs * 2, maxBackoffMs);
}
setConnectionState("disconnected");
}
export default function claudeMemPlugin(api: OpenClawPluginApi): void {
let sseAbortController: AbortController | null = null;
let connectionState: ConnectionState = "disconnected";
let connectionPromise: Promise<void> | null = null;
api.registerService({
id: "claude-mem-observation-feed",
start: async (_ctx) => {
// Abort any existing connection before starting a new one
if (sseAbortController) {
sseAbortController.abort();
if (connectionPromise) {
await connectionPromise;
connectionPromise = null;
}
}
const config = api.getConfig();
const workerPort = (config.workerPort as number) || 37777;
const feedConfig = config.observationFeed as
| { enabled?: boolean; channel?: string; to?: string }
| undefined;
if (!feedConfig?.enabled) {
api.log("[claude-mem] Observation feed disabled");
return;
}
if (!feedConfig.channel || !feedConfig.to) {
api.log("[claude-mem] Observation feed misconfigured — channel or target missing");
return;
}
api.log(`[claude-mem] Observation feed starting — channel: ${feedConfig.channel}, target: ${feedConfig.to}`);
sseAbortController = new AbortController();
connectionPromise = connectToSSEStream(
api,
workerPort,
feedConfig.channel,
feedConfig.to,
sseAbortController,
(state) => { connectionState = state; }
);
},
stop: async (_ctx) => {
if (sseAbortController) {
sseAbortController.abort();
sseAbortController = null;
}
if (connectionPromise) {
await connectionPromise;
connectionPromise = null;
}
connectionState = "disconnected";
api.log("[claude-mem] Observation feed stopped — SSE connection closed");
},
});
api.registerCommand({
name: "claude-mem-feed",
description: "Show or toggle Claude-Mem observation feed status",
handler: async (args, _ctx) => {
const config = api.getConfig();
const feedConfig = config.observationFeed as
| { enabled?: boolean; channel?: string; to?: string }
| undefined;
if (!feedConfig) {
return "Observation feed not configured. Add observationFeed to your plugin config.";
}
if (args[0] === "on") {
api.log("[claude-mem] Feed enable requested via command");
return "Feed enable requested. Update observationFeed.enabled in your plugin config to persist.";
}
if (args[0] === "off") {
api.log("[claude-mem] Feed disable requested via command");
return "Feed disable requested. Update observationFeed.enabled in your plugin config to persist.";
}
return [
"Claude-Mem Observation Feed",
`Enabled: ${feedConfig.enabled ? "yes" : "no"}`,
`Channel: ${feedConfig.channel || "not set"}`,
`Target: ${feedConfig.to || "not set"}`,
`Connection: ${connectionState}`,
].join("\n");
},
});
api.log("[claude-mem] OpenClaw plugin loaded — v1.0.0");
}