MAESTRO: Fix OpenClaw SDK API mismatch — use real PluginApi interface

E2E testing against the official OpenClaw Docker image revealed the plugin
was built against a custom interface that didn't match the real SDK:
- api.log() → api.logger.info/warn/error() (PluginLogger interface)
- api.getConfig() → api.pluginConfig (direct property)
- command handler (args[], ctx) → (ctx) with ctx.args string
- service stop optional, service context typed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-02-07 21:28:08 -05:00
parent db207807cb
commit 1b9f601c41
3 changed files with 93 additions and 38 deletions

View File

@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
import claudeMemPlugin from "./index.js";
function createMockApi(configOverride: Record<string, any> = {}) {
function createMockApi(pluginConfigOverride: Record<string, any> = {}) {
const logs: string[] = [];
const sentMessages: Array<{ to: string; text: string; channel: string }> = [];
@@ -11,9 +11,17 @@ function createMockApi(configOverride: Record<string, any> = {}) {
let registeredCommand: any = null;
const api = {
getConfig: () => configOverride,
log: (message: string) => {
logs.push(message);
id: "claude-mem",
name: "Claude-Mem (Persistent Memory)",
version: "1.0.0",
source: "/test/extensions/claude-mem/dist/index.js",
config: {},
pluginConfig: pluginConfigOverride,
logger: {
info: (message: string) => { logs.push(message); },
warn: (message: string) => { logs.push(message); },
error: (message: string) => { logs.push(message); },
debug: (message: string) => { logs.push(message); },
},
registerService: (service: any) => {
registeredService = service;
@@ -133,7 +141,7 @@ describe("claudeMemPlugin", () => {
const { api, getCommand } = createMockApi({});
claudeMemPlugin(api);
const result = await getCommand().handler([], {});
const result = await getCommand().handler({ args: "", channel: "telegram", isAuthorizedSender: true, commandBody: "/claude-mem-feed", config: {} });
assert.ok(result.includes("not configured"));
});
@@ -143,7 +151,7 @@ describe("claudeMemPlugin", () => {
});
claudeMemPlugin(api);
const result = await getCommand().handler([], {});
const result = await getCommand().handler({ args: "", channel: "telegram", isAuthorizedSender: true, commandBody: "/claude-mem-feed", config: {} });
assert.ok(result.includes("Enabled: yes"));
assert.ok(result.includes("Channel: telegram"));
assert.ok(result.includes("Target: 123"));
@@ -156,7 +164,7 @@ describe("claudeMemPlugin", () => {
});
claudeMemPlugin(api);
const result = await getCommand().handler(["on"], {});
const result = await getCommand().handler({ args: "on", channel: "telegram", isAuthorizedSender: true, commandBody: "/claude-mem-feed on", config: {} });
assert.ok(result.includes("enable requested"));
assert.ok(logs.some((l) => l.includes("enable requested")));
});
@@ -167,7 +175,7 @@ describe("claudeMemPlugin", () => {
});
claudeMemPlugin(api);
const result = await getCommand().handler(["off"], {});
const result = await getCommand().handler({ args: "off", channel: "telegram", isAuthorizedSender: true, commandBody: "/claude-mem-feed off", config: {} });
assert.ok(result.includes("disable requested"));
assert.ok(logs.some((l) => l.includes("disable requested")));
});
@@ -178,7 +186,7 @@ describe("claudeMemPlugin", () => {
});
claudeMemPlugin(api);
const result = await getCommand().handler([], {});
const result = await getCommand().handler({ args: "", channel: "slack", isAuthorizedSender: true, commandBody: "/claude-mem-feed", config: {} });
assert.ok(result.includes("Connection: disconnected"));
});
});

View File

