From a4d11aec3e270f32dedc0e7b250c9ad77fc57e07 Mon Sep 17 00:00:00 2001 From: Nuoyao <65593624+Nuoyao@users.noreply.github.com> Date: Sat, 21 Mar 2026 13:55:00 +0800 Subject: [PATCH] Rebase session reset hook fix onto latest main --- docs/automation/hooks.md | 8 +- docs/cli/hooks.md | 8 +- src/auto-reply/reply/session.test.ts | 169 ++++++++++++++ src/auto-reply/reply/session.ts | 56 ++++- src/hooks/bundled/session-memory/HOOK.md | 9 +- .../bundled/session-memory/handler.test.ts | 61 +++-- src/hooks/bundled/session-memory/handler.ts | 23 +- src/plugin-sdk/channel-runtime.cjs | 221 ++++++++++++++++++ src/plugins/loader.test.ts | 210 ++++++++++++++--- src/plugins/loader.ts | 48 +++- src/plugins/sdk-alias.ts | 29 ++- 11 files changed, 763 insertions(+), 79 deletions(-) create mode 100644 src/plugin-sdk/channel-runtime.cjs diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md index 4d7dbd02533..4690c7682c4 100644 --- a/docs/automation/hooks.md +++ b/docs/automation/hooks.md @@ -43,7 +43,7 @@ The hooks system allows you to: OpenClaw ships with four bundled hooks that are automatically discovered: -- **💾 session-memory**: Saves session context to your agent workspace (default `~/.openclaw/workspace/memory/`) when you issue `/new` +- **💾 session-memory**: Saves session context to your agent workspace (default `~/.openclaw/workspace/memory/`) when you issue `/new` or `/reset`, or when the session rotates automatically due to idle/daily reset - **📎 bootstrap-extra-files**: Injects additional workspace bootstrap files from configured glob/path patterns during `agent:bootstrap` - **📝 command-logger**: Logs all command events to `~/.openclaw/logs/commands.log` - **🚀 boot-md**: Runs `BOOT.md` when the gateway starts (requires internal hooks enabled) @@ -250,6 +250,8 @@ Triggered when agent commands are issued: ### Session Events +- **`session:daily_reset`**: When a stale session is rotated by the daily reset policy +- **`session:idle_reset`**: When a stale session is rotated by the idle timeout policy - **`session:compact:before`**: Right before compaction summarizes history - **`session:compact:after`**: After compaction completes with summary metadata @@ -571,9 +573,9 @@ openclaw hooks disable command-logger ### session-memory -Saves session context to memory when you issue `/new`. +Saves session context to memory when you issue `/new` or `/reset`, or when the session rotates automatically after idle/daily reset. -**Events**: `command:new` +**Events**: `command:new`, `command:reset`, `session:idle_reset`, `session:daily_reset` **Requirements**: `workspace.dir` must be configured diff --git a/docs/cli/hooks.md b/docs/cli/hooks.md index 939dac99c66..49e77697d70 100644 --- a/docs/cli/hooks.md +++ b/docs/cli/hooks.md @@ -38,7 +38,7 @@ Ready: 🚀 boot-md ✓ - Run BOOT.md on gateway startup 📎 bootstrap-extra-files ✓ - Inject extra workspace bootstrap files during agent bootstrap 📝 command-logger ✓ - Log all command events to a centralized audit file - 💾 session-memory ✓ - Save session context to memory when /new command is issued + 💾 session-memory ✓ - Save session context to memory when a session is reset manually or automatically ``` **Example (verbose):** @@ -84,14 +84,14 @@ openclaw hooks info session-memory ``` 💾 session-memory ✓ Ready -Save session context to memory when /new command is issued +Save session context to memory when a session is reset manually or automatically Details: Source: openclaw-bundled Path: /path/to/openclaw/hooks/bundled/session-memory/HOOK.md Handler: /path/to/openclaw/hooks/bundled/session-memory/handler.ts Homepage: https://docs.openclaw.ai/automation/hooks#session-memory - Events: command:new + Events: command:new, command:reset, session:idle_reset, session:daily_reset Requirements: Config: ✓ workspace.dir @@ -252,7 +252,7 @@ global `--yes` to bypass prompts in CI/non-interactive runs. ### session-memory -Saves session context to memory when you issue `/new`. +Saves session context to memory when you issue `/new` or `/reset`, or when the session rotates automatically after idle/daily reset. **Enable:** diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 6de8811faee..f7fd9c0c06c 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -6,6 +6,7 @@ import * as bootstrapCache from "../../agents/bootstrap-cache.js"; import { buildModelAliasIndex } from "../../agents/model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; +import { clearInternalHooks, registerInternalHook } from "../../hooks/internal-hooks.js"; import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.ts"; import { __testing as sessionBindingTesting, @@ -863,6 +864,7 @@ describe("initSessionState reset policy", () => { afterEach(() => { clearBootstrapSnapshotOnSessionRolloverSpy.mockRestore(); + clearInternalHooks(); vi.useRealTimers(); }); @@ -895,6 +897,52 @@ describe("initSessionState reset policy", () => { }); }); + it("emits session:daily_reset internal hooks for stale daily sessions", async () => { + vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0)); + const root = await makeCaseDir("openclaw-reset-daily-hook-"); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:whatsapp:dm:daily-hook"; + const existingSessionId = "daily-hook-session-id"; + const captured: Array<{ + action: string; + sessionKey: string; + context: Record; + }> = []; + registerInternalHook("session:daily_reset", async (event) => { + captured.push(event); + }); + + await writeSessionStoreFast(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(), + }, + }); + + const cfg = { + agents: { defaults: { workspace: root } }, + session: { store: storePath }, + } as OpenClawConfig; + const result = await initSessionState({ + ctx: { Body: "hello", SessionKey: sessionKey, Surface: "telegram" }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(captured).toHaveLength(1); + expect(captured[0]).toMatchObject({ + action: "daily_reset", + sessionKey, + context: expect.objectContaining({ + commandSource: "telegram", + workspaceDir: root, + previousSessionEntry: expect.objectContaining({ sessionId: existingSessionId }), + sessionEntry: expect.objectContaining({ sessionId: result.sessionId }), + }), + }); + }); + it("treats sessions as stale before the daily reset when updated before yesterday's boundary", async () => { vi.setSystemTime(new Date(2026, 0, 18, 3, 0, 0)); const root = await makeCaseDir("openclaw-reset-daily-edge-"); @@ -950,6 +998,127 @@ describe("initSessionState reset policy", () => { expect(result.sessionId).not.toBe(existingSessionId); }); + it("emits session:idle_reset internal hooks when idle expiry rotates a session", async () => { + vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0)); + const root = await makeCaseDir("openclaw-reset-idle-hook-"); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:whatsapp:dm:idle-hook"; + const existingSessionId = "idle-hook-session-id"; + const captured: Array<{ + action: string; + sessionKey: string; + context: Record; + }> = []; + registerInternalHook("session:idle_reset", async (event) => { + captured.push(event); + }); + + await writeSessionStoreFast(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: new Date(2026, 0, 18, 4, 45, 0).getTime(), + }, + }); + + const cfg = { + agents: { defaults: { workspace: root } }, + session: { + store: storePath, + reset: { mode: "daily", atHour: 4, idleMinutes: 30 }, + }, + } as OpenClawConfig; + const result = await initSessionState({ + ctx: { Body: "hello", SessionKey: sessionKey, Surface: "telegram" }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(captured).toHaveLength(1); + expect(captured[0]).toMatchObject({ + action: "idle_reset", + sessionKey, + context: expect.objectContaining({ + commandSource: "telegram", + workspaceDir: root, + previousSessionEntry: expect.objectContaining({ sessionId: existingSessionId }), + sessionEntry: expect.objectContaining({ sessionId: result.sessionId }), + }), + }); + }); + + it("emits automatic reset hooks before archiving legacy transcripts", async () => { + vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0)); + const root = await makeCaseDir("openclaw-reset-daily-memory-"); + const storePath = path.join(root, "sessions.json"); + const sessionsDir = path.join(root, "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + + const sessionKey = "agent:main:whatsapp:dm:daily-memory"; + const existingSessionId = "daily-memory-session-id"; + await fs.writeFile( + path.join(sessionsDir, `${existingSessionId}.jsonl`), + [ + JSON.stringify({ + type: "session", + version: 3, + id: existingSessionId, + timestamp: new Date().toISOString(), + cwd: process.cwd(), + }), + JSON.stringify({ + type: "message", + id: "m1", + parentId: null, + timestamp: new Date().toISOString(), + message: { role: "user", content: "Remember this after automatic reset" }, + }), + JSON.stringify({ + type: "message", + id: "m2", + parentId: "m1", + timestamp: new Date().toISOString(), + message: { role: "assistant", content: "Recovered from pre-archive transcript" }, + }), + "", + ].join("\n"), + "utf-8", + ); + + await writeSessionStoreFast(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(), + }, + }); + + const { default: sessionMemoryHandler } = + await import("../../hooks/bundled/session-memory/handler.js"); + registerInternalHook("session:daily_reset", sessionMemoryHandler); + + const cfg = { + agents: { defaults: { workspace: root } }, + session: { store: storePath }, + } as OpenClawConfig; + + await initSessionState({ + ctx: { Body: "hello", SessionKey: sessionKey, Surface: "telegram" }, + cfg, + commandAuthorized: true, + }); + + const memoryDir = path.join(root, "memory"); + const memoryFiles = await fs.readdir(memoryDir); + expect(memoryFiles).toHaveLength(1); + + const memoryContent = await fs.readFile(path.join(memoryDir, memoryFiles[0]), "utf-8"); + expect(memoryContent).toContain("user: Remember this after automatic reset"); + expect(memoryContent).toContain("assistant: Recovered from pre-archive transcript"); + + const sessionFiles = await fs.readdir(sessionsDir); + expect(sessionFiles).toContain(`${existingSessionId}.jsonl`); + }); + it("uses per-type overrides for thread sessions", async () => { vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0)); const root = await makeCaseDir("openclaw-reset-thread-"); diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 6c1b2233c0f..9bbbda4f5ad 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -5,7 +5,7 @@ import { normalizeConversationText, parseTelegramChatIdFromTarget, } from "../../acp/conversation-id.js"; -import { resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "../../agents/agent-scope.js"; import { clearBootstrapSnapshotOnSessionRollover } from "../../agents/bootstrap-cache.js"; import { normalizeChatType } from "../../channels/chat-type.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -30,6 +30,7 @@ import { } from "../../config/sessions.js"; import type { TtsAutoMode } from "../../config/types.tts.js"; import { archiveSessionTranscripts } from "../../gateway/session-utils.fs.js"; +import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; import { resolveConversationIdFromTargets } from "../../infra/outbound/conversation-id.js"; import { deliverSessionMaintenanceWarning } from "../../infra/session-maintenance-warning.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; @@ -52,6 +53,8 @@ import { buildSessionEndHookPayload, buildSessionStartHookPayload } from "./sess const log = createSubsystemLogger("session-init"); +type AutomaticSessionResetAction = "daily_reset" | "idle_reset"; + export type SessionInitResult = { sessionCtx: TemplateContext; sessionEntry: SessionEntry; @@ -166,6 +169,28 @@ function resolveBoundAcpSessionForReset(params: { }); } +function resolveAutomaticSessionResetAction(params: { + updatedAt: number; + now: number; + dailyResetAt?: number; + idleExpiresAt?: number; + resetTriggered: boolean; +}): AutomaticSessionResetAction | undefined { + if (params.resetTriggered) { + return undefined; + } + const staleDaily = + typeof params.dailyResetAt === "number" && params.updatedAt < params.dailyResetAt; + const staleIdle = typeof params.idleExpiresAt === "number" && params.now > params.idleExpiresAt; + if (staleIdle) { + return "idle_reset"; + } + if (staleDaily) { + return "daily_reset"; + } + return undefined; +} + export async function initSessionState(params: { ctx: MsgContext; cfg: OpenClawConfig; @@ -333,9 +358,19 @@ export async function initSessionState(params: { resetType, resetOverride: channelReset, }); - const freshEntry = entry - ? evaluateSessionFreshness({ updatedAt: entry.updatedAt, now, policy: resetPolicy }).fresh - : false; + const freshness = entry + ? evaluateSessionFreshness({ updatedAt: entry.updatedAt, now, policy: resetPolicy }) + : undefined; + const freshEntry = freshness?.fresh ?? false; + const automaticResetAction = entry + ? resolveAutomaticSessionResetAction({ + updatedAt: entry.updatedAt, + now, + dailyResetAt: freshness?.dailyResetAt, + idleExpiresAt: freshness?.idleExpiresAt, + resetTriggered, + }) + : undefined; // Capture the current session entry before any reset so its transcript can be // archived afterward. We need to do this for both explicit resets (/new, /reset) // and for scheduled/daily resets where the session has become stale (!freshEntry). @@ -557,6 +592,19 @@ export async function initSessionState(params: { }, ); + if (automaticResetAction && previousSessionEntry) { + const channelSource = + ctx.OriginatingChannel?.trim() || ctx.Surface?.trim() || ctx.Provider?.trim() || undefined; + const hookEvent = createInternalHookEvent("session", automaticResetAction, sessionKey, { + sessionEntry, + previousSessionEntry, + commandSource: channelSource, + workspaceDir: resolveAgentWorkspaceDir(cfg, agentId), + cfg, + }); + await triggerInternalHook(hookEvent); + } + // Archive old transcript so it doesn't accumulate on disk (#14869). if (previousSessionEntry?.sessionId) { archiveSessionTranscripts({ diff --git a/src/hooks/bundled/session-memory/HOOK.md b/src/hooks/bundled/session-memory/HOOK.md index c963e17b76c..c59d54c60b5 100644 --- a/src/hooks/bundled/session-memory/HOOK.md +++ b/src/hooks/bundled/session-memory/HOOK.md @@ -1,13 +1,13 @@ --- name: session-memory -description: "Save session context to memory when /new or /reset command is issued" +description: "Save session context to memory when a manual or automatic session reset occurs" homepage: https://docs.openclaw.ai/automation/hooks#session-memory metadata: { "openclaw": { "emoji": "💾", - "events": ["command:new", "command:reset"], + "events": ["command:new", "command:reset", "session:idle_reset", "session:daily_reset"], "requires": { "config": ["workspace.dir"] }, "install": [{ "id": "bundled", "kind": "bundled", "label": "Bundled with OpenClaw" }], }, @@ -16,11 +16,12 @@ metadata: # Session Memory Hook -Automatically saves session context to your workspace memory when you issue `/new` or `/reset`. +Automatically saves session context to your workspace memory when you issue `/new` or `/reset`, +or when the session rotates automatically because of idle or daily reset policy. ## What It Does -When you run `/new` or `/reset` to start a fresh session: +When a fresh session starts manually or automatically: 1. **Finds the previous session** - Uses the pre-reset session entry to locate the correct transcript 2. **Extracts conversation** - Reads the last N user/assistant messages from the session (default: 15, configurable) diff --git a/src/hooks/bundled/session-memory/handler.test.ts b/src/hooks/bundled/session-memory/handler.test.ts index fb7e9ca0a4d..f704f9e7171 100644 --- a/src/hooks/bundled/session-memory/handler.test.ts +++ b/src/hooks/bundled/session-memory/handler.test.ts @@ -64,24 +64,21 @@ async function runNewWithPreviousSessionEntry(params: { tempDir: string; previousSessionEntry: { sessionId: string; sessionFile?: string }; cfg?: OpenClawConfig; - action?: "new" | "reset"; + action?: "new" | "reset" | "idle_reset" | "daily_reset"; sessionKey?: string; workspaceDirOverride?: string; }): Promise<{ files: string[]; memoryContent: string }> { - const event = createHookEvent( - "command", - params.action ?? "new", - params.sessionKey ?? "agent:main:main", - { - cfg: - params.cfg ?? - ({ - agents: { defaults: { workspace: params.tempDir } }, - } satisfies OpenClawConfig), - previousSessionEntry: params.previousSessionEntry, - ...(params.workspaceDirOverride ? { workspaceDir: params.workspaceDirOverride } : {}), - }, - ); + const action = params.action ?? "new"; + const eventType = action === "idle_reset" || action === "daily_reset" ? "session" : "command"; + const event = createHookEvent(eventType, action, params.sessionKey ?? "agent:main:main", { + cfg: + params.cfg ?? + ({ + agents: { defaults: { workspace: params.tempDir } }, + } satisfies OpenClawConfig), + previousSessionEntry: params.previousSessionEntry, + ...(params.workspaceDirOverride ? { workspaceDir: params.workspaceDirOverride } : {}), + }); await handler(event); @@ -95,7 +92,7 @@ async function runNewWithPreviousSessionEntry(params: { async function runNewWithPreviousSession(params: { sessionContent: string; cfg?: (tempDir: string) => OpenClawConfig; - action?: "new" | "reset"; + action?: "new" | "reset" | "idle_reset" | "daily_reset"; }): Promise<{ tempDir: string; files: string[]; memoryContent: string }> { const tempDir = await createCaseWorkspace("workspace"); const sessionsDir = path.join(tempDir, "sessions"); @@ -189,7 +186,7 @@ function expectMemoryConversation(params: { } describe("session-memory hook", () => { - it("skips non-command events", async () => { + it("skips unrelated events", async () => { const tempDir = await createCaseWorkspace("workspace"); const event = createHookEvent("agent", "bootstrap", "agent:main:main", { @@ -250,6 +247,36 @@ describe("session-memory hook", () => { expect(memoryContent).toContain("assistant: Captured before reset"); }); + it("creates memory file with session content on session:idle_reset", async () => { + const sessionContent = createMockSessionContent([ + { role: "user", content: "Remember this after idle timeout" }, + { role: "assistant", content: "Recovered after idle reset" }, + ]); + const { files, memoryContent } = await runNewWithPreviousSession({ + sessionContent, + action: "idle_reset", + }); + + expect(files.length).toBe(1); + expect(memoryContent).toContain("user: Remember this after idle timeout"); + expect(memoryContent).toContain("assistant: Recovered after idle reset"); + }); + + it("creates memory file with session content on session:daily_reset", async () => { + const sessionContent = createMockSessionContent([ + { role: "user", content: "Daily rollover note" }, + { role: "assistant", content: "Recovered after daily reset" }, + ]); + const { files, memoryContent } = await runNewWithPreviousSession({ + sessionContent, + action: "daily_reset", + }); + + expect(files.length).toBe(1); + expect(memoryContent).toContain("user: Daily rollover note"); + expect(memoryContent).toContain("assistant: Recovered after daily reset"); + }); + it("prefers workspaceDir from hook context when sessionKey points at main", async () => { const mainWorkspace = await createCaseWorkspace("workspace-main"); const naviWorkspace = await createCaseWorkspace("workspace-navi"); diff --git a/src/hooks/bundled/session-memory/handler.ts b/src/hooks/bundled/session-memory/handler.ts index 32fc36b23f0..cddcb85bf67 100644 --- a/src/hooks/bundled/session-memory/handler.ts +++ b/src/hooks/bundled/session-memory/handler.ts @@ -1,7 +1,7 @@ /** * Session memory hook handler * - * Saves session context to memory when /new or /reset command is triggered + * Saves session context to memory when a session reset is triggered * Creates a new dated memory file with LLM-generated slug */ @@ -28,6 +28,16 @@ import { generateSlugViaLLM } from "../../llm-slug-generator.js"; const log = createSubsystemLogger("hooks/session-memory"); +function isSessionResetEvent(event: Parameters[0]): boolean { + if (event.type === "command") { + return event.action === "new" || event.action === "reset"; + } + if (event.type === "session") { + return event.action === "idle_reset" || event.action === "daily_reset"; + } + return false; +} + function resolveDisplaySessionKey(params: { cfg?: OpenClawConfig; workspaceDir?: string; @@ -194,17 +204,18 @@ async function findPreviousSessionFile(params: { } /** - * Save session context to memory when /new or /reset command is triggered + * Save session context to memory when a manual or automatic reset is triggered */ const saveSessionToMemory: HookHandler = async (event) => { - // Only trigger on reset/new commands - const isResetCommand = event.action === "new" || event.action === "reset"; - if (event.type !== "command" || !isResetCommand) { + if (!isSessionResetEvent(event)) { return; } try { - log.debug("Hook triggered for reset/new command", { action: event.action }); + log.debug("Hook triggered for session reset", { + type: event.type, + action: event.action, + }); const context = event.context || {}; const cfg = context.cfg as OpenClawConfig | undefined; diff --git a/src/plugin-sdk/channel-runtime.cjs b/src/plugin-sdk/channel-runtime.cjs new file mode 100644 index 00000000000..eaa4147dc7e --- /dev/null +++ b/src/plugin-sdk/channel-runtime.cjs @@ -0,0 +1,221 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { createJiti } = require("jiti"); + +let channelRuntime = null; +const jitiLoaders = new Map(); + +function listPluginSdkSubpaths() { + try { + const packageRoot = path.resolve(__dirname, "..", ".."); + const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")); + return Object.keys(pkg.exports || {}) + .filter((key) => key.startsWith("./plugin-sdk/")) + .map((key) => key.slice("./plugin-sdk/".length)) + .filter((subpath) => subpath && !subpath.includes("/")) + .toSorted(); + } catch { + return []; + } +} + +function buildPluginSdkAliasMap() { + const aliasMap = {}; + + for (const subpath of listPluginSdkSubpaths()) { + const sourceWrapper = path.join(__dirname, `${subpath}.cjs`); + const sourceModule = path.join(__dirname, `${subpath}.ts`); + if (subpath === "channel-runtime" && fs.existsSync(sourceModule)) { + aliasMap[`openclaw/plugin-sdk/${subpath}`] = sourceModule; + continue; + } + if (fs.existsSync(sourceWrapper)) { + aliasMap[`openclaw/plugin-sdk/${subpath}`] = sourceWrapper; + continue; + } + if (fs.existsSync(sourceModule)) { + aliasMap[`openclaw/plugin-sdk/${subpath}`] = sourceModule; + } + } + + return aliasMap; +} + +function getJiti() { + if (jitiLoaders.has(false)) { + return jitiLoaders.get(false); + } + + const jiti = createJiti(__filename, { + interopDefault: true, + tryNative: false, + extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"], + alias: buildPluginSdkAliasMap(), + }); + jitiLoaders.set(false, jiti); + return jiti; +} + +function loadChannelRuntime() { + if (channelRuntime) { + return channelRuntime; + } + + channelRuntime = getJiti()(path.join(__dirname, "channel-runtime.ts")); + return channelRuntime; +} + +function tryLoadChannelRuntime() { + try { + return loadChannelRuntime(); + } catch { + return null; + } +} + +function normalizeChatType(raw) { + const value = typeof raw === "string" ? raw.trim().toLowerCase() : ""; + if (!value) { + return undefined; + } + if (value === "direct" || value === "dm") { + return "direct"; + } + if (value === "group") { + return "group"; + } + if (value === "channel") { + return "channel"; + } + return undefined; +} + +const LEGACY_SEND_DEP_KEYS = { + whatsapp: "sendWhatsApp", + telegram: "sendTelegram", + discord: "sendDiscord", + slack: "sendSlack", + signal: "sendSignal", + imessage: "sendIMessage", + matrix: "sendMatrix", + msteams: "sendMSTeams", +}; + +function resolveOutboundSendDep(deps, channelId) { + const dynamic = deps == null ? undefined : deps[channelId]; + if (dynamic !== undefined) { + return dynamic; + } + const legacyKey = LEGACY_SEND_DEP_KEYS[channelId]; + return legacyKey && deps ? deps[legacyKey] : undefined; +} + +const fastExports = { + normalizeChatType, + resolveOutboundSendDep, +}; + +const target = { ...fastExports }; +let runtimeExports = null; + +function shouldResolveRuntime(prop) { + return typeof prop === "string" && prop !== "then"; +} + +function getRuntimeExports() { + const loaded = tryLoadChannelRuntime(); + if (loaded && typeof loaded === "object") { + return loaded; + } + return null; +} + +function getExportValue(prop) { + if (Reflect.has(target, prop)) { + return Reflect.get(target, prop); + } + if (!shouldResolveRuntime(prop)) { + return undefined; + } + const loaded = getRuntimeExports(); + if (!loaded) { + return undefined; + } + return Reflect.get(loaded, prop); +} + +function getExportDescriptor(prop) { + const ownDescriptor = Reflect.getOwnPropertyDescriptor(target, prop); + if (ownDescriptor) { + return ownDescriptor; + } + if (!shouldResolveRuntime(prop)) { + return undefined; + } + + const loaded = getRuntimeExports(); + if (!loaded) { + return undefined; + } + + const descriptor = Reflect.getOwnPropertyDescriptor(loaded, prop); + if (!descriptor) { + return undefined; + } + + return { + ...descriptor, + configurable: true, + }; +} + +runtimeExports = new Proxy(target, { + get(_target, prop, receiver) { + if (Reflect.has(target, prop)) { + return Reflect.get(target, prop, receiver); + } + return getExportValue(prop); + }, + has(_target, prop) { + if (Reflect.has(target, prop)) { + return true; + } + if (!shouldResolveRuntime(prop)) { + return false; + } + const loaded = getRuntimeExports(); + return loaded ? Reflect.has(loaded, prop) : false; + }, + ownKeys() { + const keys = new Set(Reflect.ownKeys(target)); + if (channelRuntime && typeof channelRuntime === "object") { + for (const key of Reflect.ownKeys(channelRuntime)) { + if (!keys.has(key)) { + keys.add(key); + } + } + } + return [...keys]; + }, + getOwnPropertyDescriptor(_target, prop) { + return getExportDescriptor(prop); + }, +}); + +Object.defineProperty(target, "__esModule", { + configurable: true, + enumerable: false, + writable: false, + value: true, +}); +Object.defineProperty(target, "default", { + configurable: true, + enumerable: false, + get() { + return runtimeExports; + }, +}); + +module.exports = runtimeExports; diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index a4bf12fad15..b03507cd137 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1,18 +1,11 @@ +import { execFileSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { createJiti } from "jiti"; import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { withEnv } from "../test-utils/env.js"; -type CreateJiti = typeof import("jiti").createJiti; - -let createJitiPromise: Promise | undefined; - -async function getCreateJiti() { - createJitiPromise ??= import("jiti").then(({ createJiti }) => createJiti); - return createJitiPromise; -} - async function importFreshPluginTestModules() { vi.resetModules(); vi.doUnmock("node:fs"); @@ -3251,24 +3244,42 @@ module.exports = { body: `module.exports = { id: "legacy-root-import", configSchema: (require("openclaw/plugin-sdk").emptyPluginConfigSchema)(), - register() {}, - };`, + register() {}, +};`, }); - const registry = withEnv({ OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins" }, () => - loadOpenClawPlugins({ + const loaderModuleUrl = pathToFileURL( + path.join(process.cwd(), "src", "plugins", "loader.ts"), + ).href; + const script = ` + import { loadOpenClawPlugins } from ${JSON.stringify(loaderModuleUrl)}; + const registry = loadOpenClawPlugins({ cache: false, - workspaceDir: plugin.dir, + workspaceDir: ${JSON.stringify(plugin.dir)}, config: { plugins: { - load: { paths: [plugin.file] }, + load: { paths: [${JSON.stringify(plugin.file)}] }, allow: ["legacy-root-import"], }, }, - }), - ); - const record = registry.plugins.find((entry) => entry.id === "legacy-root-import"); - expect(record?.status).toBe("loaded"); + }); + const record = registry.plugins.find((entry) => entry.id === "legacy-root-import"); + if (!record || record.status !== "loaded") { + console.error(record?.error ?? "legacy-root-import missing"); + process.exit(1); + } + `; + + execFileSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", script], { + cwd: process.cwd(), + env: { + ...process.env, + OPENCLAW_HOME: undefined, + OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", + }, + encoding: "utf-8", + stdio: "pipe", + }); }); it.each([ @@ -3446,6 +3457,29 @@ module.exports = { expect(fs.realpathSync(resolved ?? "")).toBe(fs.realpathSync(fixture.srcFile)); }); + it("prefers source cjs wrappers for scoped plugin-sdk aliases when present", () => { + const fixture = createPluginSdkAliasFixture({ + srcFile: "channel-runtime.ts", + distFile: "channel-runtime.js", + packageExports: { + "./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" }, + }, + }); + const sourceWrapper = path.join(fixture.root, "src", "plugin-sdk", "channel-runtime.cjs"); + fs.writeFileSync(sourceWrapper, 'module.exports = require("./channel-runtime.ts");\n', "utf-8"); + + const resolved = withEnv({ NODE_ENV: undefined }, () => + __testing.resolvePluginSdkAliasFile({ + srcFile: "channel-runtime.ts", + distFile: "channel-runtime.js", + modulePath: path.join(fixture.root, "extensions", "demo", "src", "index.ts"), + }), + ); + + expect(resolved).not.toBeNull(); + expect(fs.realpathSync(resolved ?? "")).toBe(fs.realpathSync(sourceWrapper)); + }); + it("does not derive plugin-sdk subpaths from cwd fallback when package root is not an OpenClaw root", () => { const fixture = createPluginSdkAliasFixture({ packageName: "moltbot", @@ -3560,8 +3594,38 @@ module.exports = { ).toBe(false); }); + it("normalizes common Jiti export shapes for loader-created runtimes", () => { + const createLoader = vi.fn() as unknown as typeof import("jiti").createJiti; + + expect(__testing.resolveCreateJitiExport(createLoader)).toBe(createLoader); + expect(__testing.resolveCreateJitiExport({ createJiti: createLoader })).toBe(createLoader); + expect(__testing.resolveCreateJitiExport({ default: createLoader })).toBe(createLoader); + expect(__testing.resolveCreateJitiExport({ default: { createJiti: createLoader } })).toBe( + createLoader, + ); + expect(__testing.resolveCreateJitiExport({})).toBeNull(); + }); + it("loads source runtime shims through the non-native Jiti boundary", async () => { - const copiedExtensionRoot = path.join(makeTempDir(), "extensions", "discord"); + const jiti = createJiti(import.meta.url, { + ...__testing.buildPluginLoaderJitiOptions(__testing.resolvePluginSdkScopedAliasMap()), + tryNative: false, + }); + const discordChannelRuntime = path.join( + process.cwd(), + "extensions", + "discord", + "src", + "channel.runtime.ts", + ); + + await expect(jiti.import(discordChannelRuntime)).resolves.toMatchObject({ + discordSetupWizard: expect.any(Object), + }); + }, 240_000); + + it("loads copied imessage runtime sources from git-style paths with plugin-sdk aliases (#49806)", async () => { + const copiedExtensionRoot = path.join(makeTempDir(), "extensions", "imessage"); const copiedSourceDir = path.join(copiedExtensionRoot, "src"); const copiedPluginSdkDir = path.join(copiedExtensionRoot, "plugin-sdk"); mkdirSafe(copiedSourceDir); @@ -3571,10 +3635,18 @@ module.exports = { fs.writeFileSync( path.join(copiedSourceDir, "channel.runtime.ts"), `import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import { PAIRING_APPROVED_MESSAGE } from "../runtime-api.js"; -export const syntheticRuntimeMarker = { +export const copiedRuntimeMarker = { resolveOutboundSendDep, + PAIRING_APPROVED_MESSAGE, }; +`, + "utf-8", + ); + fs.writeFileSync( + path.join(copiedExtensionRoot, "runtime-api.ts"), + `export const PAIRING_APPROVED_MESSAGE = "paired"; `, "utf-8", ); @@ -3590,14 +3662,13 @@ export const syntheticRuntimeMarker = { const copiedChannelRuntime = path.join(copiedExtensionRoot, "src", "channel.runtime.ts"); const jitiBaseUrl = pathToFileURL(jitiBaseFile).href; - const createJiti = await getCreateJiti(); const withoutAlias = createJiti(jitiBaseUrl, { ...__testing.buildPluginLoaderJitiOptions({}), tryNative: false, }); - // The production loader uses sync Jiti evaluation, so this boundary should - // follow the same path instead of the async import helper. - expect(() => withoutAlias(copiedChannelRuntime)).toThrow(); + await expect(withoutAlias.import(copiedChannelRuntime)).rejects.toThrow( + /plugin-sdk\/channel-runtime/, + ); const withAlias = createJiti(jitiBaseUrl, { ...__testing.buildPluginLoaderJitiOptions({ @@ -3605,12 +3676,95 @@ export const syntheticRuntimeMarker = { }), tryNative: false, }); - expect(withAlias(copiedChannelRuntime)).toMatchObject({ - syntheticRuntimeMarker: { + await expect(withAlias.import(copiedChannelRuntime)).resolves.toMatchObject({ + copiedRuntimeMarker: { + PAIRING_APPROVED_MESSAGE: "paired", resolveOutboundSendDep: expect.any(Function), }, }); - }, 240_000); + }); + + it("loads git-style package extension entries through the plugin loader when they import plugin-sdk channel-runtime (#49806)", () => { + useNoBundledPlugins(); + const pluginId = "imessage-loader-regression"; + const gitExtensionRoot = path.join( + makeTempDir(), + "git-source-checkout", + "extensions", + pluginId, + ); + const gitSourceDir = path.join(gitExtensionRoot, "src"); + mkdirSafe(gitSourceDir); + + fs.writeFileSync( + path.join(gitExtensionRoot, "package.json"), + JSON.stringify( + { + name: `@openclaw/${pluginId}`, + version: "0.0.1", + type: "module", + openclaw: { + extensions: ["./src/index.ts"], + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(gitExtensionRoot, "openclaw.plugin.json"), + JSON.stringify( + { + id: pluginId, + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(gitSourceDir, "channel.runtime.ts"), + `import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; + +export function runtimeProbeType() { + return typeof resolveOutboundSendDep; +} +`, + "utf-8", + ); + fs.writeFileSync( + path.join(gitSourceDir, "index.ts"), + `import { runtimeProbeType } from "./channel.runtime.ts"; + +export default { + id: ${JSON.stringify(pluginId)}, + register() { + if (runtimeProbeType() !== "function") { + throw new Error("channel-runtime import did not resolve"); + } + }, +}; +`, + "utf-8", + ); + + const registry = withEnv({ NODE_ENV: "production", VITEST: undefined }, () => + loadOpenClawPlugins({ + cache: false, + workspaceDir: gitExtensionRoot, + config: { + plugins: { + load: { paths: [gitExtensionRoot] }, + allow: [pluginId], + }, + }, + }), + ); + const record = registry.plugins.find((entry) => entry.id === pluginId); + expect(record?.status).toBe("loaded"); + }); it("loads source TypeScript plugins that route through local runtime shims", () => { const plugin = writePlugin({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 03a1b0810ff..8776a130608 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; +import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { createJiti } from "jiti"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { isChannelConfigured } from "../config/plugin-auto-enable.js"; @@ -93,6 +93,10 @@ export class PluginLoadFailureError extends Error { const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 128; const registryCache = new Map(); const openAllowlistWarningCache = new Set(); +const requireFromLoader = createRequire(import.meta.url); + +type CreateJitiFactory = typeof import("jiti").createJiti; + const LAZY_RUNTIME_REFLECTION_KEYS = [ "version", "config", @@ -121,6 +125,43 @@ function resolveLoaderModulePath(params: LoaderModuleResolveParams = {}): string return params.modulePath ?? fileURLToPath(params.moduleUrl ?? import.meta.url); } +export function resolveCreateJitiExport(moduleExport: unknown): CreateJitiFactory | null { + if (typeof moduleExport === "function") { + return moduleExport as CreateJitiFactory; + } + if (!moduleExport || typeof moduleExport !== "object") { + return null; + } + const record = moduleExport as Record; + if (typeof record.createJiti === "function") { + return record.createJiti as CreateJitiFactory; + } + if (typeof record.default === "function") { + return record.default as CreateJitiFactory; + } + if (!record.default || typeof record.default !== "object") { + return null; + } + const nestedDefault = record.default as Record; + return typeof nestedDefault.createJiti === "function" + ? (nestedDefault.createJiti as CreateJitiFactory) + : null; +} + +let cachedCreateJitiFactory: CreateJitiFactory | null = null; + +function getCreateJiti(): CreateJitiFactory { + if (cachedCreateJitiFactory) { + return cachedCreateJitiFactory; + } + const resolved = resolveCreateJitiExport(requireFromLoader("jiti")); + if (!resolved) { + throw new TypeError("jiti does not expose createJiti()"); + } + cachedCreateJitiFactory = resolved; + return resolved; +} + const resolvePluginSdkAlias = (params: LoaderModuleResolveParams = {}): string | null => resolvePluginSdkAliasFile({ srcFile: "root-alias.cjs", @@ -170,6 +211,7 @@ export const __testing = { buildPluginLoaderAliasMap, listPluginSdkAliasCandidates, listPluginSdkExportedSubpaths, + resolveCreateJitiExport, resolvePluginSdkScopedAliasMap, resolvePluginSdkAliasCandidateOrder, resolvePluginSdkAliasFile, @@ -747,7 +789,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi } // Lazy: avoid creating the Jiti loader when all plugins are disabled (common in unit tests). - const jitiLoaders = new Map>(); + const jitiLoaders = new Map>(); const getJiti = (modulePath: string) => { const tryNative = shouldPreferNativeJiti(modulePath); const aliasMap = buildPluginLoaderAliasMap(modulePath); @@ -759,7 +801,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi if (cached) { return cached; } - const loader = createJiti(import.meta.url, { + const loader = getCreateJiti()(resolveLoaderModulePath(), { ...buildPluginLoaderJitiOptions(aliasMap), // Source .ts runtime shims import sibling ".js" specifiers that only exist // after build. Disable native loading for source entries so Jiti rewrites diff --git a/src/plugins/sdk-alias.ts b/src/plugins/sdk-alias.ts index df8ec526271..77ca3c72621 100644 --- a/src/plugins/sdk-alias.ts +++ b/src/plugins/sdk-alias.ts @@ -145,27 +145,36 @@ export function listPluginSdkAliasCandidates(params: { cwd?: string; moduleUrl?: string; }) { + const srcRelativeCandidates = params.srcFile.endsWith(".ts") + ? [params.srcFile.replace(/\.ts$/u, ".cjs"), params.srcFile] + : [params.srcFile]; const orderedKinds = resolvePluginSdkAliasCandidateOrder({ modulePath: params.modulePath, isProduction: process.env.NODE_ENV === "production", }); const packageRoot = resolveLoaderPluginSdkPackageRoot(params); if (packageRoot) { - const candidateMap = { - src: path.join(packageRoot, "src", "plugin-sdk", params.srcFile), - dist: path.join(packageRoot, "dist", "plugin-sdk", params.distFile), - } as const; - return orderedKinds.map((kind) => candidateMap[kind]); + return orderedKinds.flatMap((kind) => + kind === "src" + ? srcRelativeCandidates.map((candidate) => + path.join(packageRoot, "src", "plugin-sdk", candidate), + ) + : [path.join(packageRoot, "dist", "plugin-sdk", params.distFile)], + ); } let cursor = path.dirname(params.modulePath); const candidates: string[] = []; for (let i = 0; i < 6; i += 1) { - const candidateMap = { - src: path.join(cursor, "src", "plugin-sdk", params.srcFile), - dist: path.join(cursor, "dist", "plugin-sdk", params.distFile), - } as const; for (const kind of orderedKinds) { - candidates.push(candidateMap[kind]); + if (kind === "src") { + candidates.push( + ...srcRelativeCandidates.map((candidate) => + path.join(cursor, "src", "plugin-sdk", candidate), + ), + ); + continue; + } + candidates.push(path.join(cursor, "dist", "plugin-sdk", params.distFile)); } const parent = path.dirname(cursor); if (parent === cursor) {