diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ae256e73dc..fa716f81826 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - Channels/Discord: suppress duplicate gateway monitors when multiple enabled accounts resolve to the same bot token, preferring config tokens over default env fallback and reporting skipped duplicates as disabled. Supersedes #73608. Thanks @kagura-agent. - Channels/Discord: ignore stale route-shaped conversation bindings after a Discord channel is reconfigured to another agent, while preserving explicit focus and subagent bindings. Fixes #73626. Thanks @ramitrkar-hash. - Gateway/sessions: add conservative stuck-session recovery that releases only stale session lanes while active embedded runs, reply operations, and lane tasks remain serialized, so queued follow-ups can drain without aborting legitimate long-running turns. Refs #73581, #73655, #73652, #73705, #73647, #73602, #73592, and #73601. Thanks @WS-Q0758, @bryangauvin, @spenceryang1996-dot, @bmilne1981, @mattmcintyre, @Vksh07, and @Spolen23. +- Plugins: cache unchanged plugin manifest loads by file signature, reducing repeated JSON/JSON5 parsing and manifest normalization in bursty startup and runtime registry paths. Refs #73532 and #73647; carries forward #73678. Thanks @TheDutchRuler. - NVIDIA/NIM: persist the `NVIDIA_API_KEY` provider marker and mark bundled NVIDIA Chat Completions models as string-content compatible, so NIM models load from `models.json` and OpenAI-compatible subagent calls send plain text content. Fixes #73013 and #50107; refs #73014. Thanks @bautrey, @iot2edge, @ifearghal, and @futhgar. - Channels/Discord: let text-only configs drop the `GuildVoiceStates` gateway intent and expose a bounded `/gateway/bot` metadata timeout with rate-limited fallback logs, reducing idle CPU and warning floods. Fixes #73709 and #73585. Thanks @sanchezm86 and @trac3r00. - Agents/sessions: mark same-turn `sessions_send` and A2A reply prompts with an inter-session `isUser=false` envelope before they reach the model, so foreign session output no longer lands as bare active user text. Fixes #73702; refs #73698, #73609, #73595, and #73622. Thanks @alvelda. diff --git a/extensions/googlechat/src/config-schema.ts b/extensions/googlechat/src/config-schema.ts index 93c43b2e25c..3f112de823c 100644 --- a/extensions/googlechat/src/config-schema.ts +++ b/extensions/googlechat/src/config-schema.ts @@ -1,3 +1,6 @@ -import { buildChannelConfigSchema, GoogleChatConfigSchema } from "../runtime-api.js"; +import { + buildChannelConfigSchema, + GoogleChatConfigSchema, +} from "openclaw/plugin-sdk/bundled-channel-config-schema"; export const GoogleChatChannelConfigSchema = buildChannelConfigSchema(GoogleChatConfigSchema); diff --git a/src/plugins/manifest.json5-tolerance.test.ts b/src/plugins/manifest.json5-tolerance.test.ts index 2809f7c4577..767bfed7d3e 100644 --- a/src/plugins/manifest.json5-tolerance.test.ts +++ b/src/plugins/manifest.json5-tolerance.test.ts @@ -2,7 +2,11 @@ import fs from "node:fs"; import path from "node:path"; import JSON5 from "json5"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { loadPluginManifest, MAX_PLUGIN_MANIFEST_BYTES } from "./manifest.js"; +import { + clearPluginManifestLoadCache, + loadPluginManifest, + MAX_PLUGIN_MANIFEST_BYTES, +} from "./manifest.js"; import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js"; const tempDirs: string[] = []; @@ -13,6 +17,7 @@ function makeTempDir() { afterEach(() => { vi.restoreAllMocks(); + clearPluginManifestLoadCache(); cleanupTrackedTempDirs(tempDirs); }); @@ -53,6 +58,26 @@ describe("loadPluginManifest JSON5 tolerance", () => { expect(json5Parse).not.toHaveBeenCalled(); }); + it("reuses unchanged manifest loads by file signature", () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, "openclaw.plugin.json"), + JSON.stringify({ + id: "cached-json", + configSchema: { type: "object" }, + }), + "utf-8", + ); + const readFileSync = vi.spyOn(fs, "readFileSync"); + + const first = loadPluginManifest(dir, false); + const second = loadPluginManifest(dir, false); + + expect(first.ok).toBe(true); + expect(second.ok).toBe(true); + expect(readFileSync).toHaveBeenCalledTimes(1); + }); + it("parses a manifest with trailing commas", () => { const dir = makeTempDir(); const json5Content = `{ diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index f132302d817..8db699ce414 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -33,6 +33,20 @@ import type { PluginKind } from "./plugin-kind.types.js"; export const PLUGIN_MANIFEST_FILENAME = "openclaw.plugin.json"; export const PLUGIN_MANIFEST_FILENAMES = [PLUGIN_MANIFEST_FILENAME] as const; export const MAX_PLUGIN_MANIFEST_BYTES = 256 * 1024; +const MAX_PLUGIN_MANIFEST_LOAD_CACHE_ENTRIES = 512; + +type PluginManifestLoadCacheEntry = { + result: PluginManifestLoadResult; + size: number; + mtimeMs: number; + ctimeMs: number; +}; + +const pluginManifestLoadCache = new Map(); + +export function clearPluginManifestLoadCache(): void { + pluginManifestLoadCache.clear(); +} export type PluginManifestChannelConfig = { schema: JsonSchemaObject; @@ -1148,6 +1162,62 @@ export function resolvePluginManifestPath(rootDir: string): string { return path.join(rootDir, PLUGIN_MANIFEST_FILENAME); } +function buildPluginManifestLoadCacheKey(params: { + manifestPath: string; + rejectHardlinks: boolean; + rootRealPath?: string; + stats: fs.Stats; +}): string { + return JSON.stringify([ + path.resolve(params.manifestPath), + params.rejectHardlinks, + params.rootRealPath ?? "", + params.stats.dev, + params.stats.ino, + params.stats.size, + params.stats.mtimeMs, + params.stats.ctimeMs, + ]); +} + +function getCachedPluginManifestLoadResult( + key: string, + stats: fs.Stats, +): PluginManifestLoadResult | undefined { + const entry = pluginManifestLoadCache.get(key); + if ( + !entry || + entry.size !== stats.size || + entry.mtimeMs !== stats.mtimeMs || + entry.ctimeMs !== stats.ctimeMs + ) { + return undefined; + } + pluginManifestLoadCache.delete(key); + pluginManifestLoadCache.set(key, entry); + return entry.result; +} + +function setCachedPluginManifestLoadResult( + key: string, + stats: fs.Stats, + result: PluginManifestLoadResult, +): void { + pluginManifestLoadCache.set(key, { + result, + size: stats.size, + mtimeMs: stats.mtimeMs, + ctimeMs: stats.ctimeMs, + }); + if (pluginManifestLoadCache.size <= MAX_PLUGIN_MANIFEST_LOAD_CACHE_ENTRIES) { + return; + } + const oldestKey = pluginManifestLoadCache.keys().next().value; + if (typeof oldestKey === "string") { + pluginManifestLoadCache.delete(oldestKey); + } +} + function parsePluginKind(raw: unknown): PluginKind | PluginKind[] | undefined { if (typeof raw === "string") { return raw as PluginKind; @@ -1186,28 +1256,44 @@ export function loadPluginManifest( }), }); } + const stats = opened.stat; + const cacheKey = buildPluginManifestLoadCacheKey({ + manifestPath, + rejectHardlinks, + ...(rootRealPath !== undefined ? { rootRealPath } : {}), + stats, + }); + const cached = getCachedPluginManifestLoadResult(cacheKey, stats); + if (cached) { + fs.closeSync(opened.fd); + return cached; + } + const cacheResult = (result: PluginManifestLoadResult): PluginManifestLoadResult => { + setCachedPluginManifestLoadResult(cacheKey, stats, result); + return result; + }; let raw: unknown; try { raw = parseJsonWithJson5Fallback(fs.readFileSync(opened.fd, "utf-8")); } catch (err) { - return { + return cacheResult({ ok: false, error: `failed to parse plugin manifest: ${String(err)}`, manifestPath, - }; + }); } finally { fs.closeSync(opened.fd); } if (!isRecord(raw)) { - return { ok: false, error: "plugin manifest must be an object", manifestPath }; + return cacheResult({ ok: false, error: "plugin manifest must be an object", manifestPath }); } const id = normalizeOptionalString(raw.id) ?? ""; if (!id) { - return { ok: false, error: "plugin manifest requires id", manifestPath }; + return cacheResult({ ok: false, error: "plugin manifest requires id", manifestPath }); } const configSchema = isRecord(raw.configSchema) ? raw.configSchema : null; if (!configSchema) { - return { ok: false, error: "plugin manifest requires configSchema", manifestPath }; + return cacheResult({ ok: false, error: "plugin manifest requires configSchema", manifestPath }); } const kind = parsePluginKind(raw.kind); @@ -1260,7 +1346,7 @@ export function loadPluginManifest( uiHints = raw.uiHints as Record; } - return { + return cacheResult({ ok: true, manifest: { id, @@ -1302,7 +1388,7 @@ export function loadPluginManifest( channelConfigs, }, manifestPath, - }; + }); } // package.json "openclaw" metadata (used for setup/catalog)