Rebase session reset hook fix onto latest main

This commit is contained in:
Nuoyao 2026-03-21 13:55:00 +08:00
parent 96e1c37685
commit a4d11aec3e
11 changed files with 763 additions and 79 deletions

View File

@ -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

View File

@ -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:**

View File

@ -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<string, unknown>;
}> = [];
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<string, unknown>;
}> = [];
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-");

View File

@ -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({

View File

@ -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)

View File

@ -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");

View File

@ -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<HookHandler>[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;

View File

@ -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;

View File

@ -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<CreateJiti> | 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({

View File

@ -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<string, PluginRegistry>();
const openAllowlistWarningCache = new Set<string>();
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<string, unknown>;
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<string, unknown>;
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<string, ReturnType<typeof createJiti>>();
const jitiLoaders = new Map<string, ReturnType<CreateJitiFactory>>();
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

View File

@ -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) {