diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 55d47cceb..ec8ed80bb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -64,6 +64,19 @@ export type { UserSuggestion, } from "./types"; +// Plugin types +export type { + LoadedPlugin, + OpenClawMemoryDef, + OpenClawMemoryResult, + OpenClawToolDef, + PluginConfig, + PluginManifest, + PluginRegistrations, + PluginSlot, + PluginsConfig, +} from "./plugin-types"; + // Utilities export * from "./utils/encryption"; export * from "./utils/env"; diff --git a/packages/core/src/plugin-types.ts b/packages/core/src/plugin-types.ts new file mode 100644 index 000000000..b82134cd4 --- /dev/null +++ b/packages/core/src/plugin-types.ts @@ -0,0 +1,108 @@ +/** + * OpenClaw Plugin System Types + * + * Defines the interfaces for loading, configuring, and bridging OpenClaw plugins + * into Lobu's architecture. Supports two plugin slot types: + * - tool: Agent capabilities (tools available during turns) + * - memory: Context recall/save backends (exclusive slot - one active at a time) + */ + +// ============================================================================ +// Plugin Configuration (stored in AgentSettings) +// ============================================================================ + +/** Slot types supported by OpenClaw plugins */ +export type PluginSlot = "tool" | "memory"; + +/** Individual plugin configuration in agent settings */ +export interface PluginConfig { + /** npm package name or local path (e.g., "@openclaw/tool-websearch", "./extensions/memory-rag") */ + source: string; + /** Whether this plugin is currently enabled */ + enabled: boolean; + /** Plugin-specific configuration values (validated against plugin's configSchema) */ + config?: Record; +} + +/** Plugins configuration for agent settings */ +export interface PluginsConfig { + /** Installed plugins keyed by plugin ID */ + plugins: Record; + /** Exclusive slot assignments (e.g., { memory: "memory-core" }) */ + slots?: Record; +} + +// ============================================================================ +// Plugin Manifest (read from plugin package) +// ============================================================================ + +/** Plugin manifest from openclaw.plugin.json or package.json */ +export interface PluginManifest { + id: string; + name?: string; + description?: string; + kind?: PluginSlot; + configSchema?: Record; + skills?: string[]; +} + +// ============================================================================ +// Plugin API (shim provided to plugins during registration) +// ============================================================================ + +/** Tool definition as registered by OpenClaw plugins */ +export interface OpenClawToolDef { + name: string; + description: string; + parameters: unknown; // TypeBox schema + execute: ( + toolCallId: string, + params: unknown, + signal?: AbortSignal + ) => Promise<{ + content: Array<{ type: string; text: string }>; + details?: unknown; + }>; +} + +/** Memory plugin interface */ +export interface OpenClawMemoryDef { + indexChunk?: ( + path: string, + content: string, + metadata?: unknown + ) => Promise; + search?: ( + query: string, + options?: unknown + ) => Promise; + recall?: (query: string) => Promise; + save?: (content: string, metadata?: unknown) => Promise; +} + +export interface OpenClawMemoryResult { + text: string; + path?: string; + score?: number; + metadata?: unknown; +} + +/** + * Collected registrations from an OpenClaw plugin. + * The plugin loader calls register(api) and captures all registrations here. + */ +export interface PluginRegistrations { + id: string; + slot?: PluginSlot; + tools: OpenClawToolDef[]; + memory: OpenClawMemoryDef | null; +} + +/** + * A fully loaded plugin with its manifest and registrations. + */ +export interface LoadedPlugin { + manifest: PluginManifest; + config: PluginConfig; + registrations: PluginRegistrations; +} diff --git a/packages/gateway/src/auth/settings/agent-settings-store.ts b/packages/gateway/src/auth/settings/agent-settings-store.ts index 1f0695e7b..601e42e22 100644 --- a/packages/gateway/src/auth/settings/agent-settings-store.ts +++ b/packages/gateway/src/auth/settings/agent-settings-store.ts @@ -4,6 +4,7 @@ import { type McpServerConfig, type NetworkConfig, type NixConfig, + type PluginsConfig, type SkillsConfig, type ToolsConfig, } from "@lobu/core"; @@ -36,6 +37,8 @@ export interface AgentSettings { skillsConfig?: SkillsConfig; /** Tool permission configuration - allowed/denied tools */ toolsConfig?: ToolsConfig; + /** OpenClaw plugins configuration */ + pluginsConfig?: PluginsConfig; /** Enable verbose logging (show tool calls, reasoning, etc.) */ verboseLogging?: boolean; /** Connected GitHub user info */ diff --git a/packages/gateway/src/plugins/plugin-loader.ts b/packages/gateway/src/plugins/plugin-loader.ts new file mode 100644 index 000000000..486ab4fed --- /dev/null +++ b/packages/gateway/src/plugins/plugin-loader.ts @@ -0,0 +1,455 @@ +/** + * OpenClaw Plugin Loader + * + * Discovers, validates, and loads OpenClaw plugins. Provides a shim + * OpenClawPluginApi that captures tool and memory registrations so Lobu + * can route them to the worker's agent session. + * + * Discovery sources: + * - node_modules/@openclaw/* + * - node_modules/@* /openclaw-* (community namespace) + * - Configured local paths (extensions/ directory) + * - Per-agent plugin config from settings + */ + +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { + createLogger, + type LoadedPlugin, + type OpenClawMemoryDef, + type OpenClawToolDef, + type PluginConfig, + type PluginManifest, + type PluginRegistrations, + type PluginsConfig, +} from "@lobu/core"; + +const logger = createLogger("plugin-loader"); + +// ============================================================================ +// Plugin API Shim +// ============================================================================ + +/** + * Creates a shim OpenClawPluginApi that captures tool and memory registrations + * from a plugin's register() or init() call. + */ +function createPluginApiShim( + pluginId: string, + pluginConfig: Record +): { api: Record; registrations: PluginRegistrations } { + const registrations: PluginRegistrations = { + id: pluginId, + tools: [], + memory: null, + }; + + const api = { + logger: { + info: (...args: unknown[]) => + logger.info(`[plugin:${pluginId}]`, ...args), + warn: (...args: unknown[]) => + logger.warn(`[plugin:${pluginId}]`, ...args), + error: (...args: unknown[]) => + logger.error(`[plugin:${pluginId}]`, ...args), + debug: (...args: unknown[]) => + logger.debug(`[plugin:${pluginId}]`, ...args), + }, + + config: pluginConfig, + + runtime: { + tts: null, + stt: null, + }, + + registerTool(definition: OpenClawToolDef) { + logger.info(`Plugin ${pluginId} registered tool: ${definition.name}`); + registrations.tools.push(definition); + }, + + // Unsupported slot registrations — log and ignore + registerChannel() { + logger.debug( + `Plugin ${pluginId} tried to register channel (not supported in Lobu)` + ); + }, + + registerProvider() { + logger.debug( + `Plugin ${pluginId} tried to register provider (not supported in Lobu)` + ); + }, + + registerService() { + logger.debug( + `Plugin ${pluginId} tried to register service (not supported in Lobu)` + ); + }, + + registerGatewayMethod() { + // no-op + }, + + registerCommand() { + // no-op + }, + + registerCli() { + // no-op + }, + + on() { + // no-op + }, + }; + + return { api, registrations }; +} + +// ============================================================================ +// Plugin Discovery +// ============================================================================ + +interface DiscoveredPlugin { + packagePath: string; + packageJson: Record; + manifest?: PluginManifest; + entryPoints: string[]; +} + +/** + * Read and parse openclaw.plugin.json if it exists. + */ +async function readPluginManifest( + dir: string +): Promise { + try { + const manifestPath = path.join(dir, "openclaw.plugin.json"); + const content = await fs.readFile(manifestPath, "utf-8"); + return JSON.parse(content) as PluginManifest; + } catch { + return undefined; + } +} + +/** + * Discover OpenClaw plugins from a node_modules directory. + * Scans @openclaw/* and @* /openclaw-* packages (community namespace). + */ +async function discoverFromNodeModules( + baseDir: string +): Promise { + const nodeModules = path.join(baseDir, "node_modules"); + const discovered: DiscoveredPlugin[] = []; + + try { + await fs.stat(nodeModules); + } catch { + return discovered; + } + + // Scan @openclaw/* packages + const openclawDir = path.join(nodeModules, "@openclaw"); + try { + const entries = await fs.readdir(openclawDir); + for (const entry of entries) { + const plugin = await tryLoadPluginPackage(path.join(openclawDir, entry)); + if (plugin) discovered.push(plugin); + } + } catch { + // @openclaw directory doesn't exist + } + + // Scan @*/openclaw-* packages (community namespace) + try { + const scopes = await fs.readdir(nodeModules); + for (const scope of scopes) { + if (!scope.startsWith("@") || scope === "@openclaw") continue; + try { + const scopeDir = path.join(nodeModules, scope); + const packages = await fs.readdir(scopeDir); + for (const pkg of packages) { + if (!pkg.startsWith("openclaw-")) continue; + const plugin = await tryLoadPluginPackage(path.join(scopeDir, pkg)); + if (plugin) discovered.push(plugin); + } + } catch { + // skip unreadable scope dir + } + } + } catch { + // node_modules not readable + } + + return discovered; +} + +/** + * Discover plugins from a local extensions directory. + */ +async function discoverFromExtensions( + extensionsDir: string +): Promise { + const discovered: DiscoveredPlugin[] = []; + + try { + const entries = await fs.readdir(extensionsDir); + for (const entry of entries) { + const plugin = await tryLoadPluginPackage( + path.join(extensionsDir, entry) + ); + if (plugin) discovered.push(plugin); + } + } catch { + // Extensions directory doesn't exist + } + + return discovered; +} + +/** + * Try to load a plugin from a directory. + * Returns null if not a valid OpenClaw plugin. + */ +async function tryLoadPluginPackage( + dir: string +): Promise { + try { + const pkgPath = path.join(dir, "package.json"); + const content = await fs.readFile(pkgPath, "utf-8"); + const packageJson = JSON.parse(content); + + // Check for openclaw.extensions field + const openclawConfig = packageJson.openclaw; + if ( + !openclawConfig?.extensions || + !Array.isArray(openclawConfig.extensions) + ) { + return null; + } + + const manifest = await readPluginManifest(dir); + + return { + packagePath: dir, + packageJson, + manifest, + entryPoints: openclawConfig.extensions as string[], + }; + } catch { + return null; + } +} + +// ============================================================================ +// Plugin Loading +// ============================================================================ + +/** + * Load a single plugin by importing its entry points and calling register/init. + */ +async function loadPlugin( + discovered: DiscoveredPlugin, + pluginConfig: PluginConfig +): Promise { + const pluginId = + discovered.manifest?.id || + (discovered.packageJson.name as string) || + path.basename(discovered.packagePath); + + logger.info(`Loading plugin: ${pluginId} from ${discovered.packagePath}`); + + const { api, registrations } = createPluginApiShim( + pluginId, + pluginConfig.config || {} + ); + + for (const entryPoint of discovered.entryPoints) { + const entryPath = path.resolve(discovered.packagePath, entryPoint); + + try { + const module = await import(entryPath); + const exported = module.default || module; + + if (typeof exported === "function") { + // Function form: register(api) + await exported(api); + } else if (typeof exported === "object" && exported !== null) { + if (typeof exported.register === "function") { + // Object form with register method + await exported.register(api); + } else if (typeof exported.init === "function") { + // PluginDefinition form with init method + const result = await exported.init(pluginConfig.config || {}, { + logger: api.logger, + configDir: process.env.HOME || "/tmp", + workspaceDir: process.cwd(), + rpc: {}, + }); + + // Capture slot-specific registrations from init result + const slot = exported.slot || exported.kind; + if (slot === "memory") { + registrations.memory = result as OpenClawMemoryDef; + registrations.slot = "memory"; + } else if (slot === "tool") { + if (Array.isArray(result)) { + for (const tool of result) { + registrations.tools.push(tool as OpenClawToolDef); + } + } + registrations.slot = "tool"; + } + } + } + } catch (error) { + logger.error(`Failed to load plugin entry point ${entryPath}:`, { + error, + }); + return null; + } + } + + // Infer slot from manifest if not set during registration + if (!registrations.slot && discovered.manifest?.kind) { + registrations.slot = discovered.manifest.kind; + } + + const manifest: PluginManifest = discovered.manifest || { + id: pluginId, + name: (discovered.packageJson.name as string) || pluginId, + description: (discovered.packageJson.description as string) || undefined, + }; + + logger.info( + `Plugin ${pluginId} loaded: ${registrations.tools.length} tools, memory=${!!registrations.memory}` + ); + + return { + manifest, + config: pluginConfig, + registrations, + }; +} + +// ============================================================================ +// Public API +// ============================================================================ + +export interface PluginLoaderOptions { + /** Base directory for node_modules discovery */ + baseDir?: string; + /** Additional directories to scan for plugins */ + extensionDirs?: string[]; +} + +/** + * Discover all available OpenClaw plugins (without loading them). + */ +export async function discoverPlugins( + options?: PluginLoaderOptions +): Promise { + const baseDir = options?.baseDir || process.cwd(); + const discovered: DiscoveredPlugin[] = []; + + const fromNpm = await discoverFromNodeModules(baseDir); + discovered.push(...fromNpm); + + for (const dir of options?.extensionDirs || []) { + const fromDir = await discoverFromExtensions(dir); + discovered.push(...fromDir); + } + + logger.info(`Discovered ${discovered.length} OpenClaw plugins`); + return discovered; +} + +/** + * Load all enabled plugins from a PluginsConfig. + */ +export async function loadPlugins( + pluginsConfig: PluginsConfig, + options?: PluginLoaderOptions +): Promise { + const discovered = await discoverPlugins(options); + const loaded: LoadedPlugin[] = []; + + for (const [pluginId, config] of Object.entries(pluginsConfig.plugins)) { + if (!config.enabled) { + logger.debug(`Skipping disabled plugin: ${pluginId}`); + continue; + } + + // Find the discovered plugin matching this config + const found = discovered.find((d) => { + const id = + d.manifest?.id || d.packageJson.name || path.basename(d.packagePath); + return id === pluginId || id === config.source; + }); + + if (!found) { + // Try loading directly from source path + if (config.source) { + const directPlugin = await tryLoadPluginPackage( + path.resolve(options?.baseDir || process.cwd(), config.source) + ); + if (directPlugin) { + const plugin = await loadPlugin(directPlugin, config); + if (plugin) loaded.push(plugin); + continue; + } + } + + logger.warn(`Plugin not found: ${pluginId} (source: ${config.source})`); + continue; + } + + const plugin = await loadPlugin(found, config); + if (plugin) { + // Check exclusive slot constraints (memory is exclusive — one at a time) + if (pluginsConfig.slots && plugin.registrations.slot) { + const slotAssignment = pluginsConfig.slots[plugin.registrations.slot]; + if (slotAssignment && slotAssignment !== pluginId) { + logger.info( + `Skipping plugin ${pluginId} -- slot ${plugin.registrations.slot} assigned to ${slotAssignment}` + ); + continue; + } + } + + loaded.push(plugin); + } + } + + logger.info(`Loaded ${loaded.length} plugins`); + return loaded; +} + +/** + * Filter loaded plugins by slot type. + */ +export function getPluginsBySlot( + plugins: LoadedPlugin[], + slot: string +): LoadedPlugin[] { + return plugins.filter((p) => p.registrations.slot === slot); +} + +/** + * Get all tool definitions from loaded plugins. + */ +export function getPluginTools(plugins: LoadedPlugin[]): OpenClawToolDef[] { + return plugins.flatMap((p) => p.registrations.tools); +} + +/** + * Get the active memory plugin (only one allowed). + */ +export function getActiveMemoryPlugin( + plugins: LoadedPlugin[] +): LoadedPlugin | undefined { + const memoryPlugins = getPluginsBySlot(plugins, "memory"); + return memoryPlugins[0]; +} diff --git a/packages/gateway/src/routes/public/agent-config.ts b/packages/gateway/src/routes/public/agent-config.ts index 6caf5ad09..7fa205e1b 100644 --- a/packages/gateway/src/routes/public/agent-config.ts +++ b/packages/gateway/src/routes/public/agent-config.ts @@ -115,6 +115,19 @@ const updateConfigRoute = createRoute({ ), }) .optional(), + pluginsConfig: z + .object({ + plugins: z.record( + z.string(), + z.object({ + source: z.string(), + enabled: z.boolean(), + config: z.record(z.string(), z.any()).optional(), + }) + ), + slots: z.record(z.string(), z.string()).optional(), + }) + .optional(), verboseLogging: z.boolean().optional(), githubUser: z .null() @@ -456,6 +469,10 @@ function validateSettings( settings.skillsConfig = input.skillsConfig; } + if (input.pluginsConfig) { + settings.pluginsConfig = input.pluginsConfig; + } + if (typeof input.verboseLogging === "boolean") { settings.verboseLogging = input.verboseLogging; } diff --git a/packages/gateway/src/slack/events/messages.ts b/packages/gateway/src/slack/events/messages.ts index 6b0671e34..464f0c5ad 100644 --- a/packages/gateway/src/slack/events/messages.ts +++ b/packages/gateway/src/slack/events/messages.ts @@ -253,6 +253,9 @@ export class MessageHandler { if (settings.verboseLogging !== undefined) { mergedOptions.verboseLogging = settings.verboseLogging; } + if (settings.pluginsConfig) { + mergedOptions.pluginsConfig = settings.pluginsConfig; + } return mergedOptions; } diff --git a/packages/gateway/src/telegram/events/message-handler.ts b/packages/gateway/src/telegram/events/message-handler.ts index 0e24cb020..dc16b73c5 100644 --- a/packages/gateway/src/telegram/events/message-handler.ts +++ b/packages/gateway/src/telegram/events/message-handler.ts @@ -139,6 +139,9 @@ export class TelegramMessageHandler { if (settings.verboseLogging !== undefined) { mergedOptions.verboseLogging = settings.verboseLogging; } + if (settings.pluginsConfig) { + mergedOptions.pluginsConfig = settings.pluginsConfig; + } return mergedOptions; } diff --git a/packages/gateway/src/whatsapp/events/message-handler.ts b/packages/gateway/src/whatsapp/events/message-handler.ts index 8731c92c5..22316e1bf 100644 --- a/packages/gateway/src/whatsapp/events/message-handler.ts +++ b/packages/gateway/src/whatsapp/events/message-handler.ts @@ -214,6 +214,9 @@ export class WhatsAppMessageHandler { if (settings.verboseLogging !== undefined) { mergedOptions.verboseLogging = settings.verboseLogging; } + if (settings.pluginsConfig) { + mergedOptions.pluginsConfig = settings.pluginsConfig; + } return mergedOptions; } diff --git a/packages/worker/src/openclaw/plugin-bridge.ts b/packages/worker/src/openclaw/plugin-bridge.ts new file mode 100644 index 000000000..11eb78691 --- /dev/null +++ b/packages/worker/src/openclaw/plugin-bridge.ts @@ -0,0 +1,229 @@ +/** + * OpenClaw Plugin Bridge + * + * Bridges OpenClaw plugin registrations into the pi-coding-agent runtime: + * - Tool plugins → ToolDefinition objects injected into createAgentSession + * - Memory plugins → before_agent_start (recall) and agent_end (save) hooks + */ + +import { + createLogger, + type LoadedPlugin, + type OpenClawToolDef, +} from "@lobu/core"; +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; + +const logger = createLogger("plugin-bridge"); + +// ============================================================================ +// Tool Plugin Bridge +// ============================================================================ + +/** + * Convert an OpenClaw tool definition to pi-coding-agent ToolDefinition format. + */ +function bridgeToolDef( + pluginId: string, + tool: OpenClawToolDef +): ToolDefinition { + // Convert TypeBox schema or pass through + const parameters = tool.parameters || Type.Object({}); + + return { + name: tool.name, + label: tool.name, + description: tool.description || `Tool from plugin ${pluginId}`, + parameters: parameters as any, + execute: async ( + toolCallId: string, + params: unknown, + signal?: AbortSignal + ): Promise>> => { + try { + logger.info(`[plugin:${pluginId}] Executing tool: ${tool.name}`); + const result = await tool.execute(toolCallId, params, signal); + + // Normalize result to AgentToolResult format + const content = (result?.content || []).map((block) => { + if (block.type === "text") { + return { type: "text" as const, text: block.text }; + } + return { type: "text" as const, text: JSON.stringify(block) }; + }); + + if (content.length === 0) { + content.push({ + type: "text" as const, + text: "Tool executed successfully", + }); + } + + return { content, details: {} }; + } catch (error) { + logger.error(`[plugin:${pluginId}] Tool ${tool.name} failed:`, { + error, + }); + return { + content: [ + { + type: "text" as const, + text: `Error: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + details: {}, + }; + } + }, + }; +} + +/** + * Extract all tool definitions from loaded plugins. + * Returns ToolDefinition[] ready for createAgentSession({ customTools }). + */ +export function bridgePluginTools(plugins: LoadedPlugin[]): ToolDefinition[] { + const tools: ToolDefinition[] = []; + + for (const plugin of plugins) { + for (const toolDef of plugin.registrations.tools) { + tools.push(bridgeToolDef(plugin.manifest.id, toolDef)); + } + } + + if (tools.length > 0) { + logger.info( + `Bridged ${tools.length} plugin tools: ${tools.map((t) => t.name).join(", ")}` + ); + } + + return tools; +} + +// ============================================================================ +// Memory Plugin Bridge +// ============================================================================ + +/** + * Memory plugin lifecycle hooks that integrate with the agent session. + * Call recall() before prompting and save() after the agent responds. + */ +export interface MemoryHooks { + /** Recall relevant memories before agent processes a message */ + recall(query: string): Promise; + /** Save conversation context after agent responds */ + save(content: string, metadata?: Record): Promise; +} + +/** + * Create memory hooks from a loaded memory plugin. + * Returns null if no memory plugin is active. + */ +export function bridgeMemoryPlugin( + plugins: LoadedPlugin[] +): MemoryHooks | null { + const memoryPlugin = plugins.find((p) => p.registrations.slot === "memory"); + if (!memoryPlugin || !memoryPlugin.registrations.memory) { + return null; + } + + const pluginId = memoryPlugin.manifest.id; + const memory = memoryPlugin.registrations.memory; + + logger.info(`Memory plugin active: ${pluginId}`); + + return { + async recall(query: string): Promise { + try { + // Try the recall method first (simple API) + if (memory.recall) { + const result = await memory.recall(query); + logger.info( + `[memory:${pluginId}] Recalled ${result.length} chars for query` + ); + return result; + } + + // Fall back to search method (full API) + if (memory.search) { + const results = await memory.search(query, { + maxResults: 6, + minScore: 0.35, + }); + if (!results || results.length === 0) { + return ""; + } + + const formatted = results + .map((r, i) => { + const score = r.score ? ` (${(r.score * 100).toFixed(0)}%)` : ""; + const source = r.path ? ` [${r.path}]` : ""; + return `${i + 1}. ${r.text}${score}${source}`; + }) + .join("\n\n"); + + logger.info( + `[memory:${pluginId}] Found ${results.length} memories for query` + ); + + return `## Recalled Memories\n\n${formatted}`; + } + + return ""; + } catch (error) { + logger.error(`[memory:${pluginId}] Recall failed:`, { error }); + return ""; + } + }, + + async save( + content: string, + metadata?: Record + ): Promise { + try { + if (memory.save) { + await memory.save(content, metadata); + logger.info(`[memory:${pluginId}] Saved ${content.length} chars`); + return; + } + + if (memory.indexChunk) { + const timestamp = new Date().toISOString(); + await memory.indexChunk( + `memory/${timestamp.split("T")[0]}.md`, + content, + metadata + ); + logger.info(`[memory:${pluginId}] Indexed ${content.length} chars`); + } + } catch (error) { + logger.error(`[memory:${pluginId}] Save failed:`, { error }); + } + }, + }; +} + +// ============================================================================ +// Unified Bridge +// ============================================================================ + +/** + * Bridge result containing all adapted plugin components. + */ +export interface PluginBridgeResult { + /** Tool definitions for createAgentSession({ customTools }) */ + tools: ToolDefinition[]; + /** Memory hooks for recall/save lifecycle */ + memory: MemoryHooks | null; +} + +/** + * Bridge all loaded plugins into worker-consumable components. + */ +export function bridgePlugins(plugins: LoadedPlugin[]): PluginBridgeResult { + return { + tools: bridgePluginTools(plugins), + memory: bridgeMemoryPlugin(plugins), + }; +} diff --git a/packages/worker/src/openclaw/worker.ts b/packages/worker/src/openclaw/worker.ts index cc7c9d68a..f5babe80c 100644 --- a/packages/worker/src/openclaw/worker.ts +++ b/packages/worker/src/openclaw/worker.ts @@ -2,7 +2,12 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; -import { createLogger, type ToolsConfig } from "@lobu/core"; +import { + createLogger, + type LoadedPlugin, + type PluginsConfig, + type ToolsConfig, +} from "@lobu/core"; import { getModel } from "@mariozechner/pi-ai"; import { AuthStorage, @@ -19,6 +24,7 @@ import type { } from "../core/types"; import { createOpenClawCustomTools } from "./custom-tools"; import { OpenClawCoreInstructionProvider } from "./instructions"; +import { bridgePlugins, type MemoryHooks } from "./plugin-bridge"; import { OpenClawProgressProcessor } from "./processor"; import { getOpenClawSessionContext } from "./session-context"; import { @@ -166,6 +172,29 @@ Use it when the user references past discussions or you need context.`); platform: this.config.platform, }); + // Load and bridge OpenClaw plugins (tool + memory) + let memoryHooks: MemoryHooks | null = null; + const pluginsConfig = rawOptions.pluginsConfig as PluginsConfig | undefined; + const loadedPlugins: LoadedPlugin[] = pluginsConfig + ? await this.loadPlugins(pluginsConfig) + : []; + + if (loadedPlugins.length > 0) { + const bridged = bridgePlugins(loadedPlugins); + + if (bridged.tools.length > 0) { + customTools.push(...bridged.tools); + logger.info(`Added ${bridged.tools.length} plugin tools`); + } + + memoryHooks = bridged.memory; + if (memoryHooks) { + logger.info( + "Memory plugin active -- will recall before prompt and save after" + ); + } + } + logger.info( `Starting OpenClaw session: provider=${provider}, model=${modelId}, tools=${tools.length}, customTools=${customTools.length}` ); @@ -277,8 +306,58 @@ Use it when the user references past discussions or you need context.`); }); }, HEARTBEAT_INTERVAL_MS); - await session.prompt(userPrompt); + // Memory plugin: recall relevant context before prompting + let augmentedPrompt = userPrompt; + if (memoryHooks) { + try { + const recalled = await memoryHooks.recall(userPrompt); + if (recalled) { + augmentedPrompt = `${recalled}\n\n---\n\n${userPrompt}`; + logger.info( + `Prepended ${recalled.length} chars of recalled memory` + ); + } + } catch (error) { + logger.error("Memory recall failed (continuing without):", { error }); + } + } + + await session.prompt(augmentedPrompt); await done; + + // Memory plugin: save conversation context after response + if (memoryHooks) { + try { + const context = session.sessionManager?.buildSessionContext?.(); + const lastMessages = context?.messages?.slice(-2) || []; + const saveContent = lastMessages + .map((m: any) => { + const role = m.role || "unknown"; + const text = + typeof m.content === "string" + ? m.content + : Array.isArray(m.content) + ? m.content + .filter((b: any) => b.type === "text") + .map((b: any) => b.text) + .join("\n") + : ""; + return `${role}: ${text}`; + }) + .join("\n\n"); + + if (saveContent.trim()) { + await memoryHooks.save(saveContent, { + conversationId: this.config.conversationId, + userId: this.config.userId, + timestamp: Date.now(), + }); + } + } catch (error) { + logger.error("Memory save failed:", { error }); + } + } + session.dispose(); return { success: true, @@ -331,6 +410,118 @@ Use it when the user references past discussions or you need context.`); logger.info("Cleanup for OpenClaw session (no-op)"); } + /** + * Load OpenClaw plugins from the configured PluginsConfig. + * Uses dynamic import to load plugin entry points from node_modules or local paths. + */ + private async loadPlugins( + pluginsConfig: PluginsConfig + ): Promise { + const loaded: LoadedPlugin[] = []; + + for (const [pluginId, config] of Object.entries(pluginsConfig.plugins)) { + if (!config.enabled) continue; + + try { + // Try to resolve the plugin module + const modulePath = config.source.startsWith(".") + ? path.resolve(this.getWorkingDirectory(), config.source) + : config.source; + + logger.info(`Loading plugin: ${pluginId} from ${modulePath}`); + + const module = await import(modulePath); + const exported = module.default || module; + + // Create a shim API to capture registrations + const registrations: import("@lobu/core").PluginRegistrations = { + id: pluginId, + tools: [], + memory: null, + }; + + const shimApi = { + logger: { + info: (...args: unknown[]) => + logger.info(`[plugin:${pluginId}]`, ...args), + warn: (...args: unknown[]) => + logger.warn(`[plugin:${pluginId}]`, ...args), + error: (...args: unknown[]) => + logger.error(`[plugin:${pluginId}]`, ...args), + debug: (...args: unknown[]) => + logger.debug(`[plugin:${pluginId}]`, ...args), + }, + config: config.config || {}, + runtime: { tts: null, stt: null }, + registerTool: (def: any) => { + registrations.tools.push(def); + }, + // Unsupported registrations — no-op in worker + registerChannel: () => { + // no-op + }, + registerProvider: () => { + // no-op + }, + registerService: () => { + // no-op + }, + registerGatewayMethod: () => { + // no-op + }, + registerCommand: () => { + // no-op + }, + registerCli: () => { + // no-op + }, + on: () => { + // no-op + }, + }; + + if (typeof exported === "function") { + await exported(shimApi); + } else if (typeof exported === "object" && exported !== null) { + if (typeof exported.register === "function") { + await exported.register(shimApi); + } else if (typeof exported.init === "function") { + const result = await exported.init(config.config || {}, { + logger: shimApi.logger, + configDir: process.env.HOME || "/tmp", + workspaceDir: this.getWorkingDirectory(), + rpc: {}, + }); + + // Route init result based on slot type + const slot = exported.slot || exported.kind; + if (slot === "memory") { + registrations.memory = result; + registrations.slot = "memory"; + } else if (slot === "tool" && Array.isArray(result)) { + registrations.tools.push(...result); + registrations.slot = "tool"; + } + } + } + + loaded.push({ + manifest: { id: pluginId, name: pluginId }, + config, + registrations, + }); + + logger.info( + `Plugin ${pluginId} loaded: ${registrations.tools.length} tools, memory=${!!registrations.memory}` + ); + } catch (error) { + logger.error(`Failed to load plugin ${pluginId}:`, { error }); + } + } + + return loaded; + } + private buildPendingInteractionNote( unanswered: Array<{ type: string; question: string }> ): string {