mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-21 13:44:03 +00:00
refactor: move plugin setup and memory capabilities to registries
This commit is contained in:
@@ -26,6 +26,9 @@ export type BuildPluginApiParams = {
|
||||
| "registerCli"
|
||||
| "registerService"
|
||||
| "registerCliBackend"
|
||||
| "registerConfigMigration"
|
||||
| "registerLegacyConfigMigration"
|
||||
| "registerAutoEnableProbe"
|
||||
| "registerProvider"
|
||||
| "registerSpeechProvider"
|
||||
| "registerRealtimeTranscriptionProvider"
|
||||
@@ -56,6 +59,10 @@ const noopRegisterGatewayMethod: OpenClawPluginApi["registerGatewayMethod"] = ()
|
||||
const noopRegisterCli: OpenClawPluginApi["registerCli"] = () => {};
|
||||
const noopRegisterService: OpenClawPluginApi["registerService"] = () => {};
|
||||
const noopRegisterCliBackend: OpenClawPluginApi["registerCliBackend"] = () => {};
|
||||
const noopRegisterConfigMigration: OpenClawPluginApi["registerConfigMigration"] = () => {};
|
||||
const noopRegisterLegacyConfigMigration: OpenClawPluginApi["registerLegacyConfigMigration"] =
|
||||
() => {};
|
||||
const noopRegisterAutoEnableProbe: OpenClawPluginApi["registerAutoEnableProbe"] = () => {};
|
||||
const noopRegisterProvider: OpenClawPluginApi["registerProvider"] = () => {};
|
||||
const noopRegisterSpeechProvider: OpenClawPluginApi["registerSpeechProvider"] = () => {};
|
||||
const noopRegisterRealtimeTranscriptionProvider: OpenClawPluginApi["registerRealtimeTranscriptionProvider"] =
|
||||
@@ -104,6 +111,10 @@ export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi
|
||||
registerCli: handlers.registerCli ?? noopRegisterCli,
|
||||
registerService: handlers.registerService ?? noopRegisterService,
|
||||
registerCliBackend: handlers.registerCliBackend ?? noopRegisterCliBackend,
|
||||
registerConfigMigration: handlers.registerConfigMigration ?? noopRegisterConfigMigration,
|
||||
registerLegacyConfigMigration:
|
||||
handlers.registerLegacyConfigMigration ?? noopRegisterLegacyConfigMigration,
|
||||
registerAutoEnableProbe: handlers.registerAutoEnableProbe ?? noopRegisterAutoEnableProbe,
|
||||
registerProvider: handlers.registerProvider ?? noopRegisterProvider,
|
||||
registerSpeechProvider: handlers.registerSpeechProvider ?? noopRegisterSpeechProvider,
|
||||
registerRealtimeTranscriptionProvider:
|
||||
|
||||
@@ -129,6 +129,7 @@ function createCapabilityPluginRecord(params: {
|
||||
videoGenerationProviderIds: [],
|
||||
webFetchProviderIds: [],
|
||||
webSearchProviderIds: [],
|
||||
memoryEmbeddingProviderIds: [],
|
||||
gatewayMethods: [],
|
||||
cliCommands: [],
|
||||
services: [],
|
||||
@@ -292,6 +293,9 @@ export function loadBundledCapabilityRuntimeRegistry(params: {
|
||||
);
|
||||
record.webFetchProviderIds.push(...captured.webFetchProviders.map((entry) => entry.id));
|
||||
record.webSearchProviderIds.push(...captured.webSearchProviders.map((entry) => entry.id));
|
||||
record.memoryEmbeddingProviderIds.push(
|
||||
...captured.memoryEmbeddingProviders.map((entry) => entry.id),
|
||||
);
|
||||
record.toolNames.push(...captured.tools.map((entry) => entry.name));
|
||||
|
||||
registry.cliBackends?.push(
|
||||
@@ -384,6 +388,15 @@ export function loadBundledCapabilityRuntimeRegistry(params: {
|
||||
rootDir: record.rootDir,
|
||||
})),
|
||||
);
|
||||
registry.memoryEmbeddingProviders.push(
|
||||
...captured.memoryEmbeddingProviders.map((provider) => ({
|
||||
pluginId: record.id,
|
||||
pluginName: record.name,
|
||||
provider,
|
||||
source: record.source,
|
||||
rootDir: record.rootDir,
|
||||
})),
|
||||
);
|
||||
registry.tools.push(
|
||||
...captured.tools.map((tool) => ({
|
||||
pluginId: record.id,
|
||||
|
||||
@@ -103,6 +103,7 @@ function setBundledCapabilityFixture(contractKey: string) {
|
||||
|
||||
function expectCompatChainApplied(params: {
|
||||
key:
|
||||
| "memoryEmbeddingProviders"
|
||||
| "speechProviders"
|
||||
| "realtimeTranscriptionProviders"
|
||||
| "realtimeVoiceProviders"
|
||||
@@ -205,6 +206,7 @@ describe("resolvePluginCapabilityProviders", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
["memoryEmbeddingProviders", "memoryEmbeddingProviders"],
|
||||
["speechProviders", "speechProviders"],
|
||||
["realtimeTranscriptionProviders", "realtimeTranscriptionProviders"],
|
||||
["realtimeVoiceProviders", "realtimeVoiceProviders"],
|
||||
|
||||
@@ -8,6 +8,7 @@ import { loadPluginManifestRegistry } from "./manifest-registry.js";
|
||||
import type { PluginRegistry } from "./registry.js";
|
||||
|
||||
type CapabilityProviderRegistryKey =
|
||||
| "memoryEmbeddingProviders"
|
||||
| "speechProviders"
|
||||
| "realtimeTranscriptionProviders"
|
||||
| "realtimeVoiceProviders"
|
||||
@@ -16,6 +17,7 @@ type CapabilityProviderRegistryKey =
|
||||
| "videoGenerationProviders";
|
||||
|
||||
type CapabilityContractKey =
|
||||
| "memoryEmbeddingProviders"
|
||||
| "speechProviders"
|
||||
| "realtimeTranscriptionProviders"
|
||||
| "realtimeVoiceProviders"
|
||||
@@ -27,6 +29,7 @@ type CapabilityProviderForKey<K extends CapabilityProviderRegistryKey> =
|
||||
PluginRegistry[K][number] extends { provider: infer T } ? T : never;
|
||||
|
||||
const CAPABILITY_CONTRACT_KEY: Record<CapabilityProviderRegistryKey, CapabilityContractKey> = {
|
||||
memoryEmbeddingProviders: "memoryEmbeddingProviders",
|
||||
speechProviders: "speechProviders",
|
||||
realtimeTranscriptionProviders: "realtimeTranscriptionProviders",
|
||||
realtimeVoiceProviders: "realtimeVoiceProviders",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { buildPluginApi } from "./api-builder.js";
|
||||
import type { MemoryEmbeddingProviderAdapter } from "./memory-embedding-providers.js";
|
||||
import type { PluginRuntime } from "./runtime/types.js";
|
||||
import type {
|
||||
AnyAgentTool,
|
||||
@@ -37,6 +38,7 @@ export type CapturedPluginRegistration = {
|
||||
videoGenerationProviders: VideoGenerationProviderPlugin[];
|
||||
webFetchProviders: WebFetchProviderPlugin[];
|
||||
webSearchProviders: WebSearchProviderPlugin[];
|
||||
memoryEmbeddingProviders: MemoryEmbeddingProviderAdapter[];
|
||||
tools: AnyAgentTool[];
|
||||
};
|
||||
|
||||
@@ -55,6 +57,7 @@ export function createCapturedPluginRegistration(params?: {
|
||||
const videoGenerationProviders: VideoGenerationProviderPlugin[] = [];
|
||||
const webFetchProviders: WebFetchProviderPlugin[] = [];
|
||||
const webSearchProviders: WebSearchProviderPlugin[] = [];
|
||||
const memoryEmbeddingProviders: MemoryEmbeddingProviderAdapter[] = [];
|
||||
const tools: AnyAgentTool[] = [];
|
||||
const noopLogger = {
|
||||
info() {},
|
||||
@@ -75,6 +78,7 @@ export function createCapturedPluginRegistration(params?: {
|
||||
videoGenerationProviders,
|
||||
webFetchProviders,
|
||||
webSearchProviders,
|
||||
memoryEmbeddingProviders,
|
||||
tools,
|
||||
api: buildPluginApi({
|
||||
id: "captured-plugin-registration",
|
||||
@@ -139,6 +143,9 @@ export function createCapturedPluginRegistration(params?: {
|
||||
registerWebSearchProvider(provider: WebSearchProviderPlugin) {
|
||||
webSearchProviders.push(provider);
|
||||
},
|
||||
registerMemoryEmbeddingProvider(adapter: MemoryEmbeddingProviderAdapter) {
|
||||
memoryEmbeddingProviders.push(adapter);
|
||||
},
|
||||
registerTool(tool) {
|
||||
if (typeof tool !== "function") {
|
||||
tools.push(tool);
|
||||
|
||||
@@ -18,6 +18,7 @@ function hasRuntimeContractSurface(plugin: PluginManifestRecord): boolean {
|
||||
plugin.contracts?.videoGenerationProviders?.length ||
|
||||
plugin.contracts?.webFetchProviders?.length ||
|
||||
plugin.contracts?.webSearchProviders?.length ||
|
||||
plugin.contracts?.memoryEmbeddingProviders?.length ||
|
||||
hasKind(plugin.kind, "memory"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getRegisteredMemoryEmbeddingProvider } from "../memory-embedding-provid
|
||||
import { createPluginRegistryFixture, registerVirtualTestPlugin } from "./testkit.js";
|
||||
|
||||
describe("memory embedding provider registration", () => {
|
||||
it("only allows memory plugins to register adapters", () => {
|
||||
it("rejects non-memory plugins that did not declare the capability contract", () => {
|
||||
const { config, registry } = createPluginRegistryFixture();
|
||||
|
||||
registerVirtualTestPlugin({
|
||||
@@ -24,12 +24,38 @@ describe("memory embedding provider registration", () => {
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
pluginId: "not-memory",
|
||||
message: "only memory plugins can register memory embedding providers",
|
||||
message:
|
||||
"plugin must own memory slot or declare contracts.memoryEmbeddingProviders for adapter: forbidden",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows non-memory plugins that declare the capability contract", () => {
|
||||
const { config, registry } = createPluginRegistryFixture();
|
||||
|
||||
registerVirtualTestPlugin({
|
||||
registry,
|
||||
config,
|
||||
id: "ollama",
|
||||
name: "Ollama",
|
||||
contracts: {
|
||||
memoryEmbeddingProviders: ["ollama"],
|
||||
},
|
||||
register(api) {
|
||||
api.registerMemoryEmbeddingProvider({
|
||||
id: "ollama",
|
||||
create: async () => ({ provider: null }),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
expect(getRegisteredMemoryEmbeddingProvider("ollama")).toEqual({
|
||||
adapter: expect.objectContaining({ id: "ollama" }),
|
||||
ownerPluginId: "ollama",
|
||||
});
|
||||
});
|
||||
|
||||
it("records the owning memory plugin id for registered adapters", () => {
|
||||
const { config, registry } = createPluginRegistryFixture();
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ export function registerVirtualTestPlugin(params: {
|
||||
name: string;
|
||||
source?: string;
|
||||
kind?: PluginRecord["kind"];
|
||||
contracts?: PluginRecord["contracts"];
|
||||
register(this: void, api: OpenClawPluginApi): void;
|
||||
}) {
|
||||
registerTestPlugin({
|
||||
@@ -99,6 +100,7 @@ export function registerVirtualTestPlugin(params: {
|
||||
name: params.name,
|
||||
source: params.source ?? `/virtual/${params.id}/index.ts`,
|
||||
...(params.kind ? { kind: params.kind } : {}),
|
||||
...(params.contracts ? { contracts: params.contracts } : {}),
|
||||
}),
|
||||
register: params.register,
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ import { discoverOpenClawPlugins } from "./discovery.js";
|
||||
import { initializeGlobalHookRunner } from "./hook-runner-global.js";
|
||||
import { clearPluginInteractiveHandlers } from "./interactive-registry.js";
|
||||
import { loadPluginManifestRegistry } from "./manifest-registry.js";
|
||||
import type { PluginManifestContracts } from "./manifest.js";
|
||||
import {
|
||||
clearMemoryEmbeddingProviders,
|
||||
listRegisteredMemoryEmbeddingProviders,
|
||||
@@ -565,6 +566,7 @@ function createPluginRecord(params: {
|
||||
enabled: boolean;
|
||||
activationState?: PluginActivationState;
|
||||
configSchema: boolean;
|
||||
contracts?: PluginManifestContracts;
|
||||
}): PluginRecord {
|
||||
return {
|
||||
id: params.id,
|
||||
@@ -597,6 +599,7 @@ function createPluginRecord(params: {
|
||||
videoGenerationProviderIds: [],
|
||||
webFetchProviderIds: [],
|
||||
webSearchProviderIds: [],
|
||||
memoryEmbeddingProviderIds: [],
|
||||
gatewayMethods: [],
|
||||
cliCommands: [],
|
||||
services: [],
|
||||
@@ -606,6 +609,7 @@ function createPluginRecord(params: {
|
||||
configSchema: params.configSchema,
|
||||
configUiHints: undefined,
|
||||
configJsonSchema: undefined,
|
||||
contracts: params.contracts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1185,6 +1189,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
enabled: false,
|
||||
activationState,
|
||||
configSchema: Boolean(manifestRecord.configSchema),
|
||||
contracts: manifestRecord.contracts,
|
||||
});
|
||||
record.status = "disabled";
|
||||
record.error = `overridden by ${existingOrigin} plugin`;
|
||||
@@ -1217,6 +1222,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
enabled: enableState.enabled,
|
||||
activationState,
|
||||
configSchema: Boolean(manifestRecord.configSchema),
|
||||
contracts: manifestRecord.contracts,
|
||||
});
|
||||
record.kind = manifestRecord.kind;
|
||||
record.configUiHints = manifestRecord.configUiHints;
|
||||
@@ -1743,6 +1749,7 @@ export async function loadOpenClawPluginCliRegistry(
|
||||
enabled: false,
|
||||
activationState,
|
||||
configSchema: Boolean(manifestRecord.configSchema),
|
||||
contracts: manifestRecord.contracts,
|
||||
});
|
||||
record.status = "disabled";
|
||||
record.error = `overridden by ${existingOrigin} plugin`;
|
||||
@@ -1775,6 +1782,7 @@ export async function loadOpenClawPluginCliRegistry(
|
||||
enabled: enableState.enabled,
|
||||
activationState,
|
||||
configSchema: Boolean(manifestRecord.configSchema),
|
||||
contracts: manifestRecord.contracts,
|
||||
});
|
||||
record.kind = manifestRecord.kind;
|
||||
record.configUiHints = manifestRecord.configUiHints;
|
||||
|
||||
@@ -29,7 +29,10 @@ import type {
|
||||
PluginOrigin,
|
||||
} from "./types.js";
|
||||
|
||||
type PluginManifestContractListKey = "webFetchProviders" | "webSearchProviders";
|
||||
type PluginManifestContractListKey =
|
||||
| "memoryEmbeddingProviders"
|
||||
| "webFetchProviders"
|
||||
| "webSearchProviders";
|
||||
|
||||
type SeenIdEntry = {
|
||||
candidate: PluginCandidate;
|
||||
|
||||
@@ -71,6 +71,7 @@ export type PluginManifest = {
|
||||
};
|
||||
|
||||
export type PluginManifestContracts = {
|
||||
memoryEmbeddingProviders?: string[];
|
||||
speechProviders?: string[];
|
||||
realtimeTranscriptionProviders?: string[];
|
||||
realtimeVoiceProviders?: string[];
|
||||
@@ -151,6 +152,7 @@ function normalizeManifestContracts(value: unknown): PluginManifestContracts | u
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const memoryEmbeddingProviders = normalizeStringList(value.memoryEmbeddingProviders);
|
||||
const speechProviders = normalizeStringList(value.speechProviders);
|
||||
const realtimeTranscriptionProviders = normalizeStringList(value.realtimeTranscriptionProviders);
|
||||
const realtimeVoiceProviders = normalizeStringList(value.realtimeVoiceProviders);
|
||||
@@ -161,6 +163,7 @@ function normalizeManifestContracts(value: unknown): PluginManifestContracts | u
|
||||
const webSearchProviders = normalizeStringList(value.webSearchProviders);
|
||||
const tools = normalizeStringList(value.tools);
|
||||
const contracts = {
|
||||
...(memoryEmbeddingProviders.length > 0 ? { memoryEmbeddingProviders } : {}),
|
||||
...(speechProviders.length > 0 ? { speechProviders } : {}),
|
||||
...(realtimeTranscriptionProviders.length > 0 ? { realtimeTranscriptionProviders } : {}),
|
||||
...(realtimeVoiceProviders.length > 0 ? { realtimeVoiceProviders } : {}),
|
||||
|
||||
73
src/plugins/memory-embedding-provider-runtime.test.ts
Normal file
73
src/plugins/memory-embedding-provider-runtime.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearMemoryEmbeddingProviders,
|
||||
registerMemoryEmbeddingProvider,
|
||||
type MemoryEmbeddingProviderAdapter,
|
||||
} from "./memory-embedding-providers.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolvePluginCapabilityProviders: vi.fn<
|
||||
typeof import("./capability-provider-runtime.js").resolvePluginCapabilityProviders
|
||||
>(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("./capability-provider-runtime.js", () => ({
|
||||
resolvePluginCapabilityProviders: mocks.resolvePluginCapabilityProviders,
|
||||
}));
|
||||
|
||||
let runtimeModule: typeof import("./memory-embedding-provider-runtime.js");
|
||||
|
||||
function createCapabilityAdapter(id: string): MemoryEmbeddingProviderAdapter {
|
||||
return {
|
||||
id,
|
||||
create: async () => ({ provider: null }),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
clearMemoryEmbeddingProviders();
|
||||
mocks.resolvePluginCapabilityProviders.mockReset();
|
||||
mocks.resolvePluginCapabilityProviders.mockReturnValue([]);
|
||||
runtimeModule = await import("./memory-embedding-provider-runtime.js");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearMemoryEmbeddingProviders();
|
||||
});
|
||||
|
||||
describe("memory embedding provider runtime resolution", () => {
|
||||
it("prefers registered adapters over capability fallback adapters", () => {
|
||||
registerMemoryEmbeddingProvider({
|
||||
id: "registered",
|
||||
create: async () => ({ provider: null }),
|
||||
});
|
||||
mocks.resolvePluginCapabilityProviders.mockReturnValue([createCapabilityAdapter("capability")]);
|
||||
|
||||
expect(runtimeModule.listMemoryEmbeddingProviders().map((adapter) => adapter.id)).toEqual([
|
||||
"registered",
|
||||
]);
|
||||
expect(runtimeModule.getMemoryEmbeddingProvider("registered")?.id).toBe("registered");
|
||||
expect(mocks.resolvePluginCapabilityProviders).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to declared capability adapters when the registry is cold", () => {
|
||||
mocks.resolvePluginCapabilityProviders.mockReturnValue([createCapabilityAdapter("ollama")]);
|
||||
|
||||
expect(runtimeModule.listMemoryEmbeddingProviders().map((adapter) => adapter.id)).toEqual([
|
||||
"ollama",
|
||||
]);
|
||||
expect(runtimeModule.getMemoryEmbeddingProvider("ollama")?.id).toBe("ollama");
|
||||
expect(mocks.resolvePluginCapabilityProviders).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not consult capability fallback once runtime adapters are registered", () => {
|
||||
registerMemoryEmbeddingProvider({
|
||||
id: "openai",
|
||||
create: async () => ({ provider: null }),
|
||||
});
|
||||
mocks.resolvePluginCapabilityProviders.mockReturnValue([createCapabilityAdapter("ollama")]);
|
||||
|
||||
expect(runtimeModule.getMemoryEmbeddingProvider("ollama")).toBeUndefined();
|
||||
expect(mocks.resolvePluginCapabilityProviders).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
34
src/plugins/memory-embedding-provider-runtime.ts
Normal file
34
src/plugins/memory-embedding-provider-runtime.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolvePluginCapabilityProviders } from "./capability-provider-runtime.js";
|
||||
import {
|
||||
getRegisteredMemoryEmbeddingProvider,
|
||||
listRegisteredMemoryEmbeddingProviders,
|
||||
type MemoryEmbeddingProviderAdapter,
|
||||
} from "./memory-embedding-providers.js";
|
||||
|
||||
export function listMemoryEmbeddingProviders(
|
||||
cfg?: OpenClawConfig,
|
||||
): MemoryEmbeddingProviderAdapter[] {
|
||||
const registered = listRegisteredMemoryEmbeddingProviders();
|
||||
if (registered.length > 0) {
|
||||
return registered.map((entry) => entry.adapter);
|
||||
}
|
||||
return resolvePluginCapabilityProviders({
|
||||
key: "memoryEmbeddingProviders",
|
||||
cfg,
|
||||
});
|
||||
}
|
||||
|
||||
export function getMemoryEmbeddingProvider(
|
||||
id: string,
|
||||
cfg?: OpenClawConfig,
|
||||
): MemoryEmbeddingProviderAdapter | undefined {
|
||||
const registered = getRegisteredMemoryEmbeddingProvider(id);
|
||||
if (registered) {
|
||||
return registered.adapter;
|
||||
}
|
||||
if (listRegisteredMemoryEmbeddingProviders().length > 0) {
|
||||
return undefined;
|
||||
}
|
||||
return listMemoryEmbeddingProviders(cfg).find((adapter) => adapter.id === id);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
|
||||
videoGenerationProviders: [],
|
||||
webFetchProviders: [],
|
||||
webSearchProviders: [],
|
||||
memoryEmbeddingProviders: [],
|
||||
gatewayHandlers: {},
|
||||
gatewayMethodScopes: {},
|
||||
httpRoutes: [],
|
||||
|
||||
@@ -17,8 +17,10 @@ import type { PluginActivationSource } from "./config-state.js";
|
||||
import { normalizePluginHttpPath } from "./http-path.js";
|
||||
import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js";
|
||||
import { registerPluginInteractiveHandler } from "./interactive-registry.js";
|
||||
import type { PluginManifestContracts } from "./manifest.js";
|
||||
import {
|
||||
getRegisteredMemoryEmbeddingProvider,
|
||||
type MemoryEmbeddingProviderAdapter,
|
||||
registerMemoryEmbeddingProvider,
|
||||
} from "./memory-embedding-providers.js";
|
||||
import {
|
||||
@@ -160,6 +162,8 @@ export type PluginWebFetchProviderRegistration =
|
||||
PluginOwnedProviderRegistration<WebFetchProviderPlugin>;
|
||||
export type PluginWebSearchProviderRegistration =
|
||||
PluginOwnedProviderRegistration<WebSearchProviderPlugin>;
|
||||
export type PluginMemoryEmbeddingProviderRegistration =
|
||||
PluginOwnedProviderRegistration<MemoryEmbeddingProviderAdapter>;
|
||||
|
||||
export type PluginHookRegistration = {
|
||||
pluginId: string;
|
||||
@@ -230,6 +234,7 @@ export type PluginRecord = {
|
||||
videoGenerationProviderIds: string[];
|
||||
webFetchProviderIds: string[];
|
||||
webSearchProviderIds: string[];
|
||||
memoryEmbeddingProviderIds: string[];
|
||||
gatewayMethods: string[];
|
||||
cliCommands: string[];
|
||||
services: string[];
|
||||
@@ -239,6 +244,7 @@ export type PluginRecord = {
|
||||
configSchema: boolean;
|
||||
configUiHints?: Record<string, PluginConfigUiHint>;
|
||||
configJsonSchema?: Record<string, unknown>;
|
||||
contracts?: PluginManifestContracts;
|
||||
memorySlotSelected?: boolean;
|
||||
};
|
||||
|
||||
@@ -259,6 +265,7 @@ export type PluginRegistry = {
|
||||
videoGenerationProviders: PluginVideoGenerationProviderRegistration[];
|
||||
webFetchProviders: PluginWebFetchProviderRegistration[];
|
||||
webSearchProviders: PluginWebSearchProviderRegistration[];
|
||||
memoryEmbeddingProviders: PluginMemoryEmbeddingProviderRegistration[];
|
||||
gatewayHandlers: GatewayRequestHandlers;
|
||||
gatewayMethodScopes?: Partial<Record<string, OperatorScope>>;
|
||||
httpRoutes: PluginHttpRouteRegistration[];
|
||||
@@ -1208,26 +1215,29 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
registerMemoryRuntime(runtime);
|
||||
},
|
||||
registerMemoryEmbeddingProvider: (adapter) => {
|
||||
if (!hasKind(record.kind, "memory")) {
|
||||
if (hasKind(record.kind, "memory")) {
|
||||
if (
|
||||
Array.isArray(record.kind) &&
|
||||
record.kind.length > 1 &&
|
||||
!record.memorySlotSelected
|
||||
) {
|
||||
pushDiagnostic({
|
||||
level: "warn",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message:
|
||||
"dual-kind plugin not selected for memory slot; skipping memory embedding provider registration",
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else if (
|
||||
!(record.contracts?.memoryEmbeddingProviders ?? []).includes(adapter.id)
|
||||
) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: "only memory plugins can register memory embedding providers",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
Array.isArray(record.kind) &&
|
||||
record.kind.length > 1 &&
|
||||
!record.memorySlotSelected
|
||||
) {
|
||||
pushDiagnostic({
|
||||
level: "warn",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message:
|
||||
"dual-kind plugin not selected for memory slot; skipping memory embedding provider registration",
|
||||
message: `plugin must own memory slot or declare contracts.memoryEmbeddingProviders for adapter: ${adapter.id}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1247,6 +1257,13 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
registerMemoryEmbeddingProvider(adapter, {
|
||||
ownerPluginId: record.id,
|
||||
});
|
||||
registry.memoryEmbeddingProviders.push({
|
||||
pluginId: record.id,
|
||||
pluginName: record.name,
|
||||
provider: adapter,
|
||||
source: record.source,
|
||||
rootDir: record.rootDir,
|
||||
});
|
||||
},
|
||||
on: (hookName, handler, opts) =>
|
||||
registerTypedHook(record, hookName, handler, opts, params.hookPolicy),
|
||||
|
||||
@@ -206,6 +206,7 @@ describe("setActivePluginRegistry", () => {
|
||||
videoGenerationProviderIds: [],
|
||||
webFetchProviderIds: [],
|
||||
webSearchProviderIds: [],
|
||||
memoryEmbeddingProviderIds: [],
|
||||
gatewayMethods: [],
|
||||
cliCommands: [],
|
||||
services: [],
|
||||
@@ -235,6 +236,7 @@ describe("setActivePluginRegistry", () => {
|
||||
videoGenerationProviderIds: [],
|
||||
webFetchProviderIds: [],
|
||||
webSearchProviderIds: [],
|
||||
memoryEmbeddingProviderIds: [],
|
||||
gatewayMethods: [],
|
||||
cliCommands: [],
|
||||
services: [],
|
||||
|
||||
377
src/plugins/setup-registry.ts
Normal file
377
src/plugins/setup-registry.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createJiti } from "jiti";
|
||||
import { normalizeProviderId } from "../agents/provider-id.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { buildPluginApi } from "./api-builder.js";
|
||||
import { discoverOpenClawPlugins } from "./discovery.js";
|
||||
import { loadPluginManifestRegistry } from "./manifest-registry.js";
|
||||
import { resolvePluginCacheInputs } from "./roots.js";
|
||||
import type { PluginRuntime } from "./runtime/types.js";
|
||||
import {
|
||||
buildPluginLoaderAliasMap,
|
||||
buildPluginLoaderJitiOptions,
|
||||
shouldPreferNativeJiti,
|
||||
} from "./sdk-alias.js";
|
||||
import type {
|
||||
CliBackendPlugin,
|
||||
OpenClawPluginModule,
|
||||
PluginConfigMigration,
|
||||
PluginLegacyConfigMigration,
|
||||
PluginLogger,
|
||||
PluginSetupAutoEnableProbe,
|
||||
ProviderPlugin,
|
||||
} from "./types.js";
|
||||
|
||||
const SETUP_API_EXTENSIONS = [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"] as const;
|
||||
const CURRENT_MODULE_PATH = fileURLToPath(import.meta.url);
|
||||
const RUNNING_FROM_BUILT_ARTIFACT =
|
||||
CURRENT_MODULE_PATH.includes(`${path.sep}dist${path.sep}`) ||
|
||||
CURRENT_MODULE_PATH.includes(`${path.sep}dist-runtime${path.sep}`);
|
||||
|
||||
type SetupProviderEntry = {
|
||||
pluginId: string;
|
||||
provider: ProviderPlugin;
|
||||
};
|
||||
|
||||
type SetupCliBackendEntry = {
|
||||
pluginId: string;
|
||||
backend: CliBackendPlugin;
|
||||
};
|
||||
|
||||
type SetupConfigMigrationEntry = {
|
||||
pluginId: string;
|
||||
migrate: PluginConfigMigration;
|
||||
};
|
||||
|
||||
type SetupLegacyConfigMigrationEntry = {
|
||||
pluginId: string;
|
||||
migrate: PluginLegacyConfigMigration;
|
||||
};
|
||||
|
||||
type SetupAutoEnableProbeEntry = {
|
||||
pluginId: string;
|
||||
probe: PluginSetupAutoEnableProbe;
|
||||
};
|
||||
|
||||
type PluginSetupRegistry = {
|
||||
providers: SetupProviderEntry[];
|
||||
cliBackends: SetupCliBackendEntry[];
|
||||
configMigrations: SetupConfigMigrationEntry[];
|
||||
legacyConfigMigrations: SetupLegacyConfigMigrationEntry[];
|
||||
autoEnableProbes: SetupAutoEnableProbeEntry[];
|
||||
};
|
||||
|
||||
type SetupAutoEnableReason = {
|
||||
pluginId: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
const EMPTY_RUNTIME = {} as PluginRuntime;
|
||||
const NOOP_LOGGER: PluginLogger = {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
};
|
||||
|
||||
const jitiLoaders = new Map<string, ReturnType<typeof createJiti>>();
|
||||
const setupRegistryCache = new Map<string, PluginSetupRegistry>();
|
||||
|
||||
export function clearPluginSetupRegistryCache(): void {
|
||||
setupRegistryCache.clear();
|
||||
}
|
||||
|
||||
function getJiti(modulePath: string) {
|
||||
const aliasMap = buildPluginLoaderAliasMap(modulePath, process.argv[1], import.meta.url);
|
||||
const cacheKey = JSON.stringify({
|
||||
tryNative: shouldPreferNativeJiti(modulePath),
|
||||
aliasMap: Object.entries(aliasMap).toSorted(([left], [right]) => left.localeCompare(right)),
|
||||
});
|
||||
const cached = jitiLoaders.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const loader = createJiti(modulePath, buildPluginLoaderJitiOptions(aliasMap));
|
||||
jitiLoaders.set(cacheKey, loader);
|
||||
return loader;
|
||||
}
|
||||
|
||||
function buildSetupRegistryCacheKey(params: {
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): string {
|
||||
const { roots, loadPaths } = resolvePluginCacheInputs({
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
});
|
||||
return JSON.stringify({
|
||||
roots,
|
||||
loadPaths,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveSetupApiPath(rootDir: string): string | null {
|
||||
const orderedExtensions = RUNNING_FROM_BUILT_ARTIFACT
|
||||
? SETUP_API_EXTENSIONS
|
||||
: ([...SETUP_API_EXTENSIONS.slice(3), ...SETUP_API_EXTENSIONS.slice(0, 3)] as const);
|
||||
for (const extension of orderedExtensions) {
|
||||
const candidate = path.join(rootDir, `setup-api${extension}`);
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveRegister(mod: OpenClawPluginModule): {
|
||||
definition?: { id?: string };
|
||||
register?: (api: ReturnType<typeof buildPluginApi>) => void | Promise<void>;
|
||||
} {
|
||||
if (typeof mod === "function") {
|
||||
return { register: mod };
|
||||
}
|
||||
if (mod && typeof mod === "object" && typeof mod.register === "function") {
|
||||
return {
|
||||
definition: mod as { id?: string },
|
||||
register: mod.register.bind(mod),
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function matchesProvider(provider: ProviderPlugin, providerId: string): boolean {
|
||||
const normalized = normalizeProviderId(providerId);
|
||||
if (normalizeProviderId(provider.id) === normalized) {
|
||||
return true;
|
||||
}
|
||||
return [...(provider.aliases ?? []), ...(provider.hookAliases ?? [])].some(
|
||||
(alias) => normalizeProviderId(alias) === normalized,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePluginSetupRegistry(params?: {
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): PluginSetupRegistry {
|
||||
const env = params?.env ?? process.env;
|
||||
const cacheKey = buildSetupRegistryCacheKey({
|
||||
workspaceDir: params?.workspaceDir,
|
||||
env,
|
||||
});
|
||||
const cached = setupRegistryCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const providers: SetupProviderEntry[] = [];
|
||||
const cliBackends: SetupCliBackendEntry[] = [];
|
||||
const configMigrations: SetupConfigMigrationEntry[] = [];
|
||||
const legacyConfigMigrations: SetupLegacyConfigMigrationEntry[] = [];
|
||||
const autoEnableProbes: SetupAutoEnableProbeEntry[] = [];
|
||||
const providerKeys = new Set<string>();
|
||||
const cliBackendKeys = new Set<string>();
|
||||
|
||||
const discovery = discoverOpenClawPlugins({
|
||||
workspaceDir: params?.workspaceDir,
|
||||
env,
|
||||
cache: true,
|
||||
});
|
||||
const manifestRegistry = loadPluginManifestRegistry({
|
||||
workspaceDir: params?.workspaceDir,
|
||||
env,
|
||||
cache: true,
|
||||
candidates: discovery.candidates,
|
||||
diagnostics: discovery.diagnostics,
|
||||
});
|
||||
|
||||
for (const record of manifestRegistry.plugins) {
|
||||
const setupSource = resolveSetupApiPath(record.rootDir);
|
||||
if (!setupSource) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mod: OpenClawPluginModule;
|
||||
try {
|
||||
mod = getJiti(setupSource)(setupSource) as OpenClawPluginModule;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = resolveRegister((mod as { default?: OpenClawPluginModule }).default ?? mod);
|
||||
if (!resolved.register) {
|
||||
continue;
|
||||
}
|
||||
if (resolved.definition?.id && resolved.definition.id !== record.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const api = buildPluginApi({
|
||||
id: record.id,
|
||||
name: record.name ?? record.id,
|
||||
version: record.version,
|
||||
description: record.description,
|
||||
source: setupSource,
|
||||
rootDir: record.rootDir,
|
||||
registrationMode: "setup-only",
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: EMPTY_RUNTIME,
|
||||
logger: NOOP_LOGGER,
|
||||
resolvePath: (input) => input,
|
||||
handlers: {
|
||||
registerProvider(provider) {
|
||||
const key = `${record.id}:${normalizeProviderId(provider.id)}`;
|
||||
if (providerKeys.has(key)) {
|
||||
return;
|
||||
}
|
||||
providerKeys.add(key);
|
||||
providers.push({
|
||||
pluginId: record.id,
|
||||
provider,
|
||||
});
|
||||
},
|
||||
registerCliBackend(backend) {
|
||||
const key = `${record.id}:${normalizeProviderId(backend.id)}`;
|
||||
if (cliBackendKeys.has(key)) {
|
||||
return;
|
||||
}
|
||||
cliBackendKeys.add(key);
|
||||
cliBackends.push({
|
||||
pluginId: record.id,
|
||||
backend,
|
||||
});
|
||||
},
|
||||
registerConfigMigration(migrate) {
|
||||
configMigrations.push({
|
||||
pluginId: record.id,
|
||||
migrate,
|
||||
});
|
||||
},
|
||||
registerLegacyConfigMigration(migrate) {
|
||||
legacyConfigMigrations.push({
|
||||
pluginId: record.id,
|
||||
migrate,
|
||||
});
|
||||
},
|
||||
registerAutoEnableProbe(probe) {
|
||||
autoEnableProbes.push({
|
||||
pluginId: record.id,
|
||||
probe,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = resolved.register(api);
|
||||
if (result && typeof result.then === "function") {
|
||||
// Keep setup registration sync-only.
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const registry = {
|
||||
providers,
|
||||
cliBackends,
|
||||
configMigrations,
|
||||
legacyConfigMigrations,
|
||||
autoEnableProbes,
|
||||
} satisfies PluginSetupRegistry;
|
||||
setupRegistryCache.set(cacheKey, registry);
|
||||
return registry;
|
||||
}
|
||||
|
||||
export function resolvePluginSetupProvider(params: {
|
||||
provider: string;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): ProviderPlugin | undefined {
|
||||
return resolvePluginSetupRegistry(params).providers.find((entry) =>
|
||||
matchesProvider(entry.provider, params.provider),
|
||||
)?.provider;
|
||||
}
|
||||
|
||||
export function resolvePluginSetupCliBackend(params: {
|
||||
backend: string;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): SetupCliBackendEntry | undefined {
|
||||
const normalized = normalizeProviderId(params.backend);
|
||||
return resolvePluginSetupRegistry(params).cliBackends.find(
|
||||
(entry) => normalizeProviderId(entry.backend.id) === normalized,
|
||||
);
|
||||
}
|
||||
|
||||
export function runPluginSetupConfigMigrations(params: {
|
||||
config: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): {
|
||||
config: OpenClawConfig;
|
||||
changes: string[];
|
||||
} {
|
||||
let next = params.config;
|
||||
const changes: string[] = [];
|
||||
|
||||
for (const entry of resolvePluginSetupRegistry(params).configMigrations) {
|
||||
const migration = entry.migrate(next);
|
||||
if (!migration || migration.changes.length === 0) {
|
||||
continue;
|
||||
}
|
||||
next = migration.config;
|
||||
changes.push(...migration.changes);
|
||||
}
|
||||
|
||||
return { config: next, changes };
|
||||
}
|
||||
|
||||
export function runPluginSetupLegacyConfigMigrations(params: {
|
||||
raw: Record<string, unknown>;
|
||||
changes: string[];
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): void {
|
||||
for (const entry of resolvePluginSetupRegistry(params).legacyConfigMigrations) {
|
||||
entry.migrate(params.raw, params.changes);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePluginSetupAutoEnableReasons(params: {
|
||||
config: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): SetupAutoEnableReason[] {
|
||||
const env = params.env ?? process.env;
|
||||
const reasons: SetupAutoEnableReason[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const entry of resolvePluginSetupRegistry({
|
||||
workspaceDir: params.workspaceDir,
|
||||
env,
|
||||
}).autoEnableProbes) {
|
||||
const raw = entry.probe({
|
||||
config: params.config,
|
||||
env,
|
||||
});
|
||||
const values = Array.isArray(raw) ? raw : raw ? [raw] : [];
|
||||
for (const reason of values) {
|
||||
const normalized = reason.trim();
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
const key = `${entry.pluginId}:${normalized}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
reasons.push({
|
||||
pluginId: entry.pluginId,
|
||||
reason: normalized,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return reasons;
|
||||
}
|
||||
@@ -58,6 +58,7 @@ export function createPluginRecord(
|
||||
videoGenerationProviderIds: [],
|
||||
webFetchProviderIds: [],
|
||||
webSearchProviderIds: [],
|
||||
memoryEmbeddingProviderIds: [],
|
||||
gatewayMethods: [],
|
||||
cliCommands: [],
|
||||
services: [],
|
||||
@@ -123,6 +124,7 @@ export function createPluginLoadResult(
|
||||
videoGenerationProviders: [],
|
||||
webFetchProviders: [],
|
||||
webSearchProviders: [],
|
||||
memoryEmbeddingProviders: [],
|
||||
tools: [],
|
||||
hooks: [],
|
||||
typedHooks: [],
|
||||
|
||||
@@ -1988,6 +1988,25 @@ export type OpenClawPluginModule =
|
||||
|
||||
export type PluginRegistrationMode = "full" | "setup-only" | "setup-runtime" | "cli-metadata";
|
||||
|
||||
export type PluginConfigMigration = (config: OpenClawConfig) =>
|
||||
| {
|
||||
config: OpenClawConfig;
|
||||
changes: string[];
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export type PluginLegacyConfigMigration = (raw: Record<string, unknown>, changes: string[]) => void;
|
||||
|
||||
export type PluginSetupAutoEnableContext = {
|
||||
config: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
export type PluginSetupAutoEnableProbe = (
|
||||
ctx: PluginSetupAutoEnableContext,
|
||||
) => string | string[] | null | undefined;
|
||||
|
||||
/** Main registration API injected into native plugin entry files. */
|
||||
export type OpenClawPluginApi = {
|
||||
id: string;
|
||||
@@ -2049,6 +2068,12 @@ export type OpenClawPluginApi = {
|
||||
registerService: (service: OpenClawPluginService) => void;
|
||||
/** Register a text-only CLI backend used by the local CLI runner. */
|
||||
registerCliBackend: (backend: CliBackendPlugin) => void;
|
||||
/** Register a lightweight config migration that can run before plugin runtime loads. */
|
||||
registerConfigMigration: (migrate: PluginConfigMigration) => void;
|
||||
/** Register a lightweight raw legacy-config migration for pre-schema config repair. */
|
||||
registerLegacyConfigMigration: (migrate: PluginLegacyConfigMigration) => void;
|
||||
/** Register a lightweight config probe that can auto-enable this plugin generically. */
|
||||
registerAutoEnableProbe: (probe: PluginSetupAutoEnableProbe) => void;
|
||||
/** Register a native model/provider plugin (text inference capability). */
|
||||
registerProvider: (provider: ProviderPlugin) => void;
|
||||
/** Register a speech synthesis provider (speech capability). */
|
||||
|
||||
Reference in New Issue
Block a user