From c2e7c819f57082db65a4911dbf3d924e9b6f484d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 8 Aug 2026 20:28:58 -0700 Subject: [PATCH] perf(doctor): slim remaining heavy doctor contract closures (#120811) * perf(doctor): slim remaining heavy doctor contract closures Follow-up to #120698: several doctor closures still cold-loaded multi-second kysely-bearing graphs through other broad barrels (session-store-runtime, realtime-voice, channel-outbound, logging-core, memory-host-core/-events, sqlite-runtime, persistent-dedupe, and plugin-local barrels). - lazy-import heavy helpers inside async migration bodies (codex, msteams, zalouser, workboard, matrix inbound-dedupe, memory-core migrations) - bypass plugin-local barrels to defining modules (reef protocol, memory-core short-term-promotion) - move to lighter existing subpaths (slack -> channel-streaming, matrix logger -> security-runtime, memory-wiki -> agent-scope-runtime, which now also exports resolveSessionAgentId) - add narrow openclaw/plugin-sdk/realtime-voice-activation for discord's sync wake-name doctor rules - split src/infra/kysely-sync-cache-state.ts so sqlite-transaction clears Kysely caches without value-loading kysely; split the memory-host-sdk kysely bridge off the schema/transaction bridge - guard: forbid the heavy barrels in doctor closures with per-kind scoping Cold enumeration per plugin: discord 52.6s->0.3s, msteams 30.9s->0.5s, codex 29.6s->2.6s, zalouser 28.8s->2.3s, matrix 27.2s->3.2s, slack 17.5s->1.5s, reef 9.9s->0.7s, memory-core 6.4s->3.6s, workboard 3.4s->0.25s; all kysely-free except llm-task (named follow-up). * fix(plugins): route slack streaming compat through a focused streaming-config subpath The channel-streaming compat barrel is deprecated for extension production code (deprecated-api-usage guard + SDK package contract). Add the narrow non-deprecated openclaw/plugin-sdk/channel-streaming-config subpath for the pure streaming config readers, and drop the now consumer-less short-term-promotion barrel re-exports knip flagged. * test(plugins): register memory-host-sdk kysely bridge in package boundary inventory * fix(plugins): classify realtime-voice-activation as private-local ClawSweeper P2: the subpath exports only a default target, which is the private-local shape; register it in plugin-sdk-private-local-only-subpaths, the package-boundary d.ts alias maps, and correct the public surface budgets (realtime-voice-activation no longer counts as public). * fix(release): exclude realtime-voice-activation declarations from the published package Private-local subpaths ship without d.ts; register the files negation the release pack-path check requires. --- .../.generated/plugin-sdk-api-baseline.sha256 | 3 +- docs/plugins/sdk-subpaths.md | 2 + .../src/migration/session-binding-sidecars.ts | 6 +- extensions/discord/src/doctor-contract.ts | 4 +- .../monitor/inbound-dedupe-migration.ts | 32 ++++--- extensions/matrix/src/matrix/sdk/logger.ts | 4 +- .../src/migration/doctor-dreaming-state.ts | 8 +- .../migration/doctor-host-event-sources.ts | 5 +- .../src/migration/doctor-host-events.ts | 9 +- .../src/migration/doctor-memory-sidecar.ts | 18 ++-- .../migration/doctor-vector-index-provider.ts | 8 +- .../migration/dreaming-state-comparison.ts | 8 +- .../memory-core/src/short-term-promotion.ts | 4 - extensions/memory-core/src/test-helpers.ts | 8 +- extensions/memory-wiki/src/config.ts | 7 +- extensions/msteams/doctor-contract-api.ts | 16 ++-- extensions/reef/src/audit-state.ts | 4 +- extensions/reef/src/doctor-durable-state.ts | 12 ++- extensions/reef/src/state.ts | 19 ++--- extensions/slack/src/streaming-compat.ts | 4 +- .../tsconfig.package-boundary.paths.json | 3 + extensions/workboard/doctor-contract-api.ts | 5 +- extensions/xai/tsconfig.json | 3 + extensions/zalouser/doctor-contract-api.ts | 22 ++--- package.json | 8 ++ .../src/host/memory-recall-metadata.ts | 2 +- .../src/host/openclaw-runtime-kysely.ts | 4 + .../src/host/openclaw-runtime-sqlite.ts | 3 +- scripts/lib/plugin-sdk-entrypoints.json | 2 + ...lugin-sdk-private-local-only-subpaths.json | 1 + scripts/plugin-sdk-surface-report.mjs | 10 ++- src/infra/kysely-sync-cache-state.ts | 20 +++++ src/infra/kysely-sync.ts | 23 ++--- src/infra/sqlite-transaction.ts | 4 +- src/plugin-sdk/agent-scope-runtime.ts | 1 + src/plugin-sdk/channel-streaming-config.ts | 12 +++ src/plugin-sdk/realtime-voice-activation.ts | 20 +++++ ...tension-package-project-boundaries.test.ts | 1 + .../doctor-contract-closure-guard.test.ts | 85 ++++++++++++++++--- 39 files changed, 289 insertions(+), 121 deletions(-) create mode 100644 packages/memory-host-sdk/src/host/openclaw-runtime-kysely.ts create mode 100644 src/infra/kysely-sync-cache-state.ts create mode 100644 src/plugin-sdk/channel-streaming-config.ts create mode 100644 src/plugin-sdk/realtime-voice-activation.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index a0042c38852f..f9e39a50515e 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -7,7 +7,7 @@ e5e67ddf3cab38fcbf9220bc3160715897e2709d9a9ff6ff36f1ecc9453c2367 module/agent-c a43840ff36833daef61050fd801e825ec3ba806348b4fbe6c613f20a8807a5e7 module/agent-harness-runtime 5168648cd946abad8a92822889f13ceacc87ed502314a66190d0b1eb8ebe76ea module/agent-media-payload e82bf122ca0787ec0bcdd12b669cfe24f07da16be1f4090e84c7d645b07ac79f module/agent-runtime -770bcb35221a019c70eace0200233a3a7985b24a6df48625f3e67b478d071ed1 module/agent-scope-runtime +6d12eed0833b9f63b5cea9a763c470487c85c5a722a3a35ec1f5ce07dc8715a9 module/agent-scope-runtime 56b6d5fb6af3d95af1200065aca2e7d4f59e5fa59740505fe6ff433077ef6646 module/allow-from 55cea5390d68839ca7768b4a0cc570b17b65fa0fa3bc4d76130ef0f16cb79ede module/allowlist-config-edit 7ddd81bd5f55de9adf64bf4d92d012f24b37b6da0a72805a3a220d8feff24ca3 module/approval-auth-runtime @@ -48,6 +48,7 @@ f3cee48527f5cddde81912a588e72947d40443322b716871a8d9e5dc0c37c542 module/channel fb123c1b557ed2527e335f13c3d6de41ab0c3305151001cf2b1075d1da8034c5 module/channel-setup aeac9bb8127faf636dde79772a577d9cb003660ef3d1df03ddf606101020c402 module/channel-status f008d1d9ea73cd17e9833143bd117436599438523041fd157b6b6cb17500ac06 module/channel-streaming +d1ff8728d99acf8130b94db7ba75ad7050a549c96365f56b8d4f601a533ca947 module/channel-streaming-config 14f0103adb14627b662fbe9fdd5ed08596ed702a250853e8beb8bb8dd1361588 module/cli-argv c89ec1b194b76f67a6f4dd108dccf460da6065646cba31374c8aa748f23a39e4 module/collection-runtime 391e6f0c77da2e17a058fc5aea87fd193830b2a14e12fae77c7f63b0226f0bb5 module/command-auth diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index ec401410977e..539a8e2c0c8e 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -124,6 +124,7 @@ deprecated for new code; see the per-row notes below. | `plugin-sdk/channel-inbound-debounce` | Narrow inbound debounce helpers | | `plugin-sdk/channel-mention-gating` | Private-local after July 2026; Narrow mention-policy, mention marker, and mention text helpers without the broader inbound runtime surface | | `plugin-sdk/channel-streaming` | Deprecated compatibility facade. Use `plugin-sdk/channel-outbound`. | + | `plugin-sdk/channel-streaming-config` | Dependency-light channel streaming config readers (`getChannelStreamingConfigObject`, `resolveChannelStreamingNativeTransport`) for doctor contract closures and other control-plane paths that must not load the reply pipeline | | `plugin-sdk/channel-send-result` | Reply result types | | `plugin-sdk/channel-actions` | Channel message-action helpers, plus deprecated native schema helpers kept for plugin compatibility | | `plugin-sdk/channel-route` | Private-local after July 2026; Shared route normalization, parser-driven target resolution, thread-id stringification, dedupe/compact route keys, parsed-target types, and route/target comparison helpers | @@ -340,6 +341,7 @@ Use `isLoopbackHost(host)` when a plugin must accept only the local machine. It | `plugin-sdk/realtime-transcription` | Private-local after July 2026; Realtime transcription provider types, registry helpers, and shared WebSocket session helper | | `plugin-sdk/realtime-bootstrap-context` | Private-local after July 2026; Realtime profile bootstrap helper for bounded `IDENTITY.md`, `USER.md`, and `SOUL.md` context injection | | `plugin-sdk/realtime-voice-audio-queue` | Private-local JavaScript-only host runtime for bundled or separately published official plugins; narrow bounded audio queue seam for lazy realtime voice provider facades without importing the broader realtime voice runtime; not for third-party plugins | + | `plugin-sdk/realtime-voice-activation` | Private-local; dependency-light realtime-voice activation-name helpers (normalize, match, word-count, sort) for doctor contract closures and other control-plane paths that must not load the realtime voice runtime | | `plugin-sdk/realtime-voice` | Private-local after July 2026; Realtime voice provider types, registry helpers, shared audio-energy/speech-onset gates, and realtime voice behavior helpers, including the transport-independent session harness and output activity tracking. For official runtime consumers, sender-auth contract revision 1 forwards ingress-authenticated `senderId` and `senderIsOwner` unchanged; ingress owns authentication, and consumers requiring the handoff must fail closed on other revisions. | | `plugin-sdk/meeting-runtime` | Browser-meeting session runtime, realtime audio engines/transports, `MeetingPlatformAdapter`, browser/node control, agent-consult, voice-call delegation, setup checks, and SoX command helpers | | `plugin-sdk/image-generation` | Private-local after July 2026; Image generation provider types plus image asset/data URL helpers and the OpenAI-compatible image provider builder | diff --git a/extensions/codex/src/migration/session-binding-sidecars.ts b/extensions/codex/src/migration/session-binding-sidecars.ts index 1e177a94a39e..46a48f6e46eb 100644 --- a/extensions/codex/src/migration/session-binding-sidecars.ts +++ b/extensions/codex/src/migration/session-binding-sidecars.ts @@ -18,7 +18,6 @@ import { isPathInside, pathExists, } from "openclaw/plugin-sdk/security-runtime"; -import { patchSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { CODEX_APP_SERVER_BINDING_MAX_ENTRIES, @@ -97,6 +96,9 @@ type MigratedBindingRow = }; async function collectSessionSurfaces(params: MigrationEnvironment): Promise { + // Doctor enumeration cold-loads this closure; session-store-runtime pulls the + // session-accessor/kysely graph, so it stays behind lazy imports in async bodies. + const { resolveStorePath } = await import("openclaw/plugin-sdk/session-store-runtime"); const surfaces = new Map(); const stateRoot = await canonicalPathFromExistingAncestor(params.stateDir); const add = async (root: string, storePath: string, agentId: string, scan: boolean) => { @@ -305,6 +307,7 @@ async function collectBindingOwners( surfaces: SessionSurface[], params: MigrationEnvironment, ): Promise { + const { resolveStorePath } = await import("openclaw/plugin-sdk/session-store-runtime"); const sourcePaths = new Set( await Promise.all( sources.map((source) => canonicalPathFromExistingAncestor(source.transcriptPath)), @@ -679,6 +682,7 @@ async function recordSessionOwner( owner: LegacyBindingOwner, env: NodeJS.ProcessEnv, ): Promise { + const { patchSessionEntry } = await import("openclaw/plugin-sdk/session-store-runtime"); const currentIndex = await readLegacySessionIndex(owner.storePath); if ("failure" in currentIndex) { return "its legacy session owner could not be revalidated"; diff --git a/extensions/discord/src/doctor-contract.ts b/extensions/discord/src/doctor-contract.ts index 2586634597f5..bc003746f504 100644 --- a/extensions/discord/src/doctor-contract.ts +++ b/extensions/discord/src/doctor-contract.ts @@ -4,10 +4,12 @@ import type { ChannelDoctorLegacyConfigRule, } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +// The narrow activation subpath avoids realtime-voice's agent-consult/session +// graph, which doctor enumeration must not cold-load. import { isSupportedRealtimeVoiceActivationName, normalizeRealtimeVoiceActivationNamePrefix, -} from "openclaw/plugin-sdk/realtime-voice"; +} from "openclaw/plugin-sdk/realtime-voice-activation"; import { asObjectRecord, defineChannelAliasMigration, diff --git a/extensions/matrix/src/matrix/monitor/inbound-dedupe-migration.ts b/extensions/matrix/src/matrix/monitor/inbound-dedupe-migration.ts index 3108d4bc36e1..195021758b69 100644 --- a/extensions/matrix/src/matrix/monitor/inbound-dedupe-migration.ts +++ b/extensions/matrix/src/matrix/monitor/inbound-dedupe-migration.ts @@ -15,23 +15,12 @@ import type { Dirent } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import type { DatabaseSync } from "node:sqlite"; -import { - createPersistentDedupeImportEntry, - type PersistentDedupeEntry, -} from "openclaw/plugin-sdk/persistent-dedupe"; +// Doctor enumeration cold-loads this closure; persistent-dedupe pulls the +// plugin-state-store/kysely graph, so the value import stays lazy below. +import type { PersistentDedupeEntry } from "openclaw/plugin-sdk/persistent-dedupe"; import type { PluginDoctorStateMigrationContext } from "openclaw/plugin-sdk/runtime-doctor-migrations"; -import { - openNodeSqliteDatabase, - runSqliteImmediateTransactionSync, -} from "openclaw/plugin-sdk/sqlite-runtime"; import { isRecord } from "../../record-shared.js"; import { normalizeMatrixStorageMetadata } from "../client/storage.js"; -import { - buildMatrixInboundDedupeEventKey, - MATRIX_INBOUND_DEDUPE_STATE_MAX_ENTRIES, - MATRIX_INBOUND_DEDUPE_TTL_MS, - resolveMatrixInboundDedupeStateNamespace, -} from "./inbound-dedupe.js"; const LEGACY_SQLITE_NAMESPACE = "inbound-dedupe"; const LEGACY_MARKERS_NAMESPACE = "inbound-dedupe-migrations"; @@ -249,6 +238,9 @@ function parseLegacySqliteRow(row: { export async function readLegacyInboundDedupeSqliteSource( storageRootDir: string, ): Promise<{ markers: LegacyInboundDedupeMarker[]; legacyRowCount: number }> { + // sqlite-runtime re-exports the agent-db/kysely graph; keep it lazy so doctor + // enumeration does not cold-load it with this closure. + const { openNodeSqliteDatabase } = await import("openclaw/plugin-sdk/sqlite-runtime"); const databasePath = path.join(storageRootDir, STATE_DATABASE_RELATIVE_PATH); const db = openNodeSqliteDatabase(databasePath, { readOnly: true }); try { @@ -282,6 +274,8 @@ export async function readLegacyInboundDedupeSqliteSource( /** Deletes only the two retired Matrix namespaces after a successful import. */ export async function retireLegacyInboundDedupeSqliteRows(storageRootDir: string): Promise { + const { openNodeSqliteDatabase, runSqliteImmediateTransactionSync } = + await import("openclaw/plugin-sdk/sqlite-runtime"); const databasePath = path.join(storageRootDir, STATE_DATABASE_RELATIVE_PATH); const db = openNodeSqliteDatabase(databasePath); try { @@ -406,6 +400,16 @@ export async function importNewestInboundDedupeMarkers(params: { now?: number; stateMaxEntries?: number; }): Promise<{ imported: number; total: number }> { + const { createPersistentDedupeImportEntry } = + await import("openclaw/plugin-sdk/persistent-dedupe"); + // inbound-dedupe.js value-imports persistent-dedupe's replay guard, so the + // runtime module also stays out of this closure's static import graph. + const { + buildMatrixInboundDedupeEventKey, + MATRIX_INBOUND_DEDUPE_STATE_MAX_ENTRIES, + MATRIX_INBOUND_DEDUPE_TTL_MS, + resolveMatrixInboundDedupeStateNamespace, + } = await import("./inbound-dedupe.js"); const now = params.now ?? Date.now(); const stateMaxEntries = params.stateMaxEntries ?? MATRIX_INBOUND_DEDUPE_STATE_MAX_ENTRIES; const newestByKey = new Map(); diff --git a/extensions/matrix/src/matrix/sdk/logger.ts b/extensions/matrix/src/matrix/sdk/logger.ts index 6a97f54bce5a..1a897969a255 100644 --- a/extensions/matrix/src/matrix/sdk/logger.ts +++ b/extensions/matrix/src/matrix/sdk/logger.ts @@ -1,7 +1,9 @@ // Matrix plugin module implements logger behavior. import { format } from "node:util"; -import { redactSensitiveText } from "openclaw/plugin-sdk/logging-core"; import type { RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime"; +// security-runtime exports the same redaction helper without logging-core's +// diagnostic/config graph, which doctor enumeration must not cold-load. +import { redactSensitiveText } from "openclaw/plugin-sdk/security-runtime"; import { getMatrixRuntime } from "../../runtime.js"; type Logger = { diff --git a/extensions/memory-core/src/migration/doctor-dreaming-state.ts b/extensions/memory-core/src/migration/doctor-dreaming-state.ts index 37b920f8b5a8..f5556f2db2b4 100644 --- a/extensions/memory-core/src/migration/doctor-dreaming-state.ts +++ b/extensions/memory-core/src/migration/doctor-dreaming-state.ts @@ -21,12 +21,14 @@ import { writeMemoryCoreWorkspaceEntries, writeMemoryCoreWorkspaceEntry, } from "../dreaming-state.js"; +// Import from the defining modules, not the short-term-promotion barrel: the +// barrel pulls memory-host-events/kysely, which doctor enumeration cold-loads. +import { normalizeShortTermPhaseSignalStore } from "../short-term-promotion-store.js"; import { SHORT_TERM_PHASE_SIGNAL_RELATIVE_PATH, SHORT_TERM_STORE_RELATIVE_PATH, - normalizeShortTermPhaseSignalStore, - normalizeShortTermRecallStore, -} from "../short-term-promotion.js"; +} from "../short-term-promotion-types.js"; +import { normalizeShortTermRecallStore } from "../short-term-promotion-utils.js"; import { resolveConfiguredWorkspaces } from "./doctor-workspaces.js"; import { dreamingStateComparison } from "./dreaming-state-comparison.js"; diff --git a/extensions/memory-core/src/migration/doctor-host-event-sources.ts b/extensions/memory-core/src/migration/doctor-host-event-sources.ts index cecc94dd1c11..3a9d533f82b2 100644 --- a/extensions/memory-core/src/migration/doctor-host-event-sources.ts +++ b/extensions/memory-core/src/migration/doctor-host-event-sources.ts @@ -2,7 +2,8 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { root } from "openclaw/plugin-sdk/memory-core-host-engine-fs"; -import { resolveMemoryHostEventLogPath } from "openclaw/plugin-sdk/memory-host-events"; +// Doctor enumeration cold-loads this closure; memory-host-events pulls the +// event-store/kysely graph, so the path resolver loads lazily in async bodies. import { resolveConfiguredWorkspaces } from "./doctor-workspaces.js"; export type LegacyMemoryHostEventSource = @@ -45,6 +46,7 @@ export async function collectLegacyMemoryHostEventSources( config: unknown, env: NodeJS.ProcessEnv, ): Promise { + const { resolveMemoryHostEventLogPath } = await import("openclaw/plugin-sdk/memory-host-events"); const sources: LegacyMemoryHostEventSource[] = []; const seenWorkspaces = new Set(); for (const workspaceDir of resolveConfiguredWorkspaces(config, env)) { @@ -154,6 +156,7 @@ export async function collectLegacyMemoryHostEventSources( export async function resolveMemoryHostEventArchivePath( source: ReadyLegacyMemoryHostEventSource, ): Promise<{ archiveRelativePath: string; claimRelativePath: string; generationKey: string }> { + const { resolveMemoryHostEventLogPath } = await import("openclaw/plugin-sdk/memory-host-events"); const activeRelativePath = path.relative( source.workspaceDir, resolveMemoryHostEventLogPath(source.workspaceDir), diff --git a/extensions/memory-core/src/migration/doctor-host-events.ts b/extensions/memory-core/src/migration/doctor-host-events.ts index 1b55d5ffab64..deceacc53a1e 100644 --- a/extensions/memory-core/src/migration/doctor-host-events.ts +++ b/extensions/memory-core/src/migration/doctor-host-events.ts @@ -1,9 +1,8 @@ import crypto from "node:crypto"; import path from "node:path"; -import { - normalizeMemoryHostEventRecordForStorage, - resolveMemoryHostEventLogPath, -} from "openclaw/plugin-sdk/memory-host-events"; +// Doctor enumeration cold-loads this closure; memory-host-events pulls the +// event-store/kysely graph, so values load lazily inside the async migration. +import type { normalizeMemoryHostEventRecordForStorage } from "openclaw/plugin-sdk/memory-host-events"; import type { PluginDoctorStateMigration, PluginDoctorStateMigrationContext, @@ -157,6 +156,8 @@ async function migrateLegacyMemoryHostEventSource(params: { changes: string[]; warnings: string[]; }): Promise<"completed" | "blocked"> { + const { normalizeMemoryHostEventRecordForStorage, resolveMemoryHostEventLogPath } = + await import("openclaw/plugin-sdk/memory-host-events"); const activeRelativePath = path.relative( params.source.workspaceDir, resolveMemoryHostEventLogPath(params.source.workspaceDir), diff --git a/extensions/memory-core/src/migration/doctor-memory-sidecar.ts b/extensions/memory-core/src/migration/doctor-memory-sidecar.ts index 0d0b3509d6a0..4a6aaeb174b7 100644 --- a/extensions/memory-core/src/migration/doctor-memory-sidecar.ts +++ b/extensions/memory-core/src/migration/doctor-memory-sidecar.ts @@ -4,20 +4,15 @@ import fs from "node:fs/promises"; import path from "node:path"; import { reclaimDefinitelyStaleFileLock } from "openclaw/plugin-sdk/file-lock"; import { resolveUserPath } from "openclaw/plugin-sdk/memory-core-host-engine-fs"; -import { - ensureMemoryIndexSchema, - loadSqliteVecExtension, -} from "openclaw/plugin-sdk/memory-core-host-engine-schema"; +// Doctor enumeration cold-loads this closure; the host engine schema pulls the +// runtime-sqlite/kysely graph, so its helpers load lazily in the async migration. import { normalizeAgentId } from "openclaw/plugin-sdk/routing"; import { legacyStateFileExists, type PluginDoctorStateMigration, } from "openclaw/plugin-sdk/runtime-doctor-migrations"; -import { - ensureOpenClawAgentDatabaseSchema, - openNodeSqliteDatabase, - resolveOpenClawAgentSqlitePath, -} from "openclaw/plugin-sdk/sqlite-runtime"; +// sqlite-runtime re-exports the agent-db/kysely graph; keep it lazy so doctor +// enumeration does not cold-load it with this closure. import { importLegacyMemorySidecarIndex, LEGACY_MEMORY_SIDECAR_SUFFIXES, @@ -144,6 +139,7 @@ async function collectLegacyMemorySidecarSources(params: { env: NodeJS.ProcessEnv; stateDir: string; }): Promise { + const { resolveOpenClawAgentSqlitePath } = await import("openclaw/plugin-sdk/sqlite-runtime"); const agentIds = new Set(resolveConfiguredAgentIds(params.config)); const legacyDir = path.join(params.stateDir, "memory"); const retrySidecars: Array<{ agentId: string; legacyPath: string }> = []; @@ -373,6 +369,10 @@ async function migrateLegacyMemorySidecarSource(params: { changes: string[]; warnings: string[]; }): Promise<{ archiveReady: boolean }> { + const { ensureMemoryIndexSchema, loadSqliteVecExtension } = + await import("openclaw/plugin-sdk/memory-core-host-engine-schema"); + const { ensureOpenClawAgentDatabaseSchema, openNodeSqliteDatabase } = + await import("openclaw/plugin-sdk/sqlite-runtime"); // OpenClaw itself can leave a zero-byte placeholder at the legacy sidecar // path while the live index is the per-agent SQLite database. An empty file // holds no legacy rows, so remove it quietly instead of emitting a permanent diff --git a/extensions/memory-core/src/migration/doctor-vector-index-provider.ts b/extensions/memory-core/src/migration/doctor-vector-index-provider.ts index 45658a468e99..d7208b1a8c68 100644 --- a/extensions/memory-core/src/migration/doctor-vector-index-provider.ts +++ b/extensions/memory-core/src/migration/doctor-vector-index-provider.ts @@ -3,7 +3,8 @@ import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import type { PluginDoctorStateMigration } from "openclaw/plugin-sdk/runtime-doctor-migrations"; -import { openNodeSqliteDatabase } from "openclaw/plugin-sdk/sqlite-runtime"; +// sqlite-runtime re-exports the agent-db/kysely graph; keep it lazy so doctor +// enumeration does not cold-load it with this closure. const MEMORY_INDEX_META_KEY = "memory_index_meta_v1"; @@ -85,10 +86,11 @@ function listConfiguredAgentIds(config: OpenClawConfig): string[] { return ids.size > 0 ? [...ids] : ["main"]; } -function readExistingVectorModel(databasePath: string): string | null { +async function readExistingVectorModel(databasePath: string): Promise { if (!fs.existsSync(databasePath)) { return null; } + const { openNodeSqliteDatabase } = await import("openclaw/plugin-sdk/sqlite-runtime"); let db: ReturnType | undefined; try { db = openNodeSqliteDatabase(databasePath, { readOnly: true }); @@ -144,7 +146,7 @@ async function collectVectorProviderFindings(params: { "agent", "openclaw-agent.sqlite", ); - const model = readExistingVectorModel(agentDatabasePath); + const model = await readExistingVectorModel(agentDatabasePath); if (!model) { continue; } diff --git a/extensions/memory-core/src/migration/dreaming-state-comparison.ts b/extensions/memory-core/src/migration/dreaming-state-comparison.ts index bba876aac15d..6af64346c981 100644 --- a/extensions/memory-core/src/migration/dreaming-state-comparison.ts +++ b/extensions/memory-core/src/migration/dreaming-state-comparison.ts @@ -16,10 +16,10 @@ import { readMemoryCoreWorkspaceEntries, writeMemoryCoreWorkspaceEntry, } from "../dreaming-state.js"; -import { - normalizeShortTermPhaseSignalStore, - normalizeShortTermRecallStore, -} from "../short-term-promotion.js"; +// Import from the defining modules, not the short-term-promotion barrel: the +// barrel pulls memory-host-events/kysely, which doctor enumeration cold-loads. +import { normalizeShortTermPhaseSignalStore } from "../short-term-promotion-store.js"; +import { normalizeShortTermRecallStore } from "../short-term-promotion-utils.js"; type LegacyDreamingSource = { workspaceDir: string; diff --git a/extensions/memory-core/src/short-term-promotion.ts b/extensions/memory-core/src/short-term-promotion.ts index 95c63aeb48a9..ae57147d2c3a 100644 --- a/extensions/memory-core/src/short-term-promotion.ts +++ b/extensions/memory-core/src/short-term-promotion.ts @@ -240,8 +240,6 @@ export { DEFAULT_PROMOTION_MIN_RECALL_COUNT, DEFAULT_PROMOTION_MIN_SCORE, DEFAULT_PROMOTION_MIN_UNIQUE_QUERIES, - SHORT_TERM_PHASE_SIGNAL_RELATIVE_PATH, - SHORT_TERM_STORE_RELATIVE_PATH, type PromotionCandidate, type RepairShortTermPromotionArtifactsResult, type ShortTermAuditSummary, @@ -249,8 +247,6 @@ export { type ShortTermDreamingStatsEntry, type ShortTermRecallEntry, } from "./short-term-promotion-types.js"; -export { normalizeShortTermPhaseSignalStore } from "./short-term-promotion-store.js"; -export { normalizeShortTermRecallStore } from "./short-term-promotion-utils.js"; export { filterFreshLightDreamingEntries, loadShortTermPromotionDreamingStats, diff --git a/extensions/memory-core/src/test-helpers.ts b/extensions/memory-core/src/test-helpers.ts index 77a29a293a22..4d2aef718ac5 100644 --- a/extensions/memory-core/src/test-helpers.ts +++ b/extensions/memory-core/src/test-helpers.ts @@ -25,11 +25,9 @@ import { writeMemoryCoreWorkspaceEntries, writeMemoryCoreWorkspaceEntry, } from "./dreaming-state.js"; -import { - normalizeShortTermPhaseSignalStore, - normalizeShortTermRecallStore, - type ShortTermRecallEntry, -} from "./short-term-promotion.js"; +import { normalizeShortTermPhaseSignalStore } from "./short-term-promotion-store.js"; +import { normalizeShortTermRecallStore } from "./short-term-promotion-utils.js"; +import type { ShortTermRecallEntry } from "./short-term-promotion.js"; const MEMORY_CORE_PLUGIN_ID = "memory-core"; diff --git a/extensions/memory-wiki/src/config.ts b/extensions/memory-wiki/src/config.ts index b24bcc903364..573a4a64ea2c 100644 --- a/extensions/memory-wiki/src/config.ts +++ b/extensions/memory-wiki/src/config.ts @@ -1,8 +1,13 @@ // Memory Wiki helper module supports config behavior. import os from "node:os"; import path from "node:path"; +// agent-scope-runtime exports the same resolvers without memory-host-core's +// event-store/kysely graph, which doctor enumeration must not cold-load. +import { + resolveDefaultAgentId, + resolveSessionAgentId, +} from "openclaw/plugin-sdk/agent-scope-runtime"; import { mapPluginConfigIssues } from "openclaw/plugin-sdk/extension-shared"; -import { resolveDefaultAgentId, resolveSessionAgentId } from "openclaw/plugin-sdk/memory-host-core"; import { buildPluginConfigSchema, z, type OpenClawPluginConfigSchema } from "../api.js"; import type { OpenClawConfig } from "../api.js"; diff --git a/extensions/msteams/doctor-contract-api.ts b/extensions/msteams/doctor-contract-api.ts index 6cf7b47f2162..afb69ab1f21b 100644 --- a/extensions/msteams/doctor-contract-api.ts +++ b/extensions/msteams/doctor-contract-api.ts @@ -13,7 +13,6 @@ import { defineChannelAliasMigration, type PluginDoctorStateMigration, } from "openclaw/plugin-sdk/runtime-doctor-migrations"; -import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { normalizeStoredConversationId } from "./src/conversation-store-helpers.js"; import { @@ -164,10 +163,13 @@ function listAgentIds(config: OpenClawConfig): string[] { return [...ids]; } -function listCandidateStorePaths(params: { +async function listCandidateStorePaths(params: { config: Parameters[0]["config"]; env: NodeJS.ProcessEnv; -}): string[] { +}): Promise { + // Doctor enumeration cold-loads this closure; session-store-runtime pulls the + // session-accessor/kysely graph, so it stays behind a lazy import here. + const { resolveStorePath } = await import("openclaw/plugin-sdk/session-store-runtime"); const paths = new Set(); for (const agentId of listAgentIds(params.config)) { paths.add(resolveStorePath(params.config.session?.store, { agentId, env: params.env })); @@ -601,7 +603,9 @@ export const stateMigrations: PluginDoctorStateMigration[] = [ async detectLegacyState(params) { const files = ( await Promise.all( - listCandidateStorePaths(params).map((storePath) => listLegacyLearningFiles(storePath)), + ( + await listCandidateStorePaths(params) + ).map((storePath) => listLegacyLearningFiles(storePath)), ) ).flat(); if (files.length === 0) { @@ -618,7 +622,9 @@ export const stateMigrations: PluginDoctorStateMigration[] = [ const warnings: string[] = []; const files = ( await Promise.all( - listCandidateStorePaths(params).map((storePath) => listLegacyLearningFiles(storePath)), + ( + await listCandidateStorePaths(params) + ).map((storePath) => listLegacyLearningFiles(storePath)), ) ).flat(); const store = params.context.openPluginStateKeyedStore({ diff --git a/extensions/reef/src/audit-state.ts b/extensions/reef/src/audit-state.ts index 784373a1021f..8246e5b6621a 100644 --- a/extensions/reef/src/audit-state.ts +++ b/extensions/reef/src/audit-state.ts @@ -3,12 +3,14 @@ import { setTimeout as sleep } from "node:timers/promises"; import { randomBytes } from "@noble/hashes/utils.js"; import type { PluginRuntime } from "openclaw/plugin-sdk/core"; import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime"; +// Import from the defining module, not the protocol barrel: index.js re-exports +// guard-adapters, whose provider-http graph doctor enumeration must not cold-load. import { createAuditEntry, verifyChainSegment, type AuditEntry, type AuditStore, -} from "../protocol/index.js"; +} from "../protocol/audit.js"; export const REEF_AUDIT_NAMESPACE = "audit"; export const REEF_AUDIT_HEAD_NAMESPACE = "audit-head"; diff --git a/extensions/reef/src/doctor-durable-state.ts b/extensions/reef/src/doctor-durable-state.ts index e738c7e6962b..78e9a76db84b 100644 --- a/extensions/reef/src/doctor-durable-state.ts +++ b/extensions/reef/src/doctor-durable-state.ts @@ -6,13 +6,11 @@ import { type PluginDoctorStateMigration, } from "openclaw/plugin-sdk/runtime-doctor-migrations"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { - verifyChain, - verifyChainSegment, - type AuditEntry, - type ReviewRequest, - type SignedReceipt, -} from "../protocol/index.js"; +// Import from defining modules, not the protocol barrel: index.js re-exports +// guard-adapters, whose provider-http graph doctor enumeration must not cold-load. +import { verifyChain, verifyChainSegment, type AuditEntry } from "../protocol/audit.js"; +import type { ReviewRequest } from "../protocol/pipeline.js"; +import type { SignedReceipt } from "../protocol/receipts.js"; import { legacyReefFileExists, REEF_DURABLE_LEGACY_FILENAMES, diff --git a/extensions/reef/src/state.ts b/extensions/reef/src/state.ts index cca4a7ea9cf8..29f0ceffd82c 100644 --- a/extensions/reef/src/state.ts +++ b/extensions/reef/src/state.ts @@ -3,24 +3,21 @@ import { gcm } from "@noble/ciphers/aes.js"; import { concatBytes, randomBytes } from "@noble/hashes/utils.js"; import type { PluginRuntime } from "openclaw/plugin-sdk/core"; import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime"; +// Import from defining modules, not the protocol barrel: index.js re-exports +// guard-adapters, whose provider-http graph doctor enumeration must not cold-load. +import { canonicalBytes } from "../protocol/canonical.js"; +import { base64, base64url, decodeUtf8, fromBase64, fromBase64url } from "../protocol/encoding.js"; import { - base64, - base64url, - canonicalBytes, - decodeUtf8, - fromBase64, - fromBase64url, - generateIdentity, REEF_ENVELOPE_MAX_AGE_SECONDS, validateMessageBody, type CompletedReplay, type MessageBody, type ReplayClaim, type ReplayStore, - type ReviewApproval, - type ReviewRequest, - type SignedReceipt, -} from "../protocol/index.js"; +} from "../protocol/envelope.js"; +import { generateIdentity } from "../protocol/identity.js"; +import type { ReviewApproval, ReviewRequest } from "../protocol/pipeline.js"; +import type { SignedReceipt } from "../protocol/receipts.js"; import { openReefAuditStore } from "./audit-state.js"; import { loadReefIdentityBinding, type ReefIdentityBinding } from "./registration-state.js"; import type { ReefKeys } from "./types.js"; diff --git a/extensions/slack/src/streaming-compat.ts b/extensions/slack/src/streaming-compat.ts index c63e15e28561..f3dcc39bb2d0 100644 --- a/extensions/slack/src/streaming-compat.ts +++ b/extensions/slack/src/streaming-compat.ts @@ -1,8 +1,10 @@ // Slack plugin module implements streaming compat behavior. +// channel-streaming-config exports the same helpers without channel-outbound's +// reply-pipeline/channel-registry graph, which doctor enumeration cold-loads. import { getChannelStreamingConfigObject, resolveChannelStreamingNativeTransport, -} from "openclaw/plugin-sdk/channel-outbound"; +} from "openclaw/plugin-sdk/channel-streaming-config"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, diff --git a/extensions/tsconfig.package-boundary.paths.json b/extensions/tsconfig.package-boundary.paths.json index ba4a530b6bfd..4a461631003f 100644 --- a/extensions/tsconfig.package-boundary.paths.json +++ b/extensions/tsconfig.package-boundary.paths.json @@ -362,6 +362,9 @@ "openclaw/plugin-sdk/realtime-voice-audio-queue": [ "../packages/plugin-sdk/dist/src/plugin-sdk/realtime-voice-audio-queue.d.ts" ], + "openclaw/plugin-sdk/realtime-voice-activation": [ + "../packages/plugin-sdk/dist/src/plugin-sdk/realtime-voice-activation.d.ts" + ], "openclaw/plugin-sdk/realtime-voice": [ "../packages/plugin-sdk/dist/src/plugin-sdk/realtime-voice.d.ts" ], diff --git a/extensions/workboard/doctor-contract-api.ts b/extensions/workboard/doctor-contract-api.ts index 1782508cfd34..0756d4333cf6 100644 --- a/extensions/workboard/doctor-contract-api.ts +++ b/extensions/workboard/doctor-contract-api.ts @@ -10,7 +10,8 @@ import type { PersistedWorkboardNotificationSubscription, WorkboardKeyedStore, } from "./src/persistence-types.js"; -import { createWorkboardSqliteStores, resolveWorkboardSqlitePath } from "./src/sqlite-store.js"; +// Doctor enumeration cold-loads this closure; sqlite-store pulls the +// plugin-state-runtime/kysely graph, so it stays behind lazy imports below. const MAX_CARDS = 2000; @@ -176,6 +177,7 @@ export const stateMigrations: PluginDoctorStateMigration[] = [ id: "workboard-28-kv-to-sqlite", label: "Workboard .28 plugin-state KV", async detectLegacyState(params) { + const { resolveWorkboardSqlitePath } = await import("./src/sqlite-store.js"); const env = migrationEnv(params); const cards = await openLegacyStore({ context: params.context, @@ -212,6 +214,7 @@ export const stateMigrations: PluginDoctorStateMigration[] = [ }; }, async migrateLegacyState(params) { + const { createWorkboardSqliteStores } = await import("./src/sqlite-store.js"); const env = migrationEnv(params); const cards = openLegacyStore({ context: params.context, diff --git a/extensions/xai/tsconfig.json b/extensions/xai/tsconfig.json index 24cd5c4b6c22..fe8173c9f426 100644 --- a/extensions/xai/tsconfig.json +++ b/extensions/xai/tsconfig.json @@ -359,6 +359,9 @@ "openclaw/plugin-sdk/realtime-voice-audio-queue": [ "../../packages/plugin-sdk/dist/src/plugin-sdk/realtime-voice-audio-queue.d.ts" ], + "openclaw/plugin-sdk/realtime-voice-activation": [ + "../../packages/plugin-sdk/dist/src/plugin-sdk/realtime-voice-activation.d.ts" + ], "openclaw/plugin-sdk/realtime-voice": [ "../../packages/plugin-sdk/dist/src/plugin-sdk/realtime-voice.d.ts" ], diff --git a/extensions/zalouser/doctor-contract-api.ts b/extensions/zalouser/doctor-contract-api.ts index f8db087ac3c4..b1f75d8b58cb 100644 --- a/extensions/zalouser/doctor-contract-api.ts +++ b/extensions/zalouser/doctor-contract-api.ts @@ -8,13 +8,9 @@ import { archiveLegacyStateSource, type PluginDoctorStateMigration, } from "openclaw/plugin-sdk/runtime-doctor-migrations"; -import { - deleteSessionEntry, - deliveryContextFromSession, - listSessionEntries, - resolveStorePath, - upsertSessionEntry, -} from "openclaw/plugin-sdk/session-store-runtime"; +// Doctor enumeration cold-loads this closure; session-store-runtime pulls the +// session-accessor/kysely graph, so values load lazily inside async bodies. +import type { listSessionEntries } from "openclaw/plugin-sdk/session-store-runtime"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveZalouserDmSessionScope } from "./src/session-scope.js"; import { @@ -82,11 +78,13 @@ async function collectLegacyZalouserCredentialSources( .toSorted((left, right) => left.profile.localeCompare(right.profile)); } -function collectLegacyZalouserDmEntries( +async function collectLegacyZalouserDmEntries( config: OpenClawConfig, env: NodeJS.ProcessEnv, options: { readOnly?: boolean } = {}, -): LegacyZalouserDmEntry[] { +): Promise { + const { deliveryContextFromSession, listSessionEntries, resolveStorePath } = + await import("openclaw/plugin-sdk/session-store-runtime"); const entries = new Map(); const fallbackAccountId = config.channels?.zalouser?.defaultAccount?.trim() || "default"; const agentIds = new Set([ @@ -243,14 +241,16 @@ export const stateMigrations: PluginDoctorStateMigration[] = [ ) { return null; } - const pending = collectLegacyZalouserDmEntries(config, env, { readOnly: true }); + const pending = await collectLegacyZalouserDmEntries(config, env, { readOnly: true }); const count = pending.flatMap(({ legacyKeys }) => legacyKeys).length; return count > 0 ? { preview: [`- Zalo Personal direct-message session keys: ${count} legacy row(s)`] } : null; }, async migrateLegacyState({ config, env }) { - const pending = collectLegacyZalouserDmEntries(config, env); + const { deleteSessionEntry, upsertSessionEntry } = + await import("openclaw/plugin-sdk/session-store-runtime"); + const pending = await collectLegacyZalouserDmEntries(config, env); const warnings: string[] = []; let migrated = 0; for (const entry of pending) { diff --git a/package.json b/package.json index d72a91d7ee5a..087e38e61adf 100644 --- a/package.json +++ b/package.json @@ -174,6 +174,7 @@ "!dist/plugin-sdk/realtime-bootstrap-context.d.ts", "!dist/plugin-sdk/realtime-transcription.d.ts", "!dist/plugin-sdk/realtime-voice-audio-queue.d.ts", + "!dist/plugin-sdk/realtime-voice-activation.d.ts", "!dist/plugin-sdk/realtime-voice.d.ts", "!dist/plugin-sdk/reply-payload-testing.js", "!dist/plugin-sdk/reply-payload-testing.d.ts", @@ -431,6 +432,10 @@ "types": "./dist/plugin-sdk/channel-streaming.d.ts", "default": "./dist/plugin-sdk/channel-streaming.js" }, + "./plugin-sdk/channel-streaming-config": { + "types": "./dist/plugin-sdk/channel-streaming-config.d.ts", + "default": "./dist/plugin-sdk/channel-streaming-config.js" + }, "./plugin-sdk/setup-tools": { "types": "./dist/plugin-sdk/setup-tools.d.ts", "default": "./dist/plugin-sdk/setup-tools.js" @@ -1141,6 +1146,9 @@ "./plugin-sdk/realtime-voice-audio-queue": { "default": "./dist/plugin-sdk/realtime-voice-audio-queue.js" }, + "./plugin-sdk/realtime-voice-activation": { + "default": "./dist/plugin-sdk/realtime-voice-activation.js" + }, "./plugin-sdk/realtime-voice": { "default": "./dist/plugin-sdk/realtime-voice.js" }, diff --git a/packages/memory-host-sdk/src/host/memory-recall-metadata.ts b/packages/memory-host-sdk/src/host/memory-recall-metadata.ts index dae819253073..b89d1af0de1b 100644 --- a/packages/memory-host-sdk/src/host/memory-recall-metadata.ts +++ b/packages/memory-host-sdk/src/host/memory-recall-metadata.ts @@ -1,6 +1,6 @@ import type { DatabaseSync } from "node:sqlite"; import { INVALID_PROJECT_ANNOTATION_KEY } from "./internal.js"; -import { executeSqliteQuerySync, getNodeSqliteKysely } from "./openclaw-runtime-sqlite.js"; +import { executeSqliteQuerySync, getNodeSqliteKysely } from "./openclaw-runtime-kysely.js"; type MemoryRecallMetadataDatabase = { memory_index_chunks: { diff --git a/packages/memory-host-sdk/src/host/openclaw-runtime-kysely.ts b/packages/memory-host-sdk/src/host/openclaw-runtime-kysely.ts new file mode 100644 index 000000000000..1964afb28806 --- /dev/null +++ b/packages/memory-host-sdk/src/host/openclaw-runtime-kysely.ts @@ -0,0 +1,4 @@ +// Narrow core bridge for Kysely-backed sync query helpers. Split from +// openclaw-runtime-sqlite so schema/transaction consumers stay off the kysely +// value graph, which doctor/setup control-plane paths must not cold-load. +export { executeSqliteQuerySync, getNodeSqliteKysely } from "../../../../src/infra/kysely-sync.js"; diff --git a/packages/memory-host-sdk/src/host/openclaw-runtime-sqlite.ts b/packages/memory-host-sdk/src/host/openclaw-runtime-sqlite.ts index 4f6979fb08fe..4ed9ce682011 100644 --- a/packages/memory-host-sdk/src/host/openclaw-runtime-sqlite.ts +++ b/packages/memory-host-sdk/src/host/openclaw-runtime-sqlite.ts @@ -1,5 +1,6 @@ // Narrow core bridge for shared SQLite schema migration primitives. +// Kysely-backed query helpers live in openclaw-runtime-kysely.ts so this +// bridge stays off the kysely value graph for schema/transaction consumers. export { migrateSqliteSchemaToStrict } from "../../../../src/infra/sqlite-strict.js"; export { runSqliteImmediateTransactionSync } from "../../../../src/infra/sqlite-transaction.js"; -export { executeSqliteQuerySync, getNodeSqliteKysely } from "../../../../src/infra/kysely-sync.js"; diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 697a4ff042c3..2d7f11a9ed98 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -13,6 +13,7 @@ "setup-runtime", "channel-setup", "channel-streaming", + "channel-streaming-config", "setup-tools", "archive", "root-walk", @@ -235,6 +236,7 @@ "realtime-transcription", "realtime-bootstrap-context", "realtime-voice-audio-queue", + "realtime-voice-activation", "realtime-voice", "meeting-runtime", "transcripts", diff --git a/scripts/lib/plugin-sdk-private-local-only-subpaths.json b/scripts/lib/plugin-sdk-private-local-only-subpaths.json index 460048dbd217..2e982ce0e635 100644 --- a/scripts/lib/plugin-sdk-private-local-only-subpaths.json +++ b/scripts/lib/plugin-sdk-private-local-only-subpaths.json @@ -128,6 +128,7 @@ "realtime-bootstrap-context", "realtime-transcription", "realtime-voice-audio-queue", + "realtime-voice-activation", "realtime-voice", "reply-payload-testing", "reply-reference", diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 73f4a9710dd6..ae1cd19fd4d9 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -167,7 +167,9 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +2: restore the documented session-catalog and tool-results plugin contracts. // +1: focused inbound-event delivery correlation for channel plugins. // +1: dependency-light agent scope helpers for doctor migration enumeration. - 150, + // +1: dependency-light channel streaming config readers for doctor closures + // (realtime-voice-activation is private-local and not counted here). + 151, env, ), publicExports: readPluginSdkSurfaceBudgetEnv( @@ -228,7 +230,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +1: simple channel secret contract factory replacing repeated collectors. // +4: focused agent scope functions for doctor migration enumeration. // +1: shared transcript credential-safety prompt for plugin-owned agent harnesses. - 4840, + // +3: channel streaming config reader re-exports and session-agent scope resolver. + 4843, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( @@ -280,7 +283,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +1: shared channel interactive dispatcher with canonical binding authorization. // +1: simple channel secret contract factory replacing repeated collectors. // +4: focused agent scope functions for doctor migration enumeration. - 2916, + // +3: channel streaming config reader functions and session-agent scope resolver. + 2919, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/infra/kysely-sync-cache-state.ts b/src/infra/kysely-sync-cache-state.ts new file mode 100644 index 000000000000..94cf0c65f7a1 --- /dev/null +++ b/src/infra/kysely-sync-cache-state.ts @@ -0,0 +1,20 @@ +// Shared per-database Kysely cache state, split from kysely-sync so lifecycle +// owners (sqlite-transaction) can clear caches without value-loading kysely. +// Doctor/setup closures cold-load transaction consumers; keep this file +// dependency-free beyond node:sqlite types. +import type { DatabaseSync } from "node:sqlite"; + +export const kyselyByDatabase = new WeakMap(); +export const queryErrorHandlerByDatabase = new WeakMap void>(); +// Cached statements retain their database. Per-instance lifecycle wrappers clear +// both caches before the native database handle closes. +export const statementCacheSymbol = Symbol("openclaw.kyselySyncStatementCache"); + +/** Drop cached Kysely state for a DatabaseSync. */ +export function clearNodeSqliteKyselyCacheForDatabase(db: DatabaseSync): void { + // Delete the database-owned cache before close so statements release their + // native database backreferences instead of recreating the WeakMap leak. + delete (db as DatabaseSync & { [statementCacheSymbol]?: unknown })[statementCacheSymbol]; + kyselyByDatabase.delete(db); + queryErrorHandlerByDatabase.delete(db); +} diff --git a/src/infra/kysely-sync.ts b/src/infra/kysely-sync.ts index 7321b14fab12..fcfab677fe2e 100644 --- a/src/infra/kysely-sync.ts +++ b/src/infra/kysely-sync.ts @@ -2,16 +2,18 @@ import type { DatabaseSync, SQLInputValue, StatementSync } from "node:sqlite"; import type { Compilable, CompiledQuery, Kysely, QueryResult } from "kysely"; import { InsertQueryNode, Kysely as KyselyInstance, SqliteDialect } from "kysely"; +import { + clearNodeSqliteKyselyCacheForDatabase, + kyselyByDatabase, + queryErrorHandlerByDatabase, + statementCacheSymbol, +} from "./kysely-sync-cache-state.js"; import { pruneMapToMaxSize } from "./map-size.js"; // Sync query helpers execute compiled Kysely SQL against node:sqlite without // going through Kysely's async driver path. -const kyselyByDatabase = new WeakMap>(); -const queryErrorHandlerByDatabase = new WeakMap void>(); -// Cached statements retain their database. Per-instance lifecycle wrappers clear -// both caches before the native database handle closes. -const statementCacheSymbol = Symbol("openclaw.kyselySyncStatementCache"); +export { clearNodeSqliteKyselyCacheForDatabase } from "./kysely-sync-cache-state.js"; const statementInvalidationSymbol = Symbol("openclaw.kyselySyncStatementInvalidation"); const statementCacheEnabledSymbol = Symbol("openclaw.kyselySyncStatementCacheEnabled"); const authorizerActiveSymbol = Symbol("openclaw.kyselySyncAuthorizerActive"); @@ -45,7 +47,7 @@ const compileOnlySqliteDialect = new SqliteDialect({ }); export function getNodeSqliteKysely(db: DatabaseSync): Kysely { - const existing = kyselyByDatabase.get(db); + const existing = kyselyByDatabase.get(db) as Kysely | undefined; if (existing) { return existing as Kysely; } @@ -313,12 +315,3 @@ export function executeSqliteQueryTakeFirstSync( ): Row | undefined { return executeSqliteQuerySync(db, query).rows[0]; } - -/** Drop cached Kysely state for a DatabaseSync. */ -export function clearNodeSqliteKyselyCacheForDatabase(db: DatabaseSync): void { - // Delete the database-owned cache before close so statements release their - // native database backreferences instead of recreating the WeakMap leak. - delete (db as StatementCacheOwner)[statementCacheSymbol]; - kyselyByDatabase.delete(db); - queryErrorHandlerByDatabase.delete(db); -} diff --git a/src/infra/sqlite-transaction.ts b/src/infra/sqlite-transaction.ts index 2b7a6790d484..c9565bd1b63c 100644 --- a/src/infra/sqlite-transaction.ts +++ b/src/infra/sqlite-transaction.ts @@ -1,7 +1,9 @@ // Provides SQLite transaction helpers with nested savepoints. import type { DatabaseSync } from "node:sqlite"; import { createSubsystemLogger, type SubsystemLogger } from "../logging/subsystem.js"; -import { clearNodeSqliteKyselyCacheForDatabase } from "./kysely-sync.js"; +// The cache-state module keeps this lifecycle edge off the kysely value graph +// so cold control-plane paths using transactions do not load kysely. +import { clearNodeSqliteKyselyCacheForDatabase } from "./kysely-sync-cache-state.js"; const transactionDepthByDatabase = new WeakMap(); diff --git a/src/plugin-sdk/agent-scope-runtime.ts b/src/plugin-sdk/agent-scope-runtime.ts index df17d20c42fd..270f5bd4a0b2 100644 --- a/src/plugin-sdk/agent-scope-runtime.ts +++ b/src/plugin-sdk/agent-scope-runtime.ts @@ -4,5 +4,6 @@ export { listAgentIds, resolveAgentDir, resolveDefaultAgentId, + resolveSessionAgentId, resolveSessionAgentIds, } from "../agents/agent-scope.js"; diff --git a/src/plugin-sdk/channel-streaming-config.ts b/src/plugin-sdk/channel-streaming-config.ts new file mode 100644 index 000000000000..d95df7c71cc1 --- /dev/null +++ b/src/plugin-sdk/channel-streaming-config.ts @@ -0,0 +1,12 @@ +/** + * Dependency-light channel streaming config readers. + * + * Doctor contract closures (e.g. Slack's streaming compat rules) need these + * pure config helpers; the broad `channel-outbound` barrel also value-loads + * the reply-pipeline/channel-registry graph, which doctor enumeration must + * not cold-load, and `channel-streaming` is a deprecated compat barrel. + */ +export { + getChannelStreamingConfigObject, + resolveChannelStreamingNativeTransport, +} from "../channels/streaming.js"; diff --git a/src/plugin-sdk/realtime-voice-activation.ts b/src/plugin-sdk/realtime-voice-activation.ts new file mode 100644 index 000000000000..70cfdc09b915 --- /dev/null +++ b/src/plugin-sdk/realtime-voice-activation.ts @@ -0,0 +1,20 @@ +/** + * Dependency-light realtime-voice activation-name helpers. + * + * Doctor contract closures (e.g. Discord's wake-name migrations) need these + * pure helpers; the broad `realtime-voice` barrel also value-loads the agent + * consult runtime and session graphs, which enumeration must not cold-load. + */ +export { + REALTIME_VOICE_ACTIVATION_NAME_MAX_WORDS, + isSupportedRealtimeVoiceActivationName, + matchRealtimeVoiceActivationName, + normalizeRealtimeVoiceActivationName, + normalizeRealtimeVoiceActivationNamePrefix, + normalizeSupportedRealtimeVoiceActivationName, + realtimeVoiceActivationNameWordCount, + sortRealtimeVoiceActivationNames, + type RealtimeVoiceActivationNameEdge, + type RealtimeVoiceActivationNameMatchKind, + type RealtimeVoiceActivationNameTranscriptResult, +} from "../talk/activation-name.js"; diff --git a/src/plugins/contracts/extension-package-project-boundaries.test.ts b/src/plugins/contracts/extension-package-project-boundaries.test.ts index 3e04082fd9e5..5bcf81488e52 100644 --- a/src/plugins/contracts/extension-package-project-boundaries.test.ts +++ b/src/plugins/contracts/extension-package-project-boundaries.test.ts @@ -59,6 +59,7 @@ const MEMORY_HOST_SDK_EXPORTS = [ const MEMORY_HOST_SDK_ALLOWED_CORE_BRIDGE_FILES = [ "packages/memory-host-sdk/src/host/config-utils.ts", "packages/memory-host-sdk/src/host/openclaw-runtime-auth.ts", + "packages/memory-host-sdk/src/host/openclaw-runtime-kysely.ts", "packages/memory-host-sdk/src/host/openclaw-runtime-network.ts", "packages/memory-host-sdk/src/host/openclaw-runtime-sqlite.ts", "packages/memory-host-sdk/src/host/openclaw-runtime.ts", diff --git a/src/plugins/doctor-contract-closure-guard.test.ts b/src/plugins/doctor-contract-closure-guard.test.ts index 4291d3cd3bb7..4a1559391500 100644 --- a/src/plugins/doctor-contract-closure-guard.test.ts +++ b/src/plugins/doctor-contract-closure-guard.test.ts @@ -10,17 +10,74 @@ import { loadBundledPluginManifestRegistry } from "./manifest-registry.js"; const REPO_ROOT = path.resolve(import.meta.dirname, "../.."); const SOURCE_MODULE_EXTENSIONS = [".ts", ".mts", ".cts", ".js", ".mjs", ".cjs"] as const; const FORBIDDEN_SPECIFIER = "openclaw/plugin-sdk/agent-runtime"; +type ClosureKind = "doctor-contract" | "legacy-setup"; // Static value imports only; type-only and lazy dynamic imports of these stay allowed. -const FORBIDDEN_SPECIFIER_REASONS = new Map([ +// Rules scoped to "doctor-contract" protect doctor enumeration cold-load cost; +// legacy-setup closures (telegram sent-message-cache, discord thread-bindings.state) +// still share sync runtime modules with these barrels and are a named follow-up. +const FORBIDDEN_SPECIFIER_RULES = new Map }>([ [ FORBIDDEN_SPECIFIER, - "the deprecated broad barrel makes doctor enumeration cold-load the core agents graph; " + - "use openclaw/plugin-sdk/agent-scope-runtime or another focused subpath", + { + reason: + "the deprecated broad barrel makes doctor enumeration cold-load the core agents graph; " + + "use openclaw/plugin-sdk/agent-scope-runtime or another focused subpath", + kinds: new Set(["doctor-contract", "legacy-setup"]), + }, ], [ "openclaw/plugin-sdk/runtime-doctor", - "the heavy doctor barrel makes doctor enumeration cold-load the state-db/kysely graph; " + - "use openclaw/plugin-sdk/runtime-doctor-migrations, or defer heavy helpers behind a dynamic import", + { + reason: + "the heavy doctor barrel makes doctor enumeration cold-load the state-db/kysely graph; " + + "use openclaw/plugin-sdk/runtime-doctor-migrations, or defer heavy helpers behind a dynamic import", + kinds: new Set(["doctor-contract", "legacy-setup"]), + }, + ], + [ + "openclaw/plugin-sdk/session-store-runtime", + { + reason: + "the session-store barrel makes doctor enumeration cold-load the session-accessor/kysely graph; " + + "defer it behind a dynamic import inside async migration bodies", + kinds: new Set(["doctor-contract"]), + }, + ], + [ + "openclaw/plugin-sdk/logging-core", + { + reason: + "the logging barrel makes doctor enumeration cold-load the diagnostic/config graph; " + + "use openclaw/plugin-sdk/security-runtime for redaction helpers", + kinds: new Set(["doctor-contract", "legacy-setup"]), + }, + ], + [ + "openclaw/plugin-sdk/realtime-voice", + { + reason: + "the realtime-voice barrel makes doctor enumeration cold-load the agent-consult/session graph; " + + "use openclaw/plugin-sdk/realtime-voice-activation for activation-name helpers", + kinds: new Set(["doctor-contract", "legacy-setup"]), + }, + ], + [ + "openclaw/plugin-sdk/channel-outbound", + { + reason: + "the channel-outbound barrel makes doctor enumeration cold-load the reply-pipeline/channel-registry graph; " + + "use openclaw/plugin-sdk/channel-streaming-config for streaming config helpers", + kinds: new Set(["doctor-contract"]), + }, + ], + [ + "openclaw/plugin-sdk/memory-host-core", + { + reason: + "the memory-host barrel makes doctor enumeration cold-load the event-store/kysely graph; " + + "use openclaw/plugin-sdk/agent-scope-runtime for agent scope resolvers", + kinds: new Set(["doctor-contract", "legacy-setup"]), + }, ], ]); const LEGACY_SETUP_PROPERTIES = new Set([ @@ -33,6 +90,7 @@ type ClosureEntry = { pluginId: string; pluginRoot: string; entryPath: string; + kind: ClosureKind; }; type ModuleReference = ReturnType[number]; @@ -148,7 +206,7 @@ function collectStaticValueReferences(filePath: string, source: string): ModuleR return collectModuleReferencesFromSource(source, { fileName: filePath, acceptSpecifier: (specifier) => - FORBIDDEN_SPECIFIER_REASONS.has(specifier) || specifier.startsWith("."), + FORBIDDEN_SPECIFIER_RULES.has(specifier) || specifier.startsWith("."), }).filter((reference) => staticValueReferenceKeys.has(`${reference.kind}\0${reference.line}\0${reference.specifier}`), ); @@ -164,7 +222,12 @@ function collectClosureEntries(): ClosureEntry[] { const pluginRoot = path.resolve(record.rootDir); const doctorContractPath = resolvePluginDoctorContractArtifactPath(pluginRoot); if (doctorContractPath) { - entries.push({ pluginId: record.id, pluginRoot, entryPath: doctorContractPath }); + entries.push({ + pluginId: record.id, + pluginRoot, + entryPath: doctorContractPath, + kind: "doctor-contract", + }); } if (record.channels.length === 0) { @@ -185,7 +248,7 @@ function collectClosureEntries(): ClosureEntry[] { for (const specifier of collectLegacySetupSpecifiers(setupEntryPath)) { const entryPath = resolveRelativeSourceModule(setupEntryPath, specifier); if (entryPath && isInsideRoot(pluginRoot, entryPath)) { - entries.push({ pluginId: record.id, pluginRoot, entryPath }); + entries.push({ pluginId: record.id, pluginRoot, entryPath, kind: "legacy-setup" }); } } } @@ -205,10 +268,10 @@ function collectForbiddenClosureImports(entry: ClosureEntry): string[] { visited.add(filePath); const source = fs.readFileSync(filePath, "utf8"); for (const reference of collectStaticValueReferences(filePath, source)) { - const reason = FORBIDDEN_SPECIFIER_REASONS.get(reference.specifier); - if (reason) { + const rule = FORBIDDEN_SPECIFIER_RULES.get(reference.specifier); + if (rule?.kinds.has(entry.kind)) { violations.push( - `${entry.pluginId}: ${formatRepoPath(filePath)}:${reference.line} imports ${reference.specifier}; ${reason}`, + `${entry.pluginId}: ${formatRepoPath(filePath)}:${reference.line} imports ${reference.specifier}; ${rule.reason}`, ); continue; }