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
+17 -9
View File
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http"; import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
import claudeMemPlugin from "./index.js"; import claudeMemPlugin from "./index.js";
function createMockApi(configOverride: Record<string, any> = {}) { function createMockApi(pluginConfigOverride: Record<string, any> = {}) {
const logs: string[] = []; const logs: string[] = [];
const sentMessages: Array<{ to: string; text: string; channel: string }> = []; const sentMessages: Array<{ to: string; text: string; channel: string }> = [];
@@ -11,9 +11,17 @@ function createMockApi(configOverride: Record<string, any> = {}) {
let registeredCommand: any = null; let registeredCommand: any = null;
const api = { const api = {
getConfig: () => configOverride, id: "claude-mem",
log: (message: string) => { name: "Claude-Mem (Persistent Memory)",
logs.push(message); 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) => { registerService: (service: any) => {
registeredService = service; registeredService = service;
@@ -133,7 +141,7 @@ describe("claudeMemPlugin", () => {
const { api, getCommand } = createMockApi({}); const { api, getCommand } = createMockApi({});
claudeMemPlugin(api); 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")); assert.ok(result.includes("not configured"));
}); });
@@ -143,7 +151,7 @@ describe("claudeMemPlugin", () => {
}); });
claudeMemPlugin(api); 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("Enabled: yes"));
assert.ok(result.includes("Channel: telegram")); assert.ok(result.includes("Channel: telegram"));
assert.ok(result.includes("Target: 123")); assert.ok(result.includes("Target: 123"));
@@ -156,7 +164,7 @@ describe("claudeMemPlugin", () => {
}); });
claudeMemPlugin(api); 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(result.includes("enable requested"));
assert.ok(logs.some((l) => l.includes("enable requested"))); assert.ok(logs.some((l) => l.includes("enable requested")));
}); });
@@ -167,7 +175,7 @@ describe("claudeMemPlugin", () => {
}); });
claudeMemPlugin(api); 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(result.includes("disable requested"));
assert.ok(logs.some((l) => l.includes("disable requested"))); assert.ok(logs.some((l) => l.includes("disable requested")));
}); });
@@ -178,7 +186,7 @@ describe("claudeMemPlugin", () => {
}); });
claudeMemPlugin(api); 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")); assert.ok(result.includes("Connection: disconnected"));
}); });
}); });
+65 -26
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 { interface OpenClawPluginApi {
getConfig: () => Record<string, any>; id: string;
log: (message: string) => void; name: string;
version?: string;
source: string;
config: Record<string, unknown>;
pluginConfig?: Record<string, unknown>;
logger: PluginLogger;
registerService: (service: { registerService: (service: {
id: string; id: string;
start: (ctx: any) => Promise<void>; start: (ctx: PluginServiceContext) => void | Promise<void>;
stop: (ctx: any) => Promise<void>; stop?: (ctx: PluginServiceContext) => void | Promise<void>;
}) => void; }) => void;
registerCommand: (command: { registerCommand: (command: {
name: string; name: string;
description: string; description: string;
handler: (args: string[], ctx: any) => Promise<string>; acceptsArgs?: boolean;
requireAuth?: boolean;
handler: (ctx: PluginCommandContext) => PluginCommandResult | Promise<PluginCommandResult>;
}) => void; }) => void;
runtime: { 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> { ): Promise<void> {
const channelApi = api.runtime.channel[channel]; const channelApi = api.runtime.channel[channel];
if (!channelApi) { if (!channelApi) {
api.log(`[claude-mem] Unknown channel type: ${channel}`); api.logger.warn(`[claude-mem] Unknown channel type: ${channel}`);
return Promise.resolve(); return Promise.resolve();
} }
const sendFunctionName = `sendMessage${channel.charAt(0).toUpperCase()}${channel.slice(1)}`; const sendFunctionName = `sendMessage${channel.charAt(0).toUpperCase()}${channel.slice(1)}`;
const senderFunction = channelApi[sendFunctionName]; const senderFunction = channelApi[sendFunctionName];
if (!senderFunction) { 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 Promise.resolve();
} }
return senderFunction(to, text).catch((error: unknown) => { return senderFunction(to, text).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error); 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) { while (!abortController.signal.aborted) {
try { try {
setConnectionState("reconnecting"); 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`, { const response = await fetch(`http://localhost:${port}/stream`, {
signal: abortController.signal, signal: abortController.signal,
@@ -109,7 +145,7 @@ async function connectToSSEStream(
setConnectionState("connected"); setConnectionState("connected");
backoffMs = 1000; 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 reader = response.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
@@ -122,7 +158,7 @@ async function connectToSSEStream(
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true });
if (buffer.length > MAX_SSE_BUFFER_SIZE) { 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 = ""; buffer = "";
} }
@@ -147,7 +183,7 @@ async function connectToSSEStream(
} }
} catch (parseError: unknown) { } catch (parseError: unknown) {
const errorMessage = parseError instanceof Error ? parseError.message : String(parseError); 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"); setConnectionState("reconnecting");
const errorMessage = error instanceof Error ? error.message : String(error); 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; 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 workerPort = (config.workerPort as number) || 37777;
const feedConfig = config.observationFeed as const feedConfig = config.observationFeed as
| { enabled?: boolean; channel?: string; to?: string } | { enabled?: boolean; channel?: string; to?: string }
| undefined; | undefined;
if (!feedConfig?.enabled) { if (!feedConfig?.enabled) {
api.log("[claude-mem] Observation feed disabled"); api.logger.info("[claude-mem] Observation feed disabled");
return; return;
} }
if (!feedConfig.channel || !feedConfig.to) { 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; 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(); sseAbortController = new AbortController();
connectionPromise = connectToSSEStream( connectionPromise = connectToSSEStream(
@@ -224,15 +260,16 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
connectionPromise = null; connectionPromise = null;
} }
connectionState = "disconnected"; 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({ api.registerCommand({
name: "claude-mem-feed", name: "claude-mem-feed",
description: "Show or toggle Claude-Mem observation feed status", description: "Show or toggle Claude-Mem observation feed status",
handler: async (args, _ctx) => { acceptsArgs: true,
const config = api.getConfig(); handler: async (ctx) => {
const config = api.pluginConfig || {};
const feedConfig = config.observationFeed as const feedConfig = config.observationFeed as
| { enabled?: boolean; channel?: string; to?: string } | { enabled?: boolean; channel?: string; to?: string }
| undefined; | undefined;
@@ -241,13 +278,15 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
return "Observation feed not configured. Add observationFeed to your plugin config."; return "Observation feed not configured. Add observationFeed to your plugin config.";
} }
if (args[0] === "on") { const arg = ctx.args?.trim();
api.log("[claude-mem] Feed enable requested via command");
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."; return "Feed enable requested. Update observationFeed.enabled in your plugin config to persist.";
} }
if (args[0] === "off") { if (arg === "off") {
api.log("[claude-mem] Feed disable requested via command"); api.logger.info("[claude-mem] Feed disable requested via command");
return "Feed disable requested. Update observationFeed.enabled in your plugin config to persist."; 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");
} }
+11 -3
View File
@@ -12,9 +12,17 @@ let registeredCommand = null;
const logs = []; const logs = [];
const mockApi = { const mockApi = {
getConfig: () => ({}), id: "claude-mem",
log: (message) => { name: "Claude-Mem (Persistent Memory)",
logs.push(message); 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) => { registerService: (service) => {
registeredService = service; registeredService = service;