diff --git a/CHANGELOG.md b/CHANGELOG.md index 62e54c6d5dc..57307903afd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Extensions/media local-root propagation: consistently forward `mediaLocalRoots` through extension `sendMedia` adapters (Google Chat, Slack, iMessage, Signal, WhatsApp), preserving non-local media behavior while restoring local attachment resolution from configured roots. Synthesis of #33581, #33545, #33540, #33536, #33528. Thanks @bmendonca3. - Gateway/security default response headers: add `Permissions-Policy: camera=(), microphone=(), geolocation=()` to baseline gateway HTTP security headers for all responses. (#30186) thanks @habakan. - Plugins/startup loading: lazily initialize plugin runtime, split startup-critical plugin SDK imports into `openclaw/plugin-sdk/core` and `openclaw/plugin-sdk/telegram`, and preserve `api.runtime` reflection semantics for plugin compatibility. (#28620) thanks @hmemcpy. +- Plugins/startup performance: reduce bursty plugin discovery/manifest overhead with short in-process caches, skip importing bundled memory plugins that are disabled by slot selection, and speed legacy root `openclaw/plugin-sdk` compatibility via runtime root-alias routing while preserving backward compatibility. Thanks @gumadeiras. - Build/lazy runtime boundaries: replace ineffective dynamic import sites with dedicated lazy runtime boundaries across Slack slash handling, Telegram audit, CLI send deps, memory fallback, and outbound delivery paths while preserving behavior. (#33690) thanks @gumadeiras. - Config/heartbeat legacy-path handling: auto-migrate top-level `heartbeat` into `agents.defaults.heartbeat` (with merge semantics that preserve explicit defaults), and keep startup failures on non-migratable legacy entries in the detailed invalid-config path instead of generic migration-failed errors. (#32706) thanks @xiwan. - Plugins/SDK subpath parity: add channel-specific plugin SDK subpaths for Discord, Slack, Signal, iMessage, WhatsApp, and LINE; migrate bundled plugin entrypoints to scoped subpaths/core with CI guardrails; and keep `openclaw/plugin-sdk` root import compatibility for existing external plugins. (#33737) thanks @gumadeiras. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index f5fd5a34ab6..60d5aa61c37 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -112,6 +112,7 @@ Use SDK subpaths instead of the monolithic `openclaw/plugin-sdk` import when authoring plugins: - `openclaw/plugin-sdk/core` for generic plugin APIs, provider auth types, and shared helpers. +- `openclaw/plugin-sdk/compat` for bundled/internal plugin code that needs broader shared runtime helpers than `core`. - `openclaw/plugin-sdk/telegram` for Telegram channel plugins. - `openclaw/plugin-sdk/discord` for Discord channel plugins. - `openclaw/plugin-sdk/slack` for Slack channel plugins. @@ -123,8 +124,17 @@ authoring plugins: Compatibility note: - `openclaw/plugin-sdk` remains supported for existing external plugins. -- New and migrated bundled plugins should use channel subpaths (or `core`) to - keep startup imports scoped. +- New and migrated bundled plugins should use channel subpaths and `core`; use + `compat` only when broader shared helpers are required. + +Performance note: + +- Plugin discovery and manifest metadata use short in-process caches to reduce + bursty startup/reload work. +- Set `OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE=1` or + `OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE=1` to disable these caches. +- Tune cache windows with `OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS` and + `OPENCLAW_PLUGIN_MANIFEST_CACHE_MS`. ## Discovery & precedence diff --git a/package.json b/package.json index 60c1ebaf263..590f2b4e9a4 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,10 @@ "types": "./dist/plugin-sdk/core.d.ts", "default": "./dist/plugin-sdk/core.js" }, + "./plugin-sdk/compat": { + "types": "./dist/plugin-sdk/compat.d.ts", + "default": "./dist/plugin-sdk/compat.js" + }, "./plugin-sdk/telegram": { "types": "./dist/plugin-sdk/telegram.d.ts", "default": "./dist/plugin-sdk/telegram.js" @@ -91,9 +95,9 @@ "android:run": "cd apps/android && ./gradlew :app:installDebug && adb shell am start -n ai.openclaw.android/.MainActivity", "android:test": "cd apps/android && ./gradlew :app:testDebugUnitTest", "android:test:integration": "OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_ANDROID_NODE=1 vitest run --config vitest.live.config.ts src/gateway/android-node.capabilities.live.test.ts", - "build": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", + "build": "pnpm canvas:a2ui:bundle && tsdown && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json", - "build:strict-smoke": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts", + "build:strict-smoke": "pnpm canvas:a2ui:bundle && tsdown && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", "check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope && pnpm check:host-env-policy:swift", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links", diff --git a/scripts/check-no-monolithic-plugin-sdk-entry-imports.ts b/scripts/check-no-monolithic-plugin-sdk-entry-imports.ts index 3c41add7ab6..bde974d5154 100644 --- a/scripts/check-no-monolithic-plugin-sdk-entry-imports.ts +++ b/scripts/check-no-monolithic-plugin-sdk-entry-imports.ts @@ -13,14 +13,68 @@ function hasMonolithicRootImport(content: string): boolean { return ROOT_IMPORT_PATTERNS.some((pattern) => pattern.test(content)); } +function isSourceFile(filePath: string): boolean { + if (filePath.endsWith(".d.ts")) { + return false; + } + return /\.(?:[cm]?ts|[cm]?js|tsx|jsx)$/u.test(filePath); +} + +function collectPluginSourceFiles(rootDir: string): string[] { + const srcDir = path.join(rootDir, "src"); + if (!fs.existsSync(srcDir)) { + return []; + } + + const files: string[] = []; + const stack: string[] = [srcDir]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + let entries: fs.Dirent[] = []; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + if ( + entry.name === "node_modules" || + entry.name === "dist" || + entry.name === ".git" || + entry.name === "coverage" + ) { + continue; + } + stack.push(fullPath); + continue; + } + if (entry.isFile() && isSourceFile(fullPath)) { + files.push(fullPath); + } + } + } + + return files; +} + function main() { const discovery = discoverOpenClawPlugins({}); - const bundledEntryFiles = [ - ...new Set(discovery.candidates.filter((c) => c.origin === "bundled").map((c) => c.source)), - ]; + const bundledCandidates = discovery.candidates.filter((c) => c.origin === "bundled"); + const filesToCheck = new Set(); + for (const candidate of bundledCandidates) { + filesToCheck.add(candidate.source); + for (const srcFile of collectPluginSourceFiles(candidate.rootDir)) { + filesToCheck.add(srcFile); + } + } const offenders: string[] = []; - for (const entryFile of bundledEntryFiles) { + for (const entryFile of filesToCheck) { let content = ""; try { content = fs.readFileSync(entryFile, "utf8"); @@ -33,17 +87,19 @@ function main() { } if (offenders.length > 0) { - console.error("Bundled plugin entrypoints must not import monolithic openclaw/plugin-sdk."); + console.error("Bundled plugin source files must not import monolithic openclaw/plugin-sdk."); for (const file of offenders.toSorted()) { const relative = path.relative(process.cwd(), file) || file; console.error(`- ${relative}`); } - console.error("Use openclaw/plugin-sdk/ for channel plugins or /core for others."); + console.error( + "Use openclaw/plugin-sdk/ for channel plugins, /core for startup surfaces, or /compat for broader internals.", + ); process.exit(1); } console.log( - `OK: bundled entrypoints use scoped plugin-sdk subpaths (${bundledEntryFiles.length} checked).`, + `OK: bundled plugin source files use scoped plugin-sdk subpaths (${filesToCheck.size} checked).`, ); } diff --git a/scripts/check-plugin-sdk-exports.mjs b/scripts/check-plugin-sdk-exports.mjs index 993c92e33c3..87d7826945f 100755 --- a/scripts/check-plugin-sdk-exports.mjs +++ b/scripts/check-plugin-sdk-exports.mjs @@ -43,6 +43,7 @@ const exportSet = new Set(exportedNames); const requiredSubpathEntries = [ "core", + "compat", "telegram", "discord", "slack", @@ -53,6 +54,8 @@ const requiredSubpathEntries = [ "account-id", ]; +const requiredRuntimeShimEntries = ["root-alias.cjs"]; + // Critical functions that channel extension plugins import from openclaw/plugin-sdk. // If any of these are missing, plugins will fail at runtime with: // TypeError: (0 , _pluginSdk.) is not a function @@ -101,6 +104,14 @@ for (const entry of requiredSubpathEntries) { } } +for (const entry of requiredRuntimeShimEntries) { + const shimPath = resolve(__dirname, "..", "dist", "plugin-sdk", entry); + if (!existsSync(shimPath)) { + console.error(`MISSING RUNTIME SHIM: dist/plugin-sdk/${entry}`); + missing += 1; + } +} + if (missing > 0) { console.error( `\nERROR: ${missing} required plugin-sdk artifact(s) missing (named exports or subpath files).`, diff --git a/scripts/copy-plugin-sdk-root-alias.mjs b/scripts/copy-plugin-sdk-root-alias.mjs new file mode 100644 index 00000000000..b1bf80b6312 --- /dev/null +++ b/scripts/copy-plugin-sdk-root-alias.mjs @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { copyFileSync, mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +const source = resolve("src/plugin-sdk/root-alias.cjs"); +const target = resolve("dist/plugin-sdk/root-alias.cjs"); + +mkdirSync(dirname(target), { recursive: true }); +copyFileSync(source, target); diff --git a/scripts/release-check.ts b/scripts/release-check.ts index d4f302a824b..9b2848e8ead 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -16,6 +16,9 @@ const requiredPathGroups = [ "dist/plugin-sdk/index.d.ts", "dist/plugin-sdk/core.js", "dist/plugin-sdk/core.d.ts", + "dist/plugin-sdk/root-alias.cjs", + "dist/plugin-sdk/compat.js", + "dist/plugin-sdk/compat.d.ts", "dist/plugin-sdk/telegram.js", "dist/plugin-sdk/telegram.d.ts", "dist/plugin-sdk/discord.js", diff --git a/scripts/write-plugin-sdk-entry-dts.ts b/scripts/write-plugin-sdk-entry-dts.ts index 611ec4dfe86..197b36004a8 100644 --- a/scripts/write-plugin-sdk-entry-dts.ts +++ b/scripts/write-plugin-sdk-entry-dts.ts @@ -9,6 +9,7 @@ import path from "node:path"; const entrypoints = [ "index", "core", + "compat", "telegram", "discord", "slack", diff --git a/src/plugin-sdk/compat.ts b/src/plugin-sdk/compat.ts new file mode 100644 index 00000000000..8e893de15df --- /dev/null +++ b/src/plugin-sdk/compat.ts @@ -0,0 +1 @@ +export * from "./index.js"; diff --git a/src/plugin-sdk/root-alias.cjs b/src/plugin-sdk/root-alias.cjs new file mode 100644 index 00000000000..37626deebaf --- /dev/null +++ b/src/plugin-sdk/root-alias.cjs @@ -0,0 +1,145 @@ +"use strict"; + +const path = require("node:path"); +const fs = require("node:fs"); + +let monolithicSdk = null; + +function emptyPluginConfigSchema() { + function error(message) { + return { success: false, error: { issues: [{ path: [], message }] } }; + } + + return { + safeParse(value) { + if (value === undefined) { + return { success: true, data: undefined }; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return error("expected config object"); + } + if (Object.keys(value).length > 0) { + return error("config must be empty"); + } + return { success: true, data: value }; + }, + jsonSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + }; +} + +function loadMonolithicSdk() { + if (monolithicSdk) { + return monolithicSdk; + } + + const { createJiti } = require("jiti"); + const jiti = createJiti(__filename, { + interopDefault: true, + extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"], + }); + + const distCandidate = path.resolve(__dirname, "..", "..", "dist", "plugin-sdk", "index.js"); + if (fs.existsSync(distCandidate)) { + try { + monolithicSdk = jiti(distCandidate); + return monolithicSdk; + } catch { + // Fall through to source alias if dist is unavailable or stale. + } + } + + monolithicSdk = jiti(path.join(__dirname, "index.ts")); + return monolithicSdk; +} + +const fastExports = { + emptyPluginConfigSchema, +}; + +const rootProxy = new Proxy(fastExports, { + get(target, prop, receiver) { + if (prop === "__esModule") { + return true; + } + if (prop === "default") { + return rootProxy; + } + if (Reflect.has(target, prop)) { + return Reflect.get(target, prop, receiver); + } + return loadMonolithicSdk()[prop]; + }, + has(target, prop) { + if (prop === "__esModule" || prop === "default") { + return true; + } + if (Reflect.has(target, prop)) { + return true; + } + return prop in loadMonolithicSdk(); + }, + ownKeys(target) { + const keys = new Set([ + ...Reflect.ownKeys(target), + ...Reflect.ownKeys(loadMonolithicSdk()), + "default", + "__esModule", + ]); + return [...keys]; + }, + getOwnPropertyDescriptor(target, prop) { + if (prop === "__esModule") { + return { + configurable: true, + enumerable: false, + writable: false, + value: true, + }; + } + if (prop === "default") { + return { + configurable: true, + enumerable: false, + writable: false, + value: rootProxy, + }; + } + const own = Object.getOwnPropertyDescriptor(target, prop); + if (own) { + return own; + } + const descriptor = Object.getOwnPropertyDescriptor(loadMonolithicSdk(), prop); + if (!descriptor) { + return undefined; + } + if (descriptor.get || descriptor.set) { + const monolithic = loadMonolithicSdk(); + return { + configurable: true, + enumerable: descriptor.enumerable ?? true, + get: descriptor.get + ? function getLegacyValue() { + return descriptor.get.call(monolithic); + } + : undefined, + set: descriptor.set + ? function setLegacyValue(value) { + return descriptor.set.call(monolithic, value); + } + : undefined, + }; + } + return { + configurable: true, + enumerable: descriptor.enumerable ?? true, + value: descriptor.value, + writable: descriptor.writable, + }; + }, +}); + +module.exports = rootProxy; diff --git a/src/plugin-sdk/root-alias.test.ts b/src/plugin-sdk/root-alias.test.ts new file mode 100644 index 00000000000..dd2cc10b1bb --- /dev/null +++ b/src/plugin-sdk/root-alias.test.ts @@ -0,0 +1,44 @@ +import { createRequire } from "node:module"; +import { describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const rootSdk = require("./root-alias.cjs") as Record; + +type EmptySchema = { + safeParse: (value: unknown) => + | { success: true; data?: unknown } + | { + success: false; + error: { issues: Array<{ path: Array; message: string }> }; + }; +}; + +describe("plugin-sdk root alias", () => { + it("exposes the fast empty config schema helper", () => { + const factory = rootSdk.emptyPluginConfigSchema as (() => EmptySchema) | undefined; + expect(typeof factory).toBe("function"); + if (!factory) { + return; + } + const schema = factory(); + expect(schema.safeParse(undefined)).toEqual({ success: true, data: undefined }); + expect(schema.safeParse({})).toEqual({ success: true, data: {} }); + const parsed = schema.safeParse({ invalid: true }); + expect(parsed.success).toBe(false); + }); + + it("loads legacy root exports lazily through the proxy", () => { + expect(typeof rootSdk.resolveControlCommandGate).toBe("function"); + expect(typeof rootSdk.default).toBe("object"); + expect(rootSdk.default).toBe(rootSdk); + expect(rootSdk.__esModule).toBe(true); + }); + + it("preserves reflection semantics for lazily resolved exports", () => { + expect("resolveControlCommandGate" in rootSdk).toBe(true); + const keys = Object.keys(rootSdk); + expect(keys).toContain("resolveControlCommandGate"); + const descriptor = Object.getOwnPropertyDescriptor(rootSdk, "resolveControlCommandGate"); + expect(descriptor).toBeDefined(); + }); +}); diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 80a2d2ffaf1..9065712d235 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -1,3 +1,4 @@ +import * as compatSdk from "openclaw/plugin-sdk/compat"; import * as discordSdk from "openclaw/plugin-sdk/discord"; import * as imessageSdk from "openclaw/plugin-sdk/imessage"; import * as lineSdk from "openclaw/plugin-sdk/line"; @@ -7,6 +8,11 @@ import * as whatsappSdk from "openclaw/plugin-sdk/whatsapp"; import { describe, expect, it } from "vitest"; describe("plugin-sdk subpath exports", () => { + it("exports compat helpers", () => { + expect(typeof compatSdk.emptyPluginConfigSchema).toBe("function"); + expect(typeof compatSdk.resolveControlCommandGate).toBe("function"); + }); + it("exports Discord helpers", () => { expect(typeof discordSdk.resolveDiscordAccount).toBe("function"); expect(typeof discordSdk.discordOnboardingAdapter).toBe("object"); diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index aae6a429080..75dfc920c29 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -1,9 +1,17 @@ -export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; +export type { + ChannelAccountSnapshot, + ChannelGatewayContext, + ChannelMessageActionAdapter, +} from "../channels/plugins/types.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { OpenClawConfig } from "../config/config.js"; +export type { PluginRuntime } from "../plugins/runtime/types.js"; +export type { OpenClawPluginApi } from "../plugins/types.js"; export type { ResolvedTelegramAccount } from "../telegram/accounts.js"; export type { TelegramProbe } from "../telegram/probe.js"; +export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; + export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; export { diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 5a760161f41..aa33803c2ab 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { withEnvAsync } from "../test-utils/env.js"; -import { discoverOpenClawPlugins } from "./discovery.js"; +import { clearPluginDiscoveryCache, discoverOpenClawPlugins } from "./discovery.js"; const tempDirs: string[] = []; @@ -57,6 +57,7 @@ function expectEscapesPackageDiagnostic(diagnostics: Array<{ message: string }>) } afterEach(() => { + clearPluginDiscoveryCache(); for (const dir of tempDirs.splice(0)) { try { fs.rmSync(dir, { recursive: true, force: true }); @@ -350,4 +351,40 @@ describe("discoverOpenClawPlugins", () => { ); }, ); + + it("reuses discovery results from cache until cleared", async () => { + const stateDir = makeTempDir(); + const globalExt = path.join(stateDir, "extensions"); + fs.mkdirSync(globalExt, { recursive: true }); + const pluginPath = path.join(globalExt, "cached.ts"); + fs.writeFileSync(pluginPath, "export default function () {}", "utf-8"); + + const first = await withEnvAsync( + { + OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000", + }, + async () => withStateDir(stateDir, async () => discoverOpenClawPlugins({})), + ); + expect(first.candidates.some((candidate) => candidate.idHint === "cached")).toBe(true); + + fs.rmSync(pluginPath, { force: true }); + + const second = await withEnvAsync( + { + OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000", + }, + async () => withStateDir(stateDir, async () => discoverOpenClawPlugins({})), + ); + expect(second.candidates.some((candidate) => candidate.idHint === "cached")).toBe(true); + + clearPluginDiscoveryCache(); + + const third = await withEnvAsync( + { + OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000", + }, + async () => withStateDir(stateDir, async () => discoverOpenClawPlugins({})), + ); + expect(third.candidates.some((candidate) => candidate.idHint === "cached")).toBe(false); + }); }); diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 5d4fb48c6bf..c03b0fe01bf 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -33,6 +33,56 @@ export type PluginDiscoveryResult = { diagnostics: PluginDiagnostic[]; }; +const discoveryCache = new Map(); + +// Keep a short cache window to collapse bursty reloads during startup flows. +const DEFAULT_DISCOVERY_CACHE_MS = 1000; + +export function clearPluginDiscoveryCache(): void { + discoveryCache.clear(); +} + +function resolveDiscoveryCacheMs(env: NodeJS.ProcessEnv): number { + const raw = env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS?.trim(); + if (raw === "" || raw === "0") { + return 0; + } + if (!raw) { + return DEFAULT_DISCOVERY_CACHE_MS; + } + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed)) { + return DEFAULT_DISCOVERY_CACHE_MS; + } + return Math.max(0, parsed); +} + +function shouldUseDiscoveryCache(env: NodeJS.ProcessEnv): boolean { + const disabled = env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE?.trim(); + if (disabled) { + return false; + } + return resolveDiscoveryCacheMs(env) > 0; +} + +function buildDiscoveryCacheKey(params: { + workspaceDir?: string; + extraPaths?: string[]; + ownershipUid?: number | null; +}): string { + const workspaceKey = params.workspaceDir ? resolveUserPath(params.workspaceDir) : ""; + const configExtensionsRoot = path.join(resolveConfigDir(), "extensions"); + const bundledRoot = resolveBundledPluginsDir() ?? ""; + const normalizedExtraPaths = (params.extraPaths ?? []) + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => resolveUserPath(entry)) + .toSorted(); + const ownershipUid = params.ownershipUid ?? currentUid(); + return `${workspaceKey}::${ownershipUid ?? "none"}::${configExtensionsRoot}::${bundledRoot}::${JSON.stringify(normalizedExtraPaths)}`; +} + function currentUid(overrideUid?: number | null): number | null { if (overrideUid !== undefined) { return overrideUid; @@ -569,7 +619,23 @@ export function discoverOpenClawPlugins(params: { workspaceDir?: string; extraPaths?: string[]; ownershipUid?: number | null; + cache?: boolean; + env?: NodeJS.ProcessEnv; }): PluginDiscoveryResult { + const env = params.env ?? process.env; + const cacheEnabled = params.cache !== false && shouldUseDiscoveryCache(env); + const cacheKey = buildDiscoveryCacheKey({ + workspaceDir: params.workspaceDir, + extraPaths: params.extraPaths, + ownershipUid: params.ownershipUid, + }); + if (cacheEnabled) { + const cached = discoveryCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.result; + } + } + const candidates: PluginCandidate[] = []; const diagnostics: PluginDiagnostic[] = []; const seen = new Set(); @@ -634,5 +700,12 @@ export function discoverOpenClawPlugins(params: { seen, }); - return { candidates, diagnostics }; + const result = { candidates, diagnostics }; + if (cacheEnabled) { + const ttl = resolveDiscoveryCacheMs(env); + if (ttl > 0) { + discoveryCache.set(cacheKey, { expiresAt: Date.now() + ttl, result }); + } + } + return result; } diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 1f9a6ebd5a5..5e61d3e3270 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -211,14 +211,19 @@ function createEscapingEntryFixture(params: { id: string; sourceBody: string }) return { pluginDir, outsideEntry, linkedEntry }; } -function createPluginSdkAliasFixture() { +function createPluginSdkAliasFixture(params?: { + srcFile?: string; + distFile?: string; + srcBody?: string; + distBody?: string; +}) { const root = makeTempDir(); - const srcFile = path.join(root, "src", "plugin-sdk", "index.ts"); - const distFile = path.join(root, "dist", "plugin-sdk", "index.js"); + const srcFile = path.join(root, "src", "plugin-sdk", params?.srcFile ?? "index.ts"); + const distFile = path.join(root, "dist", "plugin-sdk", params?.distFile ?? "index.js"); fs.mkdirSync(path.dirname(srcFile), { recursive: true }); fs.mkdirSync(path.dirname(distFile), { recursive: true }); - fs.writeFileSync(srcFile, "export {};\n", "utf-8"); - fs.writeFileSync(distFile, "export {};\n", "utf-8"); + fs.writeFileSync(srcFile, params?.srcBody ?? "export {};\n", "utf-8"); + fs.writeFileSync(distFile, params?.distBody ?? "export {};\n", "utf-8"); return { root, srcFile, distFile }; } @@ -707,6 +712,73 @@ describe("loadOpenClawPlugins", () => { expect(a?.status).toBe("disabled"); }); + it("skips importing bundled memory plugins that are disabled by memory slot", () => { + const bundledDir = makeTempDir(); + const memoryADir = path.join(bundledDir, "memory-a"); + const memoryBDir = path.join(bundledDir, "memory-b"); + fs.mkdirSync(memoryADir, { recursive: true }); + fs.mkdirSync(memoryBDir, { recursive: true }); + writePlugin({ + id: "memory-a", + dir: memoryADir, + filename: "index.cjs", + body: `throw new Error("memory-a should not be imported when slot selects memory-b");`, + }); + writePlugin({ + id: "memory-b", + dir: memoryBDir, + filename: "index.cjs", + body: `module.exports = { id: "memory-b", kind: "memory", register() {} };`, + }); + fs.writeFileSync( + path.join(memoryADir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "memory-a", + kind: "memory", + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(memoryBDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "memory-b", + kind: "memory", + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf-8", + ); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + allow: ["memory-a", "memory-b"], + slots: { memory: "memory-b" }, + entries: { + "memory-a": { enabled: true }, + "memory-b": { enabled: true }, + }, + }, + }, + }); + + const a = registry.plugins.find((entry) => entry.id === "memory-a"); + const b = registry.plugins.find((entry) => entry.id === "memory-b"); + expect(a?.status).toBe("disabled"); + expect(String(a?.error ?? "")).toContain('memory slot set to "memory-b"'); + expect(b?.status).toBe("loaded"); + }); + it("disables memory plugins when slot is none", () => { process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; const memory = writePlugin({ @@ -1051,4 +1123,38 @@ describe("loadOpenClawPlugins", () => { ); expect(resolved).toBe(srcFile); }); + + it("prefers dist root-alias shim when loader runs from dist", () => { + const { root, distFile } = createPluginSdkAliasFixture({ + srcFile: "root-alias.cjs", + distFile: "root-alias.cjs", + srcBody: "module.exports = {};\n", + distBody: "module.exports = {};\n", + }); + + const resolved = __testing.resolvePluginSdkAliasFile({ + srcFile: "root-alias.cjs", + distFile: "root-alias.cjs", + modulePath: path.join(root, "dist", "plugins", "loader.js"), + }); + expect(resolved).toBe(distFile); + }); + + it("prefers src root-alias shim when loader runs from src in non-production", () => { + const { root, srcFile } = createPluginSdkAliasFixture({ + srcFile: "root-alias.cjs", + distFile: "root-alias.cjs", + srcBody: "module.exports = {};\n", + distBody: "module.exports = {};\n", + }); + + const resolved = withEnv({ NODE_ENV: undefined }, () => + __testing.resolvePluginSdkAliasFile({ + srcFile: "root-alias.cjs", + distFile: "root-alias.cjs", + modulePath: path.join(root, "src", "plugins", "loader.ts"), + }), + ); + expect(resolved).toBe(srcFile); + }); }); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 8df588d6b87..953592d1b59 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -86,7 +86,7 @@ const resolvePluginSdkAliasFile = (params: { }; const resolvePluginSdkAlias = (): string | null => - resolvePluginSdkAliasFile({ srcFile: "index.ts", distFile: "index.js" }); + resolvePluginSdkAliasFile({ srcFile: "root-alias.cjs", distFile: "root-alias.cjs" }); const resolvePluginSdkAccountIdAlias = (): string | null => { return resolvePluginSdkAliasFile({ srcFile: "account-id.ts", distFile: "account-id.js" }); @@ -96,6 +96,10 @@ const resolvePluginSdkCoreAlias = (): string | null => { return resolvePluginSdkAliasFile({ srcFile: "core.ts", distFile: "core.js" }); }; +const resolvePluginSdkCompatAlias = (): string | null => { + return resolvePluginSdkAliasFile({ srcFile: "compat.ts", distFile: "compat.js" }); +}; + const resolvePluginSdkTelegramAlias = (): string | null => { return resolvePluginSdkAliasFile({ srcFile: "telegram.ts", distFile: "telegram.js" }); }; @@ -468,6 +472,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi const discovery = discoverOpenClawPlugins({ workspaceDir: options.workspaceDir, extraPaths: normalized.loadPaths, + cache: options.cache, }); const manifestRegistry = loadPluginManifestRegistry({ config: cfg, @@ -501,6 +506,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi const pluginSdkAlias = resolvePluginSdkAlias(); const pluginSdkAccountIdAlias = resolvePluginSdkAccountIdAlias(); const pluginSdkCoreAlias = resolvePluginSdkCoreAlias(); + const pluginSdkCompatAlias = resolvePluginSdkCompatAlias(); const pluginSdkTelegramAlias = resolvePluginSdkTelegramAlias(); const pluginSdkDiscordAlias = resolvePluginSdkDiscordAlias(); const pluginSdkSlackAlias = resolvePluginSdkSlackAlias(); @@ -511,6 +517,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi const aliasMap = { ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), ...(pluginSdkCoreAlias ? { "openclaw/plugin-sdk/core": pluginSdkCoreAlias } : {}), + ...(pluginSdkCompatAlias ? { "openclaw/plugin-sdk/compat": pluginSdkCompatAlias } : {}), ...(pluginSdkTelegramAlias ? { "openclaw/plugin-sdk/telegram": pluginSdkTelegramAlias } : {}), ...(pluginSdkDiscordAlias ? { "openclaw/plugin-sdk/discord": pluginSdkDiscordAlias } : {}), ...(pluginSdkSlackAlias ? { "openclaw/plugin-sdk/slack": pluginSdkSlackAlias } : {}), @@ -610,6 +617,25 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi continue; } + // Fast-path bundled memory plugins that are guaranteed disabled by slot policy. + // This avoids opening/importing heavy memory plugin modules that will never register. + if (candidate.origin === "bundled" && manifestRecord.kind === "memory") { + const earlyMemoryDecision = resolveMemorySlotDecision({ + id: record.id, + kind: "memory", + slot: memorySlot, + selectedId: selectedMemoryPluginId, + }); + if (!earlyMemoryDecision.enabled) { + record.enabled = false; + record.status = "disabled"; + record.error = earlyMemoryDecision.reason; + registry.plugins.push(record); + seenIds.set(pluginId, candidate.origin); + continue; + } + } + if (!manifestRecord.configSchema) { pushPluginLoadError("missing config schema"); continue; diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 6176f9ee18f..d392144f925 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -46,7 +46,8 @@ export type PluginManifestRegistry = { const registryCache = new Map(); -const DEFAULT_MANIFEST_CACHE_MS = 200; +// Keep a short cache window to collapse bursty reloads during startup flows. +const DEFAULT_MANIFEST_CACHE_MS = 1000; export function clearPluginManifestRegistryCache(): void { registryCache.clear(); diff --git a/tsconfig.plugin-sdk.dts.json b/tsconfig.plugin-sdk.dts.json index 3e5be344b80..c3efae99617 100644 --- a/tsconfig.plugin-sdk.dts.json +++ b/tsconfig.plugin-sdk.dts.json @@ -13,6 +13,7 @@ "include": [ "src/plugin-sdk/index.ts", "src/plugin-sdk/core.ts", + "src/plugin-sdk/compat.ts", "src/plugin-sdk/telegram.ts", "src/plugin-sdk/discord.ts", "src/plugin-sdk/slack.ts", diff --git a/tsdown.config.ts b/tsdown.config.ts index ef5fd70dbb9..a69be542d08 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -62,6 +62,13 @@ export default defineConfig([ fixedExtension: false, platform: "node", }, + { + entry: "src/plugin-sdk/compat.ts", + outDir: "dist/plugin-sdk", + env, + fixedExtension: false, + platform: "node", + }, { entry: "src/plugin-sdk/telegram.ts", outDir: "dist/plugin-sdk", diff --git a/vitest.config.ts b/vitest.config.ts index 026b1a618f2..2094476eff1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -21,6 +21,10 @@ export default defineConfig({ find: "openclaw/plugin-sdk/core", replacement: path.join(repoRoot, "src", "plugin-sdk", "core.ts"), }, + { + find: "openclaw/plugin-sdk/compat", + replacement: path.join(repoRoot, "src", "plugin-sdk", "compat.ts"), + }, { find: "openclaw/plugin-sdk/telegram", replacement: path.join(repoRoot, "src", "plugin-sdk", "telegram.ts"),