diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 97a2cb507ca..0f11a277dfc 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -181,13 +181,20 @@ OpenClaw scans, in order: 4. Bundled extensions (shipped with OpenClaw; mixed default-on/default-off) -- `/extensions/*` +- `/dist/extensions/*` in packaged installs +- `/dist-runtime/extensions/*` in local built checkouts +- `/extensions/*` in source/Vitest workflows Many bundled provider plugins are enabled by default so model catalogs/runtime hooks stay available without extra setup. Others still require explicit enablement via `plugins.entries..enabled` or `openclaw plugins enable `. +Bundled plugin runtime dependencies are owned by each plugin package. Packaged +builds stage opted-in bundled dependencies under +`dist/extensions//node_modules` instead of requiring mirrored copies in the +root package. + Installed plugins are enabled by default, but can be disabled the same way. Workspace plugins are **disabled by default** unless you explicitly enable them diff --git a/extensions/discord/package.json b/extensions/discord/package.json index d2e42565a22..c53df4bfe15 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -3,6 +3,12 @@ "version": "2026.3.14", "description": "OpenClaw Discord channel plugin", "type": "module", + "dependencies": { + "@buape/carbon": "0.0.0-beta-20260216184201", + "@discordjs/voice": "^0.19.2", + "discord-api-types": "^0.38.42", + "opusscript": "^0.1.1" + }, "openclaw": { "extensions": [ "./index.ts" @@ -18,6 +24,14 @@ "blurb": "very well supported right now.", "systemImage": "bubble.left.and.bubble.right" }, + "install": { + "npmSpec": "@openclaw/discord", + "localPath": "extensions/discord", + "defaultChoice": "npm" + }, + "bundle": { + "stageRuntimeDependencies": true + }, "release": { "publishToNpm": true } diff --git a/extensions/discord/src/client.ts b/extensions/discord/src/client.ts index 2688add72cd..a9d730b455e 100644 --- a/extensions/discord/src/client.ts +++ b/extensions/discord/src/client.ts @@ -1,13 +1,14 @@ import { RequestClient } from "@buape/carbon"; import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; -import { createDiscordRetryRunner, type RetryRunner } from "openclaw/plugin-sdk/infra-runtime"; import type { RetryConfig } from "openclaw/plugin-sdk/infra-runtime"; +import type { RetryRunner } from "openclaw/plugin-sdk/infra-runtime"; import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { mergeDiscordAccountConfig, resolveDiscordAccount, type ResolvedDiscordAccount, } from "./accounts.js"; +import { createDiscordRetryRunner } from "./retry.js"; import { normalizeDiscordToken } from "./token.js"; export type DiscordClientOpts = { diff --git a/extensions/discord/src/monitor/agent-components.ts b/extensions/discord/src/monitor/agent-components.ts index 78fb38b3c91..dd9e5d049e2 100644 --- a/extensions/discord/src/monitor/agent-components.ts +++ b/extensions/discord/src/monitor/agent-components.ts @@ -33,7 +33,10 @@ import { } from "openclaw/plugin-sdk/conversation-runtime"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; -import { dispatchPluginInteractiveHandler } from "openclaw/plugin-sdk/plugin-runtime"; +import { + dispatchPluginInteractiveHandler, + type PluginInteractiveDiscordHandlerContext, +} from "openclaw/plugin-sdk/plugin-runtime"; import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; import { formatInboundEnvelope, @@ -117,7 +120,7 @@ async function dispatchPluginDiscordInteractiveEvent(params: { ? `channel:${params.interactionCtx.channelId}` : `user:${params.interactionCtx.userId}`; let responded = false; - const respond = { + const respond: PluginInteractiveDiscordHandlerContext["respond"] = { acknowledge: async () => { responded = true; await params.interaction.acknowledge(); @@ -136,20 +139,15 @@ async function dispatchPluginDiscordInteractiveEvent(params: { ephemeral, }); }, - editMessage: async ({ - text, - components, - }: { - text?: string; - components?: TopLevelComponents[]; - }) => { + editMessage: async (input) => { if (!("update" in params.interaction) || typeof params.interaction.update !== "function") { throw new Error("Discord interaction cannot update the source message"); } + const { text, components } = input; responded = true; await params.interaction.update({ ...(text !== undefined ? { content: text } : {}), - ...(components !== undefined ? { components } : {}), + ...(components !== undefined ? { components: components as TopLevelComponents[] } : {}), }); }, clearComponents: async (input?: { text?: string }) => { diff --git a/extensions/discord/src/monitor/reply-delivery.ts b/extensions/discord/src/monitor/reply-delivery.ts index a098c41d056..62895660006 100644 --- a/extensions/discord/src/monitor/reply-delivery.ts +++ b/extensions/discord/src/monitor/reply-delivery.ts @@ -2,11 +2,11 @@ import type { RequestClient } from "@buape/carbon"; import { resolveAgentAvatar } from "openclaw/plugin-sdk/agent-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { MarkdownTableMode, ReplyToMode } from "openclaw/plugin-sdk/config-runtime"; -import { createDiscordRetryRunner, type RetryRunner } from "openclaw/plugin-sdk/infra-runtime"; import { resolveRetryConfig, retryAsync, type RetryConfig, + type RetryRunner, } from "openclaw/plugin-sdk/infra-runtime"; import { resolveSendableOutboundReplyParts, @@ -19,6 +19,7 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime"; import { resolveDiscordAccount } from "../accounts.js"; import { chunkDiscordTextWithMode } from "../chunk.js"; +import { createDiscordRetryRunner } from "../retry.js"; import { sendMessageDiscord, sendVoiceMessageDiscord, sendWebhookMessageDiscord } from "../send.js"; import { sendDiscordText } from "../send.shared.js"; diff --git a/extensions/discord/src/retry.ts b/extensions/discord/src/retry.ts new file mode 100644 index 00000000000..c2f29c26109 --- /dev/null +++ b/extensions/discord/src/retry.ts @@ -0,0 +1,27 @@ +import { RateLimitError } from "@buape/carbon"; +import { + createRateLimitRetryRunner, + type RetryConfig, + type RetryRunner, +} from "openclaw/plugin-sdk/infra-runtime"; + +export const DISCORD_RETRY_DEFAULTS = { + attempts: 3, + minDelayMs: 500, + maxDelayMs: 30_000, + jitter: 0.1, +} satisfies RetryConfig; + +export function createDiscordRetryRunner(params: { + retry?: RetryConfig; + configRetry?: RetryConfig; + verbose?: boolean; +}): RetryRunner { + return createRateLimitRetryRunner({ + ...params, + defaults: DISCORD_RETRY_DEFAULTS, + logLabel: "discord", + shouldRetry: (err) => err instanceof RateLimitError, + retryAfterMs: (err) => (err instanceof RateLimitError ? err.retryAfter * 1000 : undefined), + }); +} diff --git a/extensions/discord/src/voice/manager.ts b/extensions/discord/src/voice/manager.ts index e7d3b099fe4..c7160a06929 100644 --- a/extensions/discord/src/voice/manager.ts +++ b/extensions/discord/src/voice/manager.ts @@ -5,17 +5,6 @@ import path from "node:path"; import type { Readable } from "node:stream"; import { ChannelType, type Client, ReadyListener } from "@buape/carbon"; import type { VoicePlugin } from "@buape/carbon/voice"; -import { - AudioPlayerStatus, - EndBehaviorType, - VoiceConnectionStatus, - createAudioPlayer, - createAudioResource, - entersState, - joinVoiceChannel, - type AudioPlayer, - type VoiceConnection, -} from "@discordjs/voice"; import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime"; import { agentCommandFromIngress } from "openclaw/plugin-sdk/agent-runtime"; import { resolveTtsConfig, type ResolvedTtsConfig } from "openclaw/plugin-sdk/agent-runtime"; @@ -34,6 +23,7 @@ import { textToSpeech } from "openclaw/plugin-sdk/speech-runtime"; import { formatMention } from "../mentions.js"; import { resolveDiscordOwnerAccess } from "../monitor/allow-list.js"; import { formatDiscordUserTag } from "../monitor/format.js"; +import { loadDiscordVoiceSdk } from "./sdk-runtime.js"; const require = createRequire(import.meta.url); @@ -67,8 +57,8 @@ type VoiceSessionEntry = { channelId: string; sessionChannelId: string; route: ReturnType; - connection: VoiceConnection; - player: AudioPlayer; + connection: import("@discordjs/voice").VoiceConnection; + player: import("@discordjs/voice").AudioPlayer; playbackQueue: Promise; processingQueue: Promise; activeSpeakers: Set; @@ -378,7 +368,8 @@ export class DiscordVoiceManager { decryptionFailureTolerance ?? "default" }`, ); - const connection = joinVoiceChannel({ + const voiceSdk = loadDiscordVoiceSdk(); + const connection = voiceSdk.joinVoiceChannel({ channelId, guildId, adapterCreator, @@ -389,7 +380,11 @@ export class DiscordVoiceManager { }); try { - await entersState(connection, VoiceConnectionStatus.Ready, PLAYBACK_READY_TIMEOUT_MS); + await voiceSdk.entersState( + connection, + voiceSdk.VoiceConnectionStatus.Ready, + PLAYBACK_READY_TIMEOUT_MS, + ); logVoiceVerbose(`join: connected to guild ${guildId} channel ${channelId}`); } catch (err) { connection.destroy(); @@ -412,7 +407,7 @@ export class DiscordVoiceManager { peer: { kind: "channel", id: sessionChannelId }, }); - const player = createAudioPlayer(); + const player = voiceSdk.createAudioPlayer(); connection.subscribe(player); let speakingHandler: ((userId: string) => void) | undefined; @@ -444,10 +439,10 @@ export class DiscordVoiceManager { connection.receiver.speaking.off("start", speakingHandler); } if (disconnectedHandler) { - connection.off(VoiceConnectionStatus.Disconnected, disconnectedHandler); + connection.off(voiceSdk.VoiceConnectionStatus.Disconnected, disconnectedHandler); } if (destroyedHandler) { - connection.off(VoiceConnectionStatus.Destroyed, destroyedHandler); + connection.off(voiceSdk.VoiceConnectionStatus.Destroyed, destroyedHandler); } if (playerErrorHandler) { player.off("error", playerErrorHandler); @@ -466,8 +461,8 @@ export class DiscordVoiceManager { disconnectedHandler = async () => { try { await Promise.race([ - entersState(connection, VoiceConnectionStatus.Signalling, 5_000), - entersState(connection, VoiceConnectionStatus.Connecting, 5_000), + voiceSdk.entersState(connection, voiceSdk.VoiceConnectionStatus.Signalling, 5_000), + voiceSdk.entersState(connection, voiceSdk.VoiceConnectionStatus.Connecting, 5_000), ]); } catch { clearSessionIfCurrent(); @@ -482,8 +477,8 @@ export class DiscordVoiceManager { }; connection.receiver.speaking.on("start", speakingHandler); - connection.on(VoiceConnectionStatus.Disconnected, disconnectedHandler); - connection.on(VoiceConnectionStatus.Destroyed, destroyedHandler); + connection.on(voiceSdk.VoiceConnectionStatus.Disconnected, disconnectedHandler); + connection.on(voiceSdk.VoiceConnectionStatus.Destroyed, destroyedHandler); player.on("error", playerErrorHandler); this.sessions.set(guildId, entry); @@ -547,13 +542,14 @@ export class DiscordVoiceManager { logVoiceVerbose( `capture start: guild ${entry.guildId} channel ${entry.channelId} user ${userId}`, ); - if (entry.player.state.status === AudioPlayerStatus.Playing) { + const voiceSdk = loadDiscordVoiceSdk(); + if (entry.player.state.status === voiceSdk.AudioPlayerStatus.Playing) { entry.player.stop(true); } const stream = entry.connection.receiver.subscribe(userId, { end: { - behavior: EndBehaviorType.AfterSilence, + behavior: voiceSdk.EndBehaviorType.AfterSilence, duration: SILENCE_DURATION_MS, }, }); @@ -681,14 +677,15 @@ export class DiscordVoiceManager { logVoiceVerbose( `playback start: guild ${entry.guildId} channel ${entry.channelId} file ${path.basename(audioPath)}`, ); - const resource = createAudioResource(audioPath); + const voiceSdk = loadDiscordVoiceSdk(); + const resource = voiceSdk.createAudioResource(audioPath); entry.player.play(resource); - await entersState(entry.player, AudioPlayerStatus.Playing, PLAYBACK_READY_TIMEOUT_MS).catch( - () => undefined, - ); - await entersState(entry.player, AudioPlayerStatus.Idle, SPEAKING_READY_TIMEOUT_MS).catch( - () => undefined, - ); + await voiceSdk + .entersState(entry.player, voiceSdk.AudioPlayerStatus.Playing, PLAYBACK_READY_TIMEOUT_MS) + .catch(() => undefined); + await voiceSdk + .entersState(entry.player, voiceSdk.AudioPlayerStatus.Idle, SPEAKING_READY_TIMEOUT_MS) + .catch(() => undefined); logVoiceVerbose(`playback done: guild ${entry.guildId} channel ${entry.channelId}`); }); } diff --git a/extensions/discord/src/voice/sdk-runtime.ts b/extensions/discord/src/voice/sdk-runtime.ts new file mode 100644 index 00000000000..35329432473 --- /dev/null +++ b/extensions/discord/src/voice/sdk-runtime.ts @@ -0,0 +1,14 @@ +import { createRequire } from "node:module"; + +type DiscordVoiceSdk = typeof import("@discordjs/voice"); + +let cachedDiscordVoiceSdk: DiscordVoiceSdk | null = null; + +export function loadDiscordVoiceSdk(): DiscordVoiceSdk { + if (cachedDiscordVoiceSdk) { + return cachedDiscordVoiceSdk; + } + const req = createRequire(import.meta.url); + cachedDiscordVoiceSdk = req("@discordjs/voice") as DiscordVoiceSdk; + return cachedDiscordVoiceSdk; +} diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index 1182828f60d..a610473f445 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -32,6 +32,9 @@ "localPath": "extensions/feishu", "defaultChoice": "npm" }, + "bundle": { + "stageRuntimeDependencies": true + }, "release": { "publishToNpm": true } diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index 0ade2d2e720..b38a23273f7 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -38,11 +38,6 @@ "npmSpec": "@openclaw/googlechat", "localPath": "extensions/googlechat", "defaultChoice": "npm" - }, - "releaseChecks": { - "rootDependencyMirrorAllowlist": [ - "google-auth-library" - ] } } } diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index ea7c5ec5141..34a2512bb35 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -33,13 +33,6 @@ }, "release": { "publishToNpm": true - }, - "releaseChecks": { - "rootDependencyMirrorAllowlist": [ - "@matrix-org/matrix-sdk-crypto-nodejs", - "@vector-im/matrix-bot-sdk", - "music-metadata" - ] } } } diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index c29afcfebbb..5a989be1cc2 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -32,11 +32,6 @@ }, "release": { "publishToNpm": true - }, - "releaseChecks": { - "rootDependencyMirrorAllowlist": [ - "@microsoft/agents-hosting" - ] } } } diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 24b50cf825d..2335eae85c7 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -29,11 +29,6 @@ }, "release": { "publishToNpm": true - }, - "releaseChecks": { - "rootDependencyMirrorAllowlist": [ - "nostr-tools" - ] } } } diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index 071280374a3..386e41c74a3 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -28,13 +28,6 @@ "npmSpec": "@openclaw/tlon", "localPath": "extensions/tlon", "defaultChoice": "npm" - }, - "releaseChecks": { - "rootDependencyMirrorAllowlist": [ - "@tloncorp/api", - "@tloncorp/tlon-skill", - "@urbit/aura" - ] } } } diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index 610744e7a8d..80c0b80b357 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -33,11 +33,6 @@ }, "release": { "publishToNpm": true - }, - "releaseChecks": { - "rootDependencyMirrorAllowlist": [ - "zca-js" - ] } } } diff --git a/package.json b/package.json index 7b503e34ab9..3879931c535 100644 --- a/package.json +++ b/package.json @@ -476,10 +476,11 @@ "protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/OpenClawProtocol/GatewayModels.swift apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift", "protocol:gen": "node --import tsx scripts/protocol-gen.ts", "protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts", - "release:check": "pnpm config:docs:check && node --import tsx scripts/release-check.ts", + "release:check": "pnpm config:docs:check && node scripts/stage-bundled-plugin-runtime-deps.mjs && node --import tsx scripts/release-check.ts", "release:openclaw:npm:check": "node --import tsx scripts/openclaw-npm-release-check.ts", "release:plugins:npm:check": "node --import tsx scripts/plugin-npm-release-check.ts", "release:plugins:npm:plan": "node --import tsx scripts/plugin-npm-release-plan.ts", + "stage:bundled-plugin-runtime-deps": "node scripts/stage-bundled-plugin-runtime-deps.mjs", "start": "node scripts/run-node.mjs", "test": "node scripts/test-parallel.mjs", "test:all": "pnpm lint && pnpm build && pnpm test && pnpm test:e2e && pnpm test:live && pnpm test:docker:all", @@ -534,14 +535,11 @@ "dependencies": { "@agentclientprotocol/sdk": "0.16.1", "@aws-sdk/client-bedrock": "^3.1011.0", - "@buape/carbon": "0.0.0-beta-20260216184201", "@clack/prompts": "^1.1.0", - "@discordjs/voice": "^0.19.2", "@grammyjs/runner": "^2.0.3", "@grammyjs/transformer-throttler": "^1.2.1", "@homebridge/ciao": "^1.3.5", "@lancedb/lancedb": "^0.27.0", - "@larksuiteoapi/node-sdk": "^1.59.0", "@line/bot-sdk": "^10.6.0", "@lydell/node-pty": "1.2.0-beta.3", "@mariozechner/pi-agent-core": "0.58.0", @@ -560,7 +558,6 @@ "cli-highlight": "^2.1.11", "commander": "^14.0.3", "croner": "^10.0.1", - "discord-api-types": "^0.38.42", "dotenv": "^17.3.1", "express": "^5.2.1", "file-type": "21.3.3", @@ -576,7 +573,6 @@ "long": "^5.3.2", "markdown-it": "^14.1.1", "node-edge-tts": "^1.2.10", - "opusscript": "^0.1.1", "osc-progress": "^0.3.0", "pdfjs-dist": "^5.5.207", "playwright-core": "1.58.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73e329eedb2..41119e0f998 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,15 +34,9 @@ importers: '@aws-sdk/client-bedrock': specifier: ^3.1011.0 version: 3.1011.0 - '@buape/carbon': - specifier: 0.0.0-beta-20260216184201 - version: 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.8)(opusscript@0.1.1) '@clack/prompts': specifier: ^1.1.0 version: 1.1.0 - '@discordjs/voice': - specifier: ^0.19.2 - version: 0.19.2(@discordjs/opus@0.10.0)(opusscript@0.1.1) '@grammyjs/runner': specifier: ^2.0.3 version: 2.0.3(grammy@1.41.1) @@ -55,9 +49,6 @@ importers: '@lancedb/lancedb': specifier: ^0.27.0 version: 0.27.0(apache-arrow@18.1.0) - '@larksuiteoapi/node-sdk': - specifier: ^1.59.0 - version: 1.59.0 '@line/bot-sdk': specifier: ^10.6.0 version: 10.6.0 @@ -115,9 +106,6 @@ importers: croner: specifier: ^10.0.1 version: 10.0.1 - discord-api-types: - specifier: ^0.38.42 - version: 0.38.42 dotenv: specifier: ^17.3.1 version: 17.3.1 @@ -166,9 +154,6 @@ importers: node-llama-cpp: specifier: 3.16.2 version: 3.16.2(typescript@5.9.3) - opusscript: - specifier: ^0.1.1 - version: 0.1.1 osc-progress: specifier: ^0.3.0 version: 0.3.0 @@ -347,7 +332,20 @@ importers: specifier: 1.58.2 version: 1.58.2 - extensions/discord: {} + extensions/discord: + dependencies: + '@buape/carbon': + specifier: 0.0.0-beta-20260216184201 + version: 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.8)(opusscript@0.1.1) + '@discordjs/voice': + specifier: ^0.19.2 + version: 0.19.2(@discordjs/opus@0.10.0)(opusscript@0.1.1) + discord-api-types: + specifier: ^0.38.42 + version: 0.38.42 + opusscript: + specifier: ^0.1.1 + version: 0.1.1 extensions/elevenlabs: {} @@ -381,7 +379,7 @@ importers: version: 10.6.2 openclaw: specifier: '>=2026.3.11' - version: 2026.3.13(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(node-llama-cpp@3.16.2(typescript@5.9.3)) + version: 2026.3.13(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(node-llama-cpp@3.16.2(typescript@5.9.3)) extensions/huggingface: {} @@ -448,7 +446,7 @@ importers: dependencies: openclaw: specifier: '>=2026.3.11' - version: 2026.3.13(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(node-llama-cpp@3.16.2(typescript@5.9.3)) + version: 2026.3.13(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(node-llama-cpp@3.16.2(typescript@5.9.3)) extensions/memory-lancedb: dependencies: @@ -1210,10 +1208,6 @@ packages: resolution: {integrity: sha512-UyX6rGEXzVyPzb1yvjHtPfTlnLvB5jX/stAMdiytHhfoydX+98hfympdOwsnTktzr+IRvphxTbdErgYDJkEsvw==} engines: {node: '>=22.12.0'} - '@discordjs/voice@0.19.1': - resolution: {integrity: sha512-XYbFVyUBB7zhRvrjREfiWDwio24nEp/vFaVe6u9aBIC5UYuT7HvoMt8LgNfZ5hOyaCW0flFr72pkhUGz+gWw4Q==} - engines: {node: '>=22.12.0'} - '@discordjs/voice@0.19.2': resolution: {integrity: sha512-3yJ255e4ag3wfZu/DSxeOZK1UtnqNxnspmLaQetGT0pDkThNZoHs+Zg6dgZZ19JEVomXygvfHn9lNpICZuYtEA==} engines: {node: '>=22.12.0'} @@ -8386,22 +8380,6 @@ snapshots: - utf-8-validate optional: true - '@discordjs/voice@0.19.1(@discordjs/opus@0.10.0)(opusscript@0.1.1)': - dependencies: - '@snazzah/davey': 0.1.10 - '@types/ws': 8.18.1 - discord-api-types: 0.38.42 - prism-media: 1.3.5(@discordjs/opus@0.10.0)(opusscript@0.1.1) - tslib: 2.8.1 - ws: 8.19.0 - transitivePeerDependencies: - - '@discordjs/opus' - - bufferutil - - ffmpeg-static - - node-opus - - opusscript - - utf-8-validate - '@discordjs/voice@0.19.2(@discordjs/opus@0.10.0)(opusscript@0.1.1)': dependencies: '@snazzah/davey': 0.1.10 @@ -13445,13 +13423,13 @@ snapshots: ws: 8.19.0 zod: 4.3.6 - openclaw@2026.3.13(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(node-llama-cpp@3.16.2(typescript@5.9.3)): + openclaw@2026.3.13(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(node-llama-cpp@3.16.2(typescript@5.9.3)): dependencies: '@agentclientprotocol/sdk': 0.16.1(zod@4.3.6) '@aws-sdk/client-bedrock': 3.1009.0 '@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.8)(opusscript@0.1.1) '@clack/prompts': 1.1.0 - '@discordjs/voice': 0.19.1(@discordjs/opus@0.10.0)(opusscript@0.1.1) + '@discordjs/voice': 0.19.2(@discordjs/opus@0.10.0)(opusscript@0.1.1) '@grammyjs/runner': 2.0.3(grammy@1.41.1) '@grammyjs/transformer-throttler': 1.2.1(grammy@1.41.1) '@homebridge/ciao': 1.3.5 diff --git a/scripts/audit-plugin-sdk-seams.mjs b/scripts/audit-plugin-sdk-seams.mjs index 67e27c036f4..4d34a3dd939 100644 --- a/scripts/audit-plugin-sdk-seams.mjs +++ b/scripts/audit-plugin-sdk-seams.mjs @@ -403,9 +403,6 @@ async function buildMissingPackages() { continue; } const meta = packageClusterMeta(relativePackagePath); - const rootDependencyMirrorAllowlist = ( - pkg.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist ?? [] - ).toSorted(compareStrings); const pluginSdkEntries = [...(pluginSdkReachability.get(meta.cluster) ?? new Set())].toSorted( compareStrings, ); @@ -421,9 +418,6 @@ async function buildMissingPackages() { packagePath: relativePackagePath, npmSpec: pkg.openclaw?.install?.npmSpec ?? null, private: pkg.private === true, - rootDependencyMirrorAllowlist, - mirrorAllowlistMatchesMissing: - missing.join("\n") === rootDependencyMirrorAllowlist.join("\n"), pluginSdkReachability: pluginSdkEntries.length > 0 ? { staticEntryPoints: pluginSdkEntries } : undefined, missing, diff --git a/scripts/lib/bundled-extension-manifest.ts b/scripts/lib/bundled-extension-manifest.ts index 07053e943eb..b82ce3ff10c 100644 --- a/scripts/lib/bundled-extension-manifest.ts +++ b/scripts/lib/bundled-extension-manifest.ts @@ -7,33 +7,10 @@ export type ExtensionPackageJson = { install?: { npmSpec?: string; }; - releaseChecks?: { - rootDependencyMirrorAllowlist?: string[]; - }; }; }; export type BundledExtension = { id: string; packageJson: ExtensionPackageJson }; -export type BundledExtensionMetadata = BundledExtension & { - npmSpec?: string; - rootDependencyMirrorAllowlist: string[]; -}; - -export function normalizeBundledExtensionMetadata( - extensions: BundledExtension[], -): BundledExtensionMetadata[] { - return extensions.map((extension) => ({ - ...extension, - npmSpec: - typeof extension.packageJson.openclaw?.install?.npmSpec === "string" - ? extension.packageJson.openclaw.install.npmSpec.trim() - : undefined, - rootDependencyMirrorAllowlist: - extension.packageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist?.filter( - (entry): entry is string => typeof entry === "string" && entry.trim().length > 0, - ) ?? [], - })); -} export function collectBundledExtensionManifestErrors(extensions: BundledExtension[]): string[] { const errors: string[] = []; @@ -48,23 +25,6 @@ export function collectBundledExtensionManifestErrors(extensions: BundledExtensi `bundled extension '${extension.id}' manifest invalid | openclaw.install.npmSpec must be a non-empty string`, ); } - - const allowlist = extension.packageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist; - if (allowlist === undefined) { - continue; - } - if (!Array.isArray(allowlist)) { - errors.push( - `bundled extension '${extension.id}' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist must be an array of non-empty strings`, - ); - continue; - } - const invalidEntries = allowlist.filter((entry) => typeof entry !== "string" || !entry.trim()); - if (invalidEntries.length > 0) { - errors.push( - `bundled extension '${extension.id}' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist must contain only non-empty strings`, - ); - } } return errors; diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 8f971fef119..72d729cc1cd 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -6,7 +6,6 @@ import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { collectBundledExtensionManifestErrors, - normalizeBundledExtensionMetadata, type BundledExtension, type ExtensionPackageJson as PackageJson, } from "./lib/bundled-extension-manifest.ts"; @@ -34,45 +33,6 @@ const appcastPath = resolve("appcast.xml"); const laneBuildMin = 1_000_000_000; const laneFloorAdoptionDateKey = 20260227; -export function collectBundledExtensionRootDependencyGapErrors(params: { - rootPackage: PackageJson; - extensions: BundledExtension[]; -}): string[] { - const rootDeps = { - ...params.rootPackage.dependencies, - ...params.rootPackage.optionalDependencies, - }; - const errors: string[] = []; - - for (const extension of normalizeBundledExtensionMetadata(params.extensions)) { - if (!extension.npmSpec) { - continue; - } - - const missing = Object.keys(extension.packageJson.dependencies ?? {}) - .filter((dep) => dep !== "openclaw" && !rootDeps[dep]) - .toSorted(); - const allowlisted = extension.rootDependencyMirrorAllowlist.toSorted(); - if (missing.join("\n") !== allowlisted.join("\n")) { - const unexpected = missing.filter((dep) => !allowlisted.includes(dep)); - const resolved = allowlisted.filter((dep) => !missing.includes(dep)); - const parts = [ - `bundled extension '${extension.id}' root dependency mirror drift`, - `missing in root package: ${missing.length > 0 ? missing.join(", ") : "(none)"}`, - ]; - if (unexpected.length > 0) { - parts.push(`new gaps: ${unexpected.join(", ")}`); - } - if (resolved.length > 0) { - parts.push(`remove stale allowlist entries: ${resolved.join(", ")}`); - } - errors.push(parts.join(" | ")); - } - } - - return errors; -} - function collectBundledExtensions(): BundledExtension[] { const extensionsDir = resolve("extensions"); const entries = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) => @@ -94,8 +54,7 @@ function collectBundledExtensions(): BundledExtension[] { }); } -function checkBundledExtensionRootDependencyMirrors() { - const rootPackage = JSON.parse(readFileSync(resolve("package.json"), "utf8")) as PackageJson; +function checkBundledExtensionMetadata() { const extensions = collectBundledExtensions(); const manifestErrors = collectBundledExtensionManifestErrors(extensions); if (manifestErrors.length > 0) { @@ -105,17 +64,6 @@ function checkBundledExtensionRootDependencyMirrors() { } process.exit(1); } - const errors = collectBundledExtensionRootDependencyGapErrors({ - rootPackage, - extensions, - }); - if (errors.length > 0) { - console.error("release-check: bundled extension root dependency mirror validation failed:"); - for (const error of errors) { - console.error(` - ${error}`); - } - process.exit(1); - } } function runPackDry(): PackResult[] { @@ -128,11 +76,13 @@ function runPackDry(): PackResult[] { } export function collectForbiddenPackPaths(paths: Iterable): string[] { + const isAllowedBundledPluginNodeModulesPath = (path: string) => + /^dist\/extensions\/[^/]+\/node_modules\//.test(path); return [...paths] .filter( (path) => forbiddenPrefixes.some((prefix) => path.startsWith(prefix)) || - /(^|\/)node_modules\//.test(path), + (/node_modules\//.test(path) && !isAllowedBundledPluginNodeModulesPath(path)), ) .toSorted(); } @@ -338,7 +288,7 @@ async function checkPluginSdkExports() { async function main() { checkAppcastSparkleVersions(); await checkPluginSdkExports(); - checkBundledExtensionRootDependencyMirrors(); + checkBundledExtensionMetadata(); const results = runPackDry(); const files = results.flatMap((entry) => entry.files ?? []); diff --git a/scripts/runtime-postbuild.mjs b/scripts/runtime-postbuild.mjs index 32dc6a31171..6b044252267 100644 --- a/scripts/runtime-postbuild.mjs +++ b/scripts/runtime-postbuild.mjs @@ -1,11 +1,13 @@ import { pathToFileURL } from "node:url"; import { copyBundledPluginMetadata } from "./copy-bundled-plugin-metadata.mjs"; import { copyPluginSdkRootAlias } from "./copy-plugin-sdk-root-alias.mjs"; +import { stageBundledPluginRuntimeDeps } from "./stage-bundled-plugin-runtime-deps.mjs"; import { stageBundledPluginRuntime } from "./stage-bundled-plugin-runtime.mjs"; export function runRuntimePostBuild(params = {}) { copyPluginSdkRootAlias(params); copyBundledPluginMetadata(params); + stageBundledPluginRuntimeDeps(params); stageBundledPluginRuntime(params); } diff --git a/scripts/stage-bundled-plugin-runtime-deps.mjs b/scripts/stage-bundled-plugin-runtime-deps.mjs new file mode 100644 index 00000000000..b4a516d104d --- /dev/null +++ b/scripts/stage-bundled-plugin-runtime-deps.mjs @@ -0,0 +1,74 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function removePathIfExists(targetPath) { + fs.rmSync(targetPath, { recursive: true, force: true }); +} + +function listBundledPluginRuntimeDirs(repoRoot) { + const extensionsRoot = path.join(repoRoot, "dist", "extensions"); + if (!fs.existsSync(extensionsRoot)) { + return []; + } + + return fs + .readdirSync(extensionsRoot, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => path.join(extensionsRoot, dirent.name)) + .filter((pluginDir) => fs.existsSync(path.join(pluginDir, "package.json"))); +} + +function hasRuntimeDeps(packageJson) { + return ( + Object.keys(packageJson.dependencies ?? {}).length > 0 || + Object.keys(packageJson.optionalDependencies ?? {}).length > 0 + ); +} + +function shouldStageRuntimeDeps(packageJson) { + return packageJson.openclaw?.bundle?.stageRuntimeDependencies === true; +} + +function installPluginRuntimeDeps(pluginDir, pluginId) { + const result = spawnSync( + "npm", + ["install", "--omit=dev", "--silent", "--ignore-scripts", "--package-lock=false"], + { + cwd: pluginDir, + encoding: "utf8", + stdio: "pipe", + shell: process.platform === "win32", + }, + ); + if (result.status === 0) { + return; + } + const output = [result.stderr, result.stdout].filter(Boolean).join("\n").trim(); + throw new Error( + `failed to stage bundled runtime deps for ${pluginId}: ${output || "npm install failed"}`, + ); +} + +export function stageBundledPluginRuntimeDeps(params = {}) { + const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd(); + for (const pluginDir of listBundledPluginRuntimeDirs(repoRoot)) { + const pluginId = path.basename(pluginDir); + const packageJson = readJson(path.join(pluginDir, "package.json")); + const nodeModulesDir = path.join(pluginDir, "node_modules"); + removePathIfExists(nodeModulesDir); + if (!hasRuntimeDeps(packageJson) || !shouldStageRuntimeDeps(packageJson)) { + continue; + } + installPluginRuntimeDeps(pluginDir, pluginId); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + stageBundledPluginRuntimeDeps(); +} diff --git a/scripts/stage-bundled-plugin-runtime.mjs b/scripts/stage-bundled-plugin-runtime.mjs index 077d8f77f44..f38f52aa6c5 100644 --- a/scripts/stage-bundled-plugin-runtime.mjs +++ b/scripts/stage-bundled-plugin-runtime.mjs @@ -98,7 +98,6 @@ export function stageBundledPluginRuntime(params = {}) { const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd(); const distRoot = path.join(repoRoot, "dist"); const runtimeRoot = path.join(repoRoot, "dist-runtime"); - const sourceExtensionsRoot = path.join(repoRoot, "extensions"); const distExtensionsRoot = path.join(distRoot, "extensions"); const runtimeExtensionsRoot = path.join(runtimeRoot, "extensions"); @@ -116,13 +115,12 @@ export function stageBundledPluginRuntime(params = {}) { } const distPluginDir = path.join(distExtensionsRoot, dirent.name); const runtimePluginDir = path.join(runtimeExtensionsRoot, dirent.name); - const sourcePluginNodeModulesDir = path.join(sourceExtensionsRoot, dirent.name, "node_modules"); + const distPluginNodeModulesDir = path.join(distPluginDir, "node_modules"); stagePluginRuntimeOverlay(distPluginDir, runtimePluginDir); linkPluginNodeModules({ runtimePluginDir, - distPluginDir, - sourcePluginNodeModulesDir, + sourcePluginNodeModulesDir: distPluginNodeModulesDir, }); } } diff --git a/scripts/tsdown-build.mjs b/scripts/tsdown-build.mjs index 79f24ea65b8..4d31d06a693 100644 --- a/scripts/tsdown-build.mjs +++ b/scripts/tsdown-build.mjs @@ -33,9 +33,9 @@ function removeDistPluginNodeModulesSymlinks(rootDir) { function pruneStaleRuntimeSymlinks() { const cwd = process.cwd(); - // runtime-postbuild links dist/dist-runtime plugin node_modules back into the - // source extensions. Remove only those symlinks up front so tsdown's clean - // step cannot traverse into the active pnpm install tree on rebuilds. + // runtime-postbuild stages plugin-owned node_modules into dist/ and links the + // dist-runtime overlay back to that tree. Remove only those symlinks up front + // so tsdown's clean step cannot traverse stale runtime overlays on rebuilds. removeDistPluginNodeModulesSymlinks(path.join(cwd, "dist")); removeDistPluginNodeModulesSymlinks(path.join(cwd, "dist-runtime")); } diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index ed6191ce1c4..7363f244270 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -1,4 +1,3 @@ -import type { TopLevelComponents } from "@buape/carbon"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import type { TSchema } from "@sinclair/typebox"; import type { MsgContext } from "../../auto-reply/templating.js"; @@ -276,12 +275,16 @@ export type ChannelStreamingAdapter = { }; }; +// Keep core transport-agnostic. Plugins can carry richer component types on +// their side and cast at the boundary. +export type ChannelStructuredComponents = unknown[]; + export type ChannelCrossContextComponentsFactory = (params: { originLabel: string; message: string; cfg: OpenClawConfig; accountId?: string | null; -}) => TopLevelComponents[]; +}) => ChannelStructuredComponents; export type ChannelReplyTransport = { replyToId?: string | null; diff --git a/src/channels/plugins/types.ts b/src/channels/plugins/types.ts index d17fd1c67bd..8aa331d6ae8 100644 --- a/src/channels/plugins/types.ts +++ b/src/channels/plugins/types.ts @@ -70,6 +70,7 @@ export type { ChannelSetupInput, ChannelStatusIssue, ChannelStreamingAdapter, + ChannelStructuredComponents, ChannelThreadingAdapter, ChannelThreadingContext, ChannelThreadingToolContext, diff --git a/src/infra/outbound/channel-adapters.ts b/src/infra/outbound/channel-adapters.ts index 0c752854e8d..e384fda1ad2 100644 --- a/src/infra/outbound/channel-adapters.ts +++ b/src/infra/outbound/channel-adapters.ts @@ -1,16 +1,15 @@ -import type { TopLevelComponents } from "@buape/carbon"; import { getChannelPlugin } from "../../channels/plugins/index.js"; -import type { ChannelId } from "../../channels/plugins/types.js"; +import type { ChannelId, ChannelStructuredComponents } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; -export type CrossContextComponentsBuilder = (message: string) => TopLevelComponents[]; +export type CrossContextComponentsBuilder = (message: string) => ChannelStructuredComponents; export type CrossContextComponentsFactory = (params: { originLabel: string; message: string; cfg: OpenClawConfig; accountId?: string | null; -}) => TopLevelComponents[]; +}) => ChannelStructuredComponents; export type ChannelMessageAdapter = { supportsComponentsV2: boolean; diff --git a/src/infra/retry-policy.ts b/src/infra/retry-policy.ts index 725357b440e..e28142b117f 100644 --- a/src/infra/retry-policy.ts +++ b/src/infra/retry-policy.ts @@ -1,17 +1,9 @@ -import { RateLimitError } from "@buape/carbon"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { formatErrorMessage } from "./errors.js"; import { type RetryConfig, resolveRetryConfig, retryAsync } from "./retry.js"; export type RetryRunner = (fn: () => Promise, label?: string) => Promise; -export const DISCORD_RETRY_DEFAULTS = { - attempts: 3, - minDelayMs: 500, - maxDelayMs: 30_000, - jitter: 0.1, -}; - export const TELEGRAM_RETRY_DEFAULTS = { attempts: 3, minDelayMs: 400, @@ -58,12 +50,16 @@ function getTelegramRetryAfterMs(err: unknown): number | undefined { return typeof candidate === "number" && Number.isFinite(candidate) ? candidate * 1000 : undefined; } -export function createDiscordRetryRunner(params: { +export function createRateLimitRetryRunner(params: { retry?: RetryConfig; configRetry?: RetryConfig; verbose?: boolean; + defaults: Required; + logLabel: string; + shouldRetry: (err: unknown) => boolean; + retryAfterMs?: (err: unknown) => number | undefined; }): RetryRunner { - const retryConfig = resolveRetryConfig(DISCORD_RETRY_DEFAULTS, { + const retryConfig = resolveRetryConfig(params.defaults, { ...params.configRetry, ...params.retry, }); @@ -71,14 +67,14 @@ export function createDiscordRetryRunner(params: { retryAsync(fn, { ...retryConfig, label, - shouldRetry: (err) => err instanceof RateLimitError, - retryAfterMs: (err) => (err instanceof RateLimitError ? err.retryAfter * 1000 : undefined), + shouldRetry: params.shouldRetry, + retryAfterMs: params.retryAfterMs, onRetry: params.verbose ? (info) => { const labelText = info.label ?? "request"; const maxRetries = Math.max(1, info.maxAttempts - 1); log.warn( - `discord ${labelText} rate limited, retry ${info.attempt}/${maxRetries} in ${info.delayMs}ms`, + `${params.logLabel} ${labelText} rate limited, retry ${info.attempt}/${maxRetries} in ${info.delayMs}ms`, ); } : undefined, diff --git a/src/plugin-sdk/media-runtime.ts b/src/plugin-sdk/media-runtime.ts index 2f2d81b0d46..f824246ed51 100644 --- a/src/plugin-sdk/media-runtime.ts +++ b/src/plugin-sdk/media-runtime.ts @@ -14,6 +14,7 @@ export * from "../media/outbound-attachment.js"; export * from "../media/png-encode.ts"; export * from "../media/store.js"; export * from "../media/temp-files.js"; +export * from "./agent-media-payload.js"; export * from "../media-understanding/audio-preflight.ts"; export * from "../media-understanding/defaults.js"; export * from "../media-understanding/providers/image-runtime.ts"; diff --git a/src/plugins/bundled-dir.test.ts b/src/plugins/bundled-dir.test.ts index 9ff474a4ada..15c754d681e 100644 --- a/src/plugins/bundled-dir.test.ts +++ b/src/plugins/bundled-dir.test.ts @@ -50,6 +50,22 @@ describe("resolveBundledPluginsDir", () => { ); }); + it("falls back to built dist/extensions in installed package roots", () => { + const repoRoot = makeRepoRoot("openclaw-bundled-dir-dist-"); + fs.mkdirSync(path.join(repoRoot, "dist", "extensions"), { recursive: true }); + fs.writeFileSync( + path.join(repoRoot, "package.json"), + `${JSON.stringify({ name: "openclaw" }, null, 2)}\n`, + "utf8", + ); + + process.chdir(repoRoot); + + expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe( + fs.realpathSync(path.join(repoRoot, "dist", "extensions")), + ); + }); + it("prefers source extensions under vitest to avoid stale staged plugins", () => { const repoRoot = makeRepoRoot("openclaw-bundled-dir-vitest-"); fs.mkdirSync(path.join(repoRoot, "extensions"), { recursive: true }); diff --git a/src/plugins/bundled-dir.ts b/src/plugins/bundled-dir.ts index 419e708ed08..930ab6c9da4 100644 --- a/src/plugins/bundled-dir.ts +++ b/src/plugins/bundled-dir.ts @@ -29,6 +29,7 @@ export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env): ); for (const packageRoot of packageRoots) { const sourceExtensionsDir = path.join(packageRoot, "extensions"); + const builtExtensionsDir = path.join(packageRoot, "dist", "extensions"); if ( (preferSourceCheckout || isSourceCheckoutRoot(packageRoot)) && fs.existsSync(sourceExtensionsDir) @@ -39,10 +40,12 @@ export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env): // dist-runtime/. Prefer that over source extensions only when the paired // dist/ tree exists; otherwise wrappers can drift ahead of the last build. const runtimeExtensionsDir = path.join(packageRoot, "dist-runtime", "extensions"); - const builtExtensionsDir = path.join(packageRoot, "dist", "extensions"); if (fs.existsSync(runtimeExtensionsDir) && fs.existsSync(builtExtensionsDir)) { return runtimeExtensionsDir; } + if (fs.existsSync(builtExtensionsDir)) { + return builtExtensionsDir; + } } } catch { // ignore @@ -51,6 +54,10 @@ export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env): // bun --compile: ship a sibling `extensions/` next to the executable. try { const execDir = path.dirname(process.execPath); + const siblingBuilt = path.join(execDir, "dist", "extensions"); + if (fs.existsSync(siblingBuilt)) { + return siblingBuilt; + } const sibling = path.join(execDir, "extensions"); if (fs.existsSync(sibling)) { return sibling; diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index c0091a017f5..3ba17d5aaba 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -12,25 +12,33 @@ function readJson(relativePath: string): T { } describe("bundled plugin runtime dependencies", () => { - it("keeps bundled Feishu runtime deps available from the published root package", () => { + it("keeps bundled Feishu runtime deps plugin-local instead of mirroring them into the root package", () => { const rootManifest = readJson("package.json"); const feishuManifest = readJson("extensions/feishu/package.json"); const feishuSpec = feishuManifest.dependencies?.["@larksuiteoapi/node-sdk"]; const rootSpec = rootManifest.dependencies?.["@larksuiteoapi/node-sdk"]; expect(feishuSpec).toBeTruthy(); - expect(rootSpec).toBeTruthy(); - expect(rootSpec).toBe(feishuSpec); + expect(rootSpec).toBeUndefined(); }); - it("keeps bundled memory-lancedb runtime deps available from the published root package", () => { + it("keeps bundled memory-lancedb runtime deps available from the root package while its native runtime stays bundled", () => { const rootManifest = readJson("package.json"); const memoryManifest = readJson("extensions/memory-lancedb/package.json"); const memorySpec = memoryManifest.dependencies?.["@lancedb/lancedb"]; const rootSpec = rootManifest.dependencies?.["@lancedb/lancedb"]; expect(memorySpec).toBeTruthy(); - expect(rootSpec).toBeTruthy(); expect(rootSpec).toBe(memorySpec); }); + + it("keeps bundled Discord runtime deps plugin-local instead of mirroring them into the root package", () => { + const rootManifest = readJson("package.json"); + const discordManifest = readJson("extensions/discord/package.json"); + const discordSpec = discordManifest.dependencies?.["@buape/carbon"]; + const rootSpec = rootManifest.dependencies?.["@buape/carbon"]; + + expect(discordSpec).toBeTruthy(); + expect(rootSpec).toBeUndefined(); + }); }); diff --git a/src/plugins/stage-bundled-plugin-runtime.test.ts b/src/plugins/stage-bundled-plugin-runtime.test.ts index 3ef875a88a6..7bdb986e030 100644 --- a/src/plugins/stage-bundled-plugin-runtime.test.ts +++ b/src/plugins/stage-bundled-plugin-runtime.test.ts @@ -22,18 +22,17 @@ afterEach(() => { }); describe("stageBundledPluginRuntime", () => { - it("stages bundled dist plugins as runtime wrappers and links plugin-local node_modules", () => { + it("stages bundled dist plugins as runtime wrappers and links staged dist node_modules", () => { const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-"); const distPluginDir = path.join(repoRoot, "dist", "extensions", "diffs"); fs.mkdirSync(path.join(repoRoot, "dist"), { recursive: true }); - const sourcePluginNodeModulesDir = path.join(repoRoot, "extensions", "diffs", "node_modules"); fs.mkdirSync(distPluginDir, { recursive: true }); - fs.mkdirSync(path.join(sourcePluginNodeModulesDir, "@pierre", "diffs"), { + fs.mkdirSync(path.join(distPluginDir, "node_modules", "@pierre", "diffs"), { recursive: true, }); fs.writeFileSync(path.join(distPluginDir, "index.js"), "export default {}\n", "utf8"); fs.writeFileSync( - path.join(sourcePluginNodeModulesDir, "@pierre", "diffs", "index.js"), + path.join(distPluginDir, "node_modules", "@pierre", "diffs", "index.js"), "export default {}\n", "utf8", ); @@ -47,9 +46,9 @@ describe("stageBundledPluginRuntime", () => { ); expect(fs.lstatSync(path.join(runtimePluginDir, "node_modules")).isSymbolicLink()).toBe(true); expect(fs.realpathSync(path.join(runtimePluginDir, "node_modules"))).toBe( - fs.realpathSync(sourcePluginNodeModulesDir), + fs.realpathSync(path.join(distPluginDir, "node_modules")), ); - expect(fs.existsSync(path.join(distPluginDir, "node_modules"))).toBe(false); + expect(fs.existsSync(path.join(distPluginDir, "node_modules"))).toBe(true); }); it("writes wrappers that forward plugin entry imports into canonical dist files", async () => { diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 0fa61a466c8..343a338c4f8 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -1,5 +1,4 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import type { TopLevelComponents } from "@buape/carbon"; import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { StreamFn } from "@mariozechner/pi-agent-core"; import type { Api, Model } from "@mariozechner/pi-ai"; @@ -16,7 +15,11 @@ import type { ProviderCapabilities } from "../agents/provider-capabilities.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; import type { ThinkLevel } from "../auto-reply/thinking.js"; import type { ReplyPayload } from "../auto-reply/types.js"; -import type { ChannelId, ChannelPlugin } from "../channels/plugins/types.js"; +import type { + ChannelId, + ChannelPlugin, + ChannelStructuredComponents, +} from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; import type { ModelProviderConfig } from "../config/types.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; @@ -1132,7 +1135,10 @@ export type PluginInteractiveDiscordHandlerContext = { acknowledge: () => Promise; reply: (params: { text: string; ephemeral?: boolean }) => Promise; followUp: (params: { text: string; ephemeral?: boolean }) => Promise; - editMessage: (params: { text?: string; components?: TopLevelComponents[] }) => Promise; + editMessage: (params: { + text?: string; + components?: ChannelStructuredComponents; + }) => Promise; clearComponents: (params?: { text?: string }) => Promise; }; requestConversationBinding: ( diff --git a/test/release-check.test.ts b/test/release-check.test.ts index 5f0bcf65192..fb518d6afe7 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { collectAppcastSparkleVersionErrors, collectBundledExtensionManifestErrors, - collectBundledExtensionRootDependencyGapErrors, collectForbiddenPackPaths, collectPackUnpackedSizeErrors, } from "../scripts/release-check.ts"; @@ -37,87 +36,6 @@ describe("collectAppcastSparkleVersionErrors", () => { }); }); -describe("collectBundledExtensionRootDependencyGapErrors", () => { - it("allows known gaps but still flags unallowlisted ones", () => { - expect( - collectBundledExtensionRootDependencyGapErrors({ - rootPackage: { dependencies: {} }, - extensions: [ - { - id: "googlechat", - packageJson: { - dependencies: { "google-auth-library": "^1.0.0" }, - openclaw: { - install: { npmSpec: "@openclaw/googlechat" }, - releaseChecks: { - rootDependencyMirrorAllowlist: ["google-auth-library"], - }, - }, - }, - }, - { - id: "feishu", - packageJson: { - dependencies: { "@larksuiteoapi/node-sdk": "^1.59.0" }, - openclaw: { install: { npmSpec: "@openclaw/feishu" } }, - }, - }, - ], - }), - ).toEqual([ - "bundled extension 'feishu' root dependency mirror drift | missing in root package: @larksuiteoapi/node-sdk | new gaps: @larksuiteoapi/node-sdk", - ]); - }); - - it("flags newly introduced bundled extension dependency gaps", () => { - expect( - collectBundledExtensionRootDependencyGapErrors({ - rootPackage: { dependencies: {} }, - extensions: [ - { - id: "googlechat", - packageJson: { - dependencies: { "google-auth-library": "^1.0.0", undici: "^7.0.0" }, - openclaw: { - install: { npmSpec: "@openclaw/googlechat" }, - releaseChecks: { - rootDependencyMirrorAllowlist: ["google-auth-library"], - }, - }, - }, - }, - ], - }), - ).toEqual([ - "bundled extension 'googlechat' root dependency mirror drift | missing in root package: google-auth-library, undici | new gaps: undici", - ]); - }); - - it("flags stale allowlist entries once a gap is resolved", () => { - expect( - collectBundledExtensionRootDependencyGapErrors({ - rootPackage: { dependencies: { "google-auth-library": "^1.0.0" } }, - extensions: [ - { - id: "googlechat", - packageJson: { - dependencies: { "google-auth-library": "^1.0.0" }, - openclaw: { - install: { npmSpec: "@openclaw/googlechat" }, - releaseChecks: { - rootDependencyMirrorAllowlist: ["google-auth-library"], - }, - }, - }, - }, - ], - }), - ).toEqual([ - "bundled extension 'googlechat' root dependency mirror drift | missing in root package: (none) | remove stale allowlist entries: google-auth-library", - ]); - }); -}); - describe("collectBundledExtensionManifestErrors", () => { it("flags invalid bundled extension install metadata", () => { expect( @@ -135,33 +53,14 @@ describe("collectBundledExtensionManifestErrors", () => { "bundled extension 'broken' manifest invalid | openclaw.install.npmSpec must be a non-empty string", ]); }); - - it("flags invalid release-check allowlist metadata", () => { - expect( - collectBundledExtensionManifestErrors([ - { - id: "broken", - packageJson: { - openclaw: { - install: { npmSpec: "@openclaw/broken" }, - releaseChecks: { - rootDependencyMirrorAllowlist: ["ok", ""], - }, - }, - }, - }, - ]), - ).toEqual([ - "bundled extension 'broken' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist must contain only non-empty strings", - ]); - }); }); describe("collectForbiddenPackPaths", () => { - it("flags nested node_modules leaking into npm pack output", () => { + it("allows bundled plugin runtime deps under dist/extensions but still blocks other node_modules", () => { expect( collectForbiddenPackPaths([ "dist/index.js", + "dist/extensions/discord/node_modules/@buape/carbon/index.js", "extensions/tlon/node_modules/.bin/tlon", "node_modules/.bin/openclaw", ]),