Rebase session reset hook fix onto latest main
This commit is contained in:
parent
96e1c37685
commit
a4d11aec3e
@ -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
|
||||
|
||||
|
||||
@ -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:**
|
||||
|
||||
|
||||
@ -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-");
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
|
||||
221
src/plugin-sdk/channel-runtime.cjs
Normal file
221
src/plugin-sdk/channel-runtime.cjs
Normal 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;
|
||||
@ -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({
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user