@@ -1,18 +1,54 @@
// Minimal type declarations for the OpenClaw Plugin SDK.
// These match the real OpenClawPluginApi provided by the gateway at runtime.
// See: https://docs.openclaw.ai/plugin
interface PluginLogger {
debug?: (message: string) => void;
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
}
interface PluginServiceContext {
config: Record<string, unknown>;
workspaceDir?: string;
stateDir: string;
logger: PluginLogger;
}
interface PluginCommandContext {
senderId?: string;
channel: string;
isAuthorizedSender: boolean;
args?: string;
commandBody: string;
config: Record<string, unknown>;
}
type PluginCommandResult = string | { text: string } | { text: string; format?: string };
interface OpenClawPluginApi {
getConfig: () => Record<string, any>;
log: (message: string) => void;
id: string;
name: string;
version?: string;
source: string;
config: Record<string, unknown>;
pluginConfig?: Record<string, unknown>;
logger: PluginLogger;
registerService: (service: {
id: string;
start: (ctx: any) => Promise<void>;
stop: (ctx: any) => Promise<void>;
start: (ctx: PluginServiceContext) => void | Promise<void>;
stop?: (ctx: PluginServiceContext) => void | Promise<void>;
}) => void;
registerCommand: (command: {
name: string;
description: string;
handler: (args: string[], ctx: any) => Promise<string>;
acceptsArgs?: boolean;
requireAuth?: boolean;
handler: (ctx: PluginCommandContext) => PluginCommandResult | Promise<PluginCommandResult>;
}) => void;
runtime: {
channel: Record<string, Record<string, (to: string, text: string) => Promise<any>>>;
channel: Record<string, Record<string, (...args: any[]) => Promise<any>>>;
};
}
@@ -61,20 +97,20 @@ function sendToChannel(
): Promise<void> {
const channelApi = api.runtime.channel[channel];
if (!channelApi) {
api.log(`[claude-mem] Unknown channel type: ${channel}`);
api.logger.warn(`[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`);
api.logger.warn(`[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}`);
api.logger.error(`[claude-mem] Failed to send to ${channel}: ${message}`);
});
}
@@ -92,7 +128,7 @@ async function connectToSSEStream(
while (!abortController.signal.aborted) {
try {
setConnectionState("reconnecting");
api.log(`[claude-mem] Connecting to SSE stream at http://localhost:${port}/stream`);
api.logger.info(`[claude-mem] Connecting to SSE stream at http://localhost:${port}/stream`);
const response = await fetch(`http://localhost:${port}/stream`, {
signal: abortController.signal,
@@ -109,7 +145,7 @@ async function connectToSSEStream(
setConnectionState("connected");
backoffMs = 1000;
api.log("[claude-mem] Connected to SSE stream");
api.logger.info("[claude-mem] Connected to SSE stream");
const reader = response.body.getReader();
const decoder = new TextDecoder();
@@ -122,7 +158,7 @@ async function connectToSSEStream(
buffer += decoder.decode(value, { stream: true });
if (buffer.length > MAX_SSE_BUFFER_SIZE) {
api.log("[claude-mem] SSE buffer overflow, clearing buffer");
api.logger.warn("[claude-mem] SSE buffer overflow, clearing buffer");
buffer = "";
}
@@ -147,7 +183,7 @@ async function connectToSSEStream(
}
} catch (parseError: unknown) {
const errorMessage = parseError instanceof Error ? parseError.message : String(parseError);
api.log(`[claude-mem] Failed to parse SSE frame: ${errorMessage}`);
api.logger.warn(`[claude-mem] Failed to parse SSE frame: ${errorMessage}`);
}
}
}
@@ -157,7 +193,7 @@ async function connectToSSEStream(
}
setConnectionState("reconnecting");
const errorMessage = error instanceof Error ? error.message : String(error);
api.log(`[claude-mem] SSE stream error: ${errorMessage}. Reconnecting in ${backoffMs / 1000}s`);
api.logger.warn(`[claude-mem] SSE stream error: ${errorMessage}. Reconnecting in ${backoffMs / 1000}s`);
}
if (abortController.signal.aborted) break;
@@ -186,23 +222,23 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
}
}
const config = api.getConfig();
const config = api.pluginConfig || {};
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");
api.logger.info("[claude-mem] Observation feed disabled");
return;
}
if (!feedConfig.channel || !feedConfig.to) {
api.log("[claude-mem] Observation feed misconfigured — channel or target missing");
api.logger.warn("[claude-mem] Observation feed misconfigured — channel or target missing");
return;
}
api.log(`[claude-mem] Observation feed starting — channel: ${feedConfig.channel}, target: ${feedConfig.to}`);
api.logger.info(`[claude-mem] Observation feed starting — channel: ${feedConfig.channel}, target: ${feedConfig.to}`);
sseAbortController = new AbortController();
connectionPromise = connectToSSEStream(
@@ -224,15 +260,16 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
connectionPromise = null;
}
connectionState = "disconnected";
api.log("[claude-mem] Observation feed stopped — SSE connection closed");
api.logger.info("[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();
acceptsArgs: true,
handler: async (ctx) => {
const config = api.pluginConfig || {};
const feedConfig = config.observationFeed as
| { enabled?: boolean; channel?: string; to?: string }
| undefined;
@@ -241,13 +278,15 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
return "Observation feed not configured. Add observationFeed to your plugin config.";
}
if (args[0] === "on") {
api.log("[claude-mem] Feed enable requested via command");
const arg = ctx.args?.trim();
if (arg === "on") {
api.logger.info("[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");
if (arg === "off") {
api.logger.info("[claude-mem] Feed disable requested via command");
return "Feed disable requested. Update observationFeed.enabled in your plugin config to persist.";
}
@@ -261,5 +300,5 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
},
});
api.log("[claude-mem] OpenClaw plugin loaded — v1.0.0");
api.logger.info("[claude-mem] OpenClaw plugin loaded — v1.0.0");
}

View File

@@ -12,9 +12,17 @@ let registeredCommand = null;
const logs = [];
const mockApi = {
getConfig: () => ({}),
log: (message) => {
logs.push(message);
id: "claude-mem",
name: "Claude-Mem (Persistent Memory)",
version: "1.0.0",
source: "/test/extensions/claude-mem/dist/index.js",
config: {},
pluginConfig: {},
logger: {
info: (message) => { logs.push(message); },
warn: (message) => { logs.push(message); },
error: (message) => { logs.push(message); },
debug: (message) => { logs.push(message); },
},
registerService: (service) => {
registeredService = service;