perf(inbound): trim cold startup import graph (#52082)

* perf(inbound): trim cold startup import graph

* chore(reply): drop redundant inline action type import

* fix(inbound): restore warning and maintenance seams

* fix(reply): restore type seam and secure forked transcripts
This commit is contained in:
Vincent Koc
2026-03-21 22:32:21 -07:00
committed by GitHub
parent c96a12aeb9
commit 041f0b87ec
30 changed files with 643 additions and 397 deletions

View File

@@ -1,12 +1,29 @@
import { normalizeChatType } from "openclaw/plugin-sdk/account-resolution";
import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime";
type DiscordSessionKeyContext = {
ChatType?: string;
From?: string;
SenderId?: string;
};
function normalizeDiscordChatType(raw?: string): "direct" | "group" | "channel" | undefined {
const normalized = (raw ?? "").trim().toLowerCase();
if (!normalized) {
return undefined;
}
if (normalized === "dm") {
return "direct";
}
if (normalized === "group" || normalized === "channel" || normalized === "direct") {
return normalized;
}
return undefined;
}
export function normalizeExplicitDiscordSessionKey(
sessionKey: string,
ctx: Pick<MsgContext, "ChatType" | "From" | "SenderId">,
ctx: DiscordSessionKeyContext,
): string {
let normalized = sessionKey.trim().toLowerCase();
if (normalizeChatType(ctx.ChatType) !== "direct") {
if (normalizeDiscordChatType(ctx.ChatType) !== "direct") {
return normalized;
}

View File

@@ -0,0 +1,8 @@
export const MODEL_CONTEXT_TOKEN_CACHE = new Map<string, number>();
export function lookupCachedContextTokens(modelId?: string): number | undefined {
if (!modelId) {
return undefined;
}
return MODEL_CONTEXT_TOKEN_CACHE.get(modelId);
}

View File

@@ -7,6 +7,7 @@ import type { OpenClawConfig } from "../config/config.js";
import { computeBackoff, type BackoffPolicy } from "../infra/backoff.js";
import { consumeRootOptionToken, FLAG_TERMINATOR } from "../infra/cli-root-options.js";
import { resolveOpenClawAgentDir } from "./agent-paths.js";
import { lookupCachedContextTokens, MODEL_CONTEXT_TOKEN_CACHE } from "./context-cache.js";
import { normalizeProviderId } from "./model-selection.js";
type ModelEntry = { id: string; contextWindow?: number };
@@ -78,7 +79,6 @@ export function applyConfiguredContextWindows(params: {
}
}
const MODEL_CACHE = new Map<string, number>();
let loadPromise: Promise<void> | null = null;
let configuredConfig: OpenClawConfig | undefined;
let configLoadFailures = 0;
@@ -169,7 +169,7 @@ function primeConfiguredContextWindows(): OpenClawConfig | undefined {
try {
const cfg = loadConfig();
applyConfiguredContextWindows({
cache: MODEL_CACHE,
cache: MODEL_CONTEXT_TOKEN_CACHE,
modelsConfig: cfg.models as ModelsConfig | undefined,
});
configuredConfig = cfg;
@@ -213,7 +213,7 @@ function ensureContextWindowCacheLoaded(): Promise<void> {
? modelRegistry.getAvailable()
: modelRegistry.getAll();
applyDiscoveredContextWindows({
cache: MODEL_CACHE,
cache: MODEL_CONTEXT_TOKEN_CACHE,
models,
});
} catch {
@@ -221,7 +221,7 @@ function ensureContextWindowCacheLoaded(): Promise<void> {
}
applyConfiguredContextWindows({
cache: MODEL_CACHE,
cache: MODEL_CONTEXT_TOKEN_CACHE,
modelsConfig: cfg.models as ModelsConfig | undefined,
});
})().catch(() => {
@@ -241,7 +241,7 @@ export function lookupContextTokens(
if (options?.allowAsyncLoad !== false) {
void ensureContextWindowCacheLoaded();
}
return MODEL_CACHE.get(modelId);
return lookupCachedContextTokens(modelId);
}
if (shouldEagerWarmContextWindowCache()) {

View File

@@ -0,0 +1 @@
export { createOpenClawTools } from "./openclaw-tools.js";

View File

@@ -5,7 +5,7 @@ import {
listChatCommandsForConfig,
normalizeCommandBody,
} from "./commands-registry.js";
import { isAbortTrigger } from "./reply/abort.js";
import { isAbortTrigger } from "./reply/abort-primitives.js";
export function hasControlCommand(
text?: string,

View File

@@ -0,0 +1,33 @@
import { updateSessionStore } from "../../config/sessions/store.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import { applyAbortCutoffToSessionEntry, hasAbortCutoff } from "./abort-cutoff.js";
export async function clearAbortCutoffInSessionRuntime(params: {
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
}): Promise<boolean> {
const { sessionEntry, sessionStore, sessionKey, storePath } = params;
if (!sessionEntry || !sessionStore || !sessionKey || !hasAbortCutoff(sessionEntry)) {
return false;
}
applyAbortCutoffToSessionEntry(sessionEntry, undefined);
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await updateSessionStore(storePath, (store) => {
const existing = store[sessionKey] ?? sessionEntry;
if (!existing) {
return;
}
applyAbortCutoffToSessionEntry(existing, undefined);
existing.updatedAt = Date.now();
store[sessionKey] = existing;
});
}
return true;
}

View File

@@ -1,5 +1,4 @@
import type { SessionEntry } from "../../config/sessions.js";
import { updateSessionStore } from "../../config/sessions.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import type { MsgContext } from "../templating.js";
export type AbortCutoff = {
@@ -51,36 +50,6 @@ export function applyAbortCutoffToSessionEntry(
entry.abortCutoffTimestamp = cutoff?.timestamp;
}
export async function clearAbortCutoffInSession(params: {
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
}): Promise<boolean> {
const { sessionEntry, sessionStore, sessionKey, storePath } = params;
if (!sessionEntry || !sessionStore || !sessionKey || !hasAbortCutoff(sessionEntry)) {
return false;
}
applyAbortCutoffToSessionEntry(sessionEntry, undefined);
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await updateSessionStore(storePath, (store) => {
const existing = store[sessionKey] ?? sessionEntry;
if (!existing) {
return;
}
applyAbortCutoffToSessionEntry(existing, undefined);
existing.updatedAt = Date.now();
store[sessionKey] = existing;
});
}
return true;
}
function toNumericMessageSid(value: string | undefined): bigint | undefined {
const trimmed = value?.trim();
if (!trimmed || !/^\d+$/.test(trimmed)) {

View File

@@ -0,0 +1,130 @@
import { normalizeCommandBody, type CommandNormalizeOptions } from "../commands-registry.js";
const ABORT_TRIGGERS = new Set([
"stop",
"esc",
"abort",
"wait",
"exit",
"interrupt",
"detente",
"deten",
"detén",
"arrete",
"arrête",
"停止",
"やめて",
"止めて",
"रुको",
"توقف",
"стоп",
"остановись",
"останови",
"остановить",
"прекрати",
"halt",
"anhalten",
"aufhören",
"hoer auf",
"stopp",
"pare",
"stop openclaw",
"openclaw stop",
"stop action",
"stop current action",
"stop run",
"stop current run",
"stop agent",
"stop the agent",
"stop don't do anything",
"stop dont do anything",
"stop do not do anything",
"stop doing anything",
"do not do that",
"please stop",
"stop please",
]);
const ABORT_MEMORY = new Map<string, boolean>();
const ABORT_MEMORY_MAX = 2000;
const TRAILING_ABORT_PUNCTUATION_RE = /[.!?,;:'")\]}]+$/u;
function normalizeAbortTriggerText(text: string): string {
return text
.trim()
.toLowerCase()
.replace(/[`]/g, "'")
.replace(/\s+/g, " ")
.replace(TRAILING_ABORT_PUNCTUATION_RE, "")
.trim();
}
export function isAbortTrigger(text?: string): boolean {
if (!text) {
return false;
}
const normalized = normalizeAbortTriggerText(text);
return ABORT_TRIGGERS.has(normalized);
}
export function isAbortRequestText(text?: string, options?: CommandNormalizeOptions): boolean {
if (!text) {
return false;
}
const normalized = normalizeCommandBody(text, options).trim();
if (!normalized) {
return false;
}
const normalizedLower = normalized.toLowerCase();
return (
normalizedLower === "/stop" ||
normalizeAbortTriggerText(normalizedLower) === "/stop" ||
isAbortTrigger(normalizedLower)
);
}
export function getAbortMemory(key: string): boolean | undefined {
const normalized = key.trim();
if (!normalized) {
return undefined;
}
return ABORT_MEMORY.get(normalized);
}
function pruneAbortMemory(): void {
if (ABORT_MEMORY.size <= ABORT_MEMORY_MAX) {
return;
}
const excess = ABORT_MEMORY.size - ABORT_MEMORY_MAX;
let removed = 0;
for (const entryKey of ABORT_MEMORY.keys()) {
ABORT_MEMORY.delete(entryKey);
removed += 1;
if (removed >= excess) {
break;
}
}
}
export function setAbortMemory(key: string, value: boolean): void {
const normalized = key.trim();
if (!normalized) {
return;
}
if (!value) {
ABORT_MEMORY.delete(normalized);
return;
}
if (ABORT_MEMORY.has(normalized)) {
ABORT_MEMORY.delete(normalized);
}
ABORT_MEMORY.set(normalized, true);
pruneAbortMemory();
}
export function getAbortMemorySizeForTest(): number {
return ABORT_MEMORY.size;
}
export function resetAbortMemoryForTest(): void {
ABORT_MEMORY.clear();
}

View File

@@ -20,147 +20,32 @@ import {
import { logVerbose } from "../../globals.js";
import { parseAgentSessionKey } from "../../routing/session-key.js";
import { resolveCommandAuthorization } from "../command-auth.js";
import { normalizeCommandBody, type CommandNormalizeOptions } from "../commands-registry.js";
import type { FinalizedMsgContext, MsgContext } from "../templating.js";
import {
applyAbortCutoffToSessionEntry,
resolveAbortCutoffFromContext,
shouldPersistAbortCutoff,
} from "./abort-cutoff.js";
import {
getAbortMemory,
getAbortMemorySizeForTest,
isAbortRequestText,
isAbortTrigger,
resetAbortMemoryForTest,
setAbortMemory,
} from "./abort-primitives.js";
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
import { clearSessionQueues } from "./queue.js";
export { resolveAbortCutoffFromContext, shouldSkipMessageByAbortCutoff } from "./abort-cutoff.js";
const ABORT_TRIGGERS = new Set([
"stop",
"esc",
"abort",
"wait",
"exit",
"interrupt",
"detente",
"deten",
"detén",
"arrete",
"arrête",
"停止",
"やめて",
"止めて",
"रुको",
"توقف",
"стоп",
"остановись",
"останови",
"остановить",
"прекрати",
"halt",
"anhalten",
"aufhören",
"hoer auf",
"stopp",
"pare",
"stop openclaw",
"openclaw stop",
"stop action",
"stop current action",
"stop run",
"stop current run",
"stop agent",
"stop the agent",
"stop don't do anything",
"stop dont do anything",
"stop do not do anything",
"stop doing anything",
"do not do that",
"please stop",
"stop please",
]);
const ABORT_MEMORY = new Map<string, boolean>();
const ABORT_MEMORY_MAX = 2000;
const TRAILING_ABORT_PUNCTUATION_RE = /[.!?,;:'")\]}]+$/u;
function normalizeAbortTriggerText(text: string): string {
return text
.trim()
.toLowerCase()
.replace(/[`]/g, "'")
.replace(/\s+/g, " ")
.replace(TRAILING_ABORT_PUNCTUATION_RE, "")
.trim();
}
export function isAbortTrigger(text?: string): boolean {
if (!text) {
return false;
}
const normalized = normalizeAbortTriggerText(text);
return ABORT_TRIGGERS.has(normalized);
}
export function isAbortRequestText(text?: string, options?: CommandNormalizeOptions): boolean {
if (!text) {
return false;
}
const normalized = normalizeCommandBody(text, options).trim();
if (!normalized) {
return false;
}
const normalizedLower = normalized.toLowerCase();
return (
normalizedLower === "/stop" ||
normalizeAbortTriggerText(normalizedLower) === "/stop" ||
isAbortTrigger(normalizedLower)
);
}
export function getAbortMemory(key: string): boolean | undefined {
const normalized = key.trim();
if (!normalized) {
return undefined;
}
return ABORT_MEMORY.get(normalized);
}
function pruneAbortMemory(): void {
if (ABORT_MEMORY.size <= ABORT_MEMORY_MAX) {
return;
}
const excess = ABORT_MEMORY.size - ABORT_MEMORY_MAX;
let removed = 0;
for (const entryKey of ABORT_MEMORY.keys()) {
ABORT_MEMORY.delete(entryKey);
removed += 1;
if (removed >= excess) {
break;
}
}
}
export function setAbortMemory(key: string, value: boolean): void {
const normalized = key.trim();
if (!normalized) {
return;
}
if (!value) {
ABORT_MEMORY.delete(normalized);
return;
}
// Refresh insertion order so active keys are less likely to be evicted.
if (ABORT_MEMORY.has(normalized)) {
ABORT_MEMORY.delete(normalized);
}
ABORT_MEMORY.set(normalized, true);
pruneAbortMemory();
}
export function getAbortMemorySizeForTest(): number {
return ABORT_MEMORY.size;
}
export function resetAbortMemoryForTest(): void {
ABORT_MEMORY.clear();
}
export {
getAbortMemory,
getAbortMemorySizeForTest,
isAbortRequestText,
isAbortTrigger,
resetAbortMemoryForTest,
setAbortMemory,
};
export function formatAbortReplyText(stoppedSubagents?: number): string {
if (typeof stoppedSubagents !== "number" || stoppedSubagents <= 0) {

View File

@@ -1,6 +1,6 @@
import type { SessionEntry } from "../../config/sessions.js";
import { updateSessionStore } from "../../config/sessions.js";
import { setAbortMemory } from "./abort.js";
import { setAbortMemory } from "./abort-primitives.js";
export async function applySessionHints(params: {
baseBody: string;

View File

@@ -0,0 +1 @@
export { emitResetCommandHooks } from "./commands-core.js";

View File

@@ -0,0 +1 @@
export { buildStatusReply, handleCommands } from "./commands.js";

View File

@@ -0,0 +1,27 @@
import { ensureAuthProfileStore } from "../../agents/auth-profiles.js";
import type { OpenClawConfig } from "../../config/config.js";
export function resolveProfileOverride(params: {
rawProfile?: string;
provider: string;
cfg: OpenClawConfig;
agentDir?: string;
}): { profileId?: string; error?: string } {
const raw = params.rawProfile?.trim();
if (!raw) {
return {};
}
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
});
const profile = store.profiles[raw];
if (!profile) {
return { error: `Auth profile "${raw}" not found.` };
}
if (profile.provider !== params.provider) {
return {
error: `Auth profile "${raw}" is for ${profile.provider}, not ${params.provider}.`,
};
}
return { profileId: raw };
}

View File

@@ -217,27 +217,4 @@ export const formatAuthLabel = (auth: { label: string; source: string }) => {
return `${auth.label} (${auth.source})`;
};
export const resolveProfileOverride = (params: {
rawProfile?: string;
provider: string;
cfg: OpenClawConfig;
agentDir?: string;
}): { profileId?: string; error?: string } => {
const raw = params.rawProfile?.trim();
if (!raw) {
return {};
}
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
});
const profile = store.profiles[raw];
if (!profile) {
return { error: `Auth profile "${raw}" not found.` };
}
if (profile.provider !== params.provider) {
return {
error: `Auth profile "${raw}" is for ${profile.provider}, not ${params.provider}.`,
};
}
return { profileId: raw };
};
export { resolveProfileOverride } from "./directive-handling.auth-profile.js";

View File

@@ -0,0 +1,24 @@
import {
buildModelAliasIndex,
type ModelAliasIndex,
resolveDefaultModelForAgent,
} from "../../agents/model-selection.js";
import type { OpenClawConfig } from "../../config/config.js";
export function resolveDefaultModel(params: { cfg: OpenClawConfig; agentId?: string }): {
defaultProvider: string;
defaultModel: string;
aliasIndex: ModelAliasIndex;
} {
const mainModel = resolveDefaultModelForAgent({
cfg: params.cfg,
agentId: params.agentId,
});
const defaultProvider = mainModel.provider;
const defaultModel = mainModel.model;
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg,
defaultProvider,
});
return { defaultProvider, defaultModel, aliasIndex };
}

View File

@@ -13,10 +13,8 @@ import { applyVerboseOverride } from "../../sessions/level-overrides.js";
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
import { formatThinkingLevels, formatXHighModelHint, supportsXHighThinking } from "../thinking.js";
import type { ReplyPayload } from "../types.js";
import {
maybeHandleModelDirectiveInfo,
resolveModelSelectionFromDirective,
} from "./directive-handling.model.js";
import { resolveModelSelectionFromDirective } from "./directive-handling.model-selection.js";
import { maybeHandleModelDirectiveInfo } from "./directive-handling.model.js";
import type { HandleDirectiveOnlyParams } from "./directive-handling.params.js";
import { maybeHandleQueueDirective } from "./directive-handling.queue-validation.js";
import {

View File

@@ -0,0 +1,159 @@
import { ensureAuthProfileStore } from "../../agents/auth-profiles.js";
import {
type ModelAliasIndex,
modelKey,
normalizeProviderIdForAuth,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import type { OpenClawConfig } from "../../config/config.js";
import { resolveProfileOverride } from "./directive-handling.auth-profile.js";
import type { InlineDirectives } from "./directive-handling.parse.js";
import { type ModelDirectiveSelection, resolveModelDirectiveSelection } from "./model-selection.js";
function resolveStoredNumericProfileModelDirective(params: { raw: string; agentDir: string }): {
modelRaw: string;
profileId: string;
profileProvider: string;
} | null {
const trimmed = params.raw.trim();
const lastSlash = trimmed.lastIndexOf("/");
const profileDelimiter = trimmed.indexOf("@", lastSlash + 1);
if (profileDelimiter <= 0) {
return null;
}
const profileId = trimmed.slice(profileDelimiter + 1).trim();
if (!/^\d{8}$/.test(profileId)) {
return null;
}
const modelRaw = trimmed.slice(0, profileDelimiter).trim();
if (!modelRaw) {
return null;
}
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
});
const profile = store.profiles[profileId];
if (!profile) {
return null;
}
return { modelRaw, profileId, profileProvider: profile.provider };
}
export function resolveModelSelectionFromDirective(params: {
directives: InlineDirectives;
cfg: OpenClawConfig;
agentDir: string;
defaultProvider: string;
defaultModel: string;
aliasIndex: ModelAliasIndex;
allowedModelKeys: Set<string>;
allowedModelCatalog: Array<{ provider: string; id?: string; name?: string }>;
provider: string;
}): {
modelSelection?: ModelDirectiveSelection;
profileOverride?: string;
errorText?: string;
} {
if (!params.directives.hasModelDirective || !params.directives.rawModelDirective) {
if (params.directives.rawModelProfile) {
return { errorText: "Auth profile override requires a model selection." };
}
return {};
}
const raw = params.directives.rawModelDirective.trim();
const storedNumericProfile =
params.directives.rawModelProfile === undefined
? resolveStoredNumericProfileModelDirective({
raw,
agentDir: params.agentDir,
})
: null;
const storedNumericProfileSelection = storedNumericProfile
? resolveModelDirectiveSelection({
raw: storedNumericProfile.modelRaw,
defaultProvider: params.defaultProvider,
defaultModel: params.defaultModel,
aliasIndex: params.aliasIndex,
allowedModelKeys: params.allowedModelKeys,
})
: null;
const useStoredNumericProfile =
Boolean(storedNumericProfileSelection?.selection) &&
normalizeProviderIdForAuth(storedNumericProfileSelection?.selection?.provider ?? "") ===
normalizeProviderIdForAuth(storedNumericProfile?.profileProvider ?? "");
const modelRaw =
useStoredNumericProfile && storedNumericProfile ? storedNumericProfile.modelRaw : raw;
let modelSelection: ModelDirectiveSelection | undefined;
if (/^[0-9]+$/.test(raw)) {
return {
errorText: [
"Numeric model selection is not supported in chat.",
"",
"Browse: /models or /models <provider>",
"Switch: /model <provider/model>",
].join("\n"),
};
}
const explicit = resolveModelRefFromString({
raw: modelRaw,
defaultProvider: params.defaultProvider,
aliasIndex: params.aliasIndex,
});
if (explicit) {
const explicitKey = modelKey(explicit.ref.provider, explicit.ref.model);
if (params.allowedModelKeys.size === 0 || params.allowedModelKeys.has(explicitKey)) {
modelSelection = {
provider: explicit.ref.provider,
model: explicit.ref.model,
isDefault:
explicit.ref.provider === params.defaultProvider &&
explicit.ref.model === params.defaultModel,
...(explicit.alias ? { alias: explicit.alias } : {}),
};
}
}
if (!modelSelection) {
const resolved = resolveModelDirectiveSelection({
raw: modelRaw,
defaultProvider: params.defaultProvider,
defaultModel: params.defaultModel,
aliasIndex: params.aliasIndex,
allowedModelKeys: params.allowedModelKeys,
});
if (resolved.error) {
return { errorText: resolved.error };
}
if (resolved.selection) {
modelSelection = resolved.selection;
}
}
let profileOverride: string | undefined;
const rawProfile =
params.directives.rawModelProfile ??
(useStoredNumericProfile ? storedNumericProfile?.profileId : undefined);
if (modelSelection && rawProfile) {
const profileResolved = resolveProfileOverride({
rawProfile,
provider: modelSelection.provider,
cfg: params.cfg,
agentDir: params.agentDir,
});
if (profileResolved.error) {
return { errorText: profileResolved.error };
}
profileOverride = profileResolved.profileId;
}
return { modelSelection, profileOverride };
}

View File

@@ -3,20 +3,16 @@ import {
resolveDefaultAgentId,
resolveSessionAgentId,
} from "../../agents/agent-scope.js";
import { lookupContextTokens } from "../../agents/context.js";
import { lookupCachedContextTokens } from "../../agents/context-cache.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import {
buildModelAliasIndex,
type ModelAliasIndex,
resolveDefaultModelForAgent,
} from "../../agents/model-selection.js";
import type { ModelAliasIndex } from "../../agents/model-selection.js";
import type { OpenClawConfig } from "../../config/config.js";
import { updateSessionStore } from "../../config/sessions/store.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { applyVerboseOverride } from "../../sessions/level-overrides.js";
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
import { resolveModelSelectionFromDirective } from "./directive-handling.model.js";
import { resolveModelSelectionFromDirective } from "./directive-handling.model-selection.js";
import type { InlineDirectives } from "./directive-handling.parse.js";
import { enqueueModeSwitchEvents } from "./directive-handling.shared.js";
import type { ElevatedLevel, ReasoningLevel } from "./directives.js";
@@ -203,24 +199,7 @@ export async function persistInlineDirectives(params: {
return {
provider,
model,
contextTokens: agentCfg?.contextTokens ?? lookupContextTokens(model) ?? DEFAULT_CONTEXT_TOKENS,
contextTokens:
agentCfg?.contextTokens ?? lookupCachedContextTokens(model) ?? DEFAULT_CONTEXT_TOKENS,
};
}
export function resolveDefaultModel(params: { cfg: OpenClawConfig; agentId?: string }): {
defaultProvider: string;
defaultModel: string;
aliasIndex: ModelAliasIndex;
} {
const mainModel = resolveDefaultModelForAgent({
cfg: params.cfg,
agentId: params.agentId,
});
const defaultProvider = mainModel.provider;
const defaultModel = mainModel.model;
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg,
defaultProvider,
});
return { defaultProvider, defaultModel, aliasIndex };
}

View File

@@ -2,5 +2,6 @@ export { applyInlineDirectivesFastLane } from "./directive-handling.fast-lane.js
export * from "./directive-handling.impl.js";
export type { InlineDirectives } from "./directive-handling.parse.js";
export { isDirectiveOnly, parseInlineDirectives } from "./directive-handling.parse.js";
export { persistInlineDirectives, resolveDefaultModel } from "./directive-handling.persist.js";
export { persistInlineDirectives } from "./directive-handling.persist.js";
export { resolveDefaultModel } from "./directive-handling.defaults.js";
export { formatDirectiveAck } from "./directive-handling.shared.js";

View File

@@ -1,5 +1,4 @@
import { collectTextContentBlocks } from "../../agents/content-blocks.js";
import { createOpenClawTools } from "../../agents/openclaw-tools.js";
import type { BlockReplyChunking } from "../../agents/pi-embedded-block-chunker.js";
import type { SkillCommandSpec } from "../../agents/skills.js";
import { applyOwnerOnlyToolPolicy } from "../../agents/tool-policy.js";
@@ -11,20 +10,18 @@ import { generateSecureToken } from "../../infra/secure-random.js";
import { resolveGatewayMessageChannel } from "../../utils/message-channel.js";
import {
listReservedChatSlashCommandNames,
listSkillCommandsForWorkspace,
resolveSkillCommandInvocation,
} from "../skill-commands.js";
} from "../skill-commands-base.js";
import type { MsgContext, TemplateContext } from "../templating.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import {
clearAbortCutoffInSession,
readAbortCutoffFromSessionEntry,
resolveAbortCutoffFromContext,
shouldSkipMessageByAbortCutoff,
} from "./abort-cutoff.js";
import { getAbortMemory, isAbortRequestText } from "./abort.js";
import { buildStatusReply, handleCommands } from "./commands.js";
import { getAbortMemory, isAbortRequestText } from "./abort-primitives.js";
import type { buildStatusReply, handleCommands } from "./commands.runtime.js";
import type { InlineDirectives } from "./directive-handling.parse.js";
import { isDirectiveOnly } from "./directive-handling.parse.js";
import type { createModelSelectionState } from "./model-selection.js";
@@ -191,7 +188,7 @@ export async function handleInlineActions(params: {
shouldLoadSkillCommands && params.skillCommands
? params.skillCommands
: shouldLoadSkillCommands
? listSkillCommandsForWorkspace({
? (await import("../skill-commands.runtime.js")).listSkillCommandsForWorkspace({
workspaceDir,
cfg,
skillFilter,
@@ -222,6 +219,7 @@ export async function handleInlineActions(params: {
resolveGatewayMessageChannel(ctx.Provider) ??
undefined;
const { createOpenClawTools } = await import("../../agents/openclaw-tools.runtime.js");
const tools = createOpenClawTools({
agentSessionKey: sessionKey,
agentChannel: channel,
@@ -305,7 +303,9 @@ export async function handleInlineActions(params: {
return { kind: "reply", reply: undefined };
}
if (cutoff) {
await clearAbortCutoffInSession({
await (
await import("./abort-cutoff.runtime.js")
).clearAbortCutoffInSessionRuntime({
sessionEntry,
sessionStore,
sessionKey,
@@ -335,6 +335,7 @@ export async function handleInlineActions(params: {
isGroup,
}) && inlineStatusRequested;
if (handleInlineStatus) {
const { buildStatusReply } = await import("./commands.runtime.js");
const inlineStatusReply = await buildStatusReply({
cfg,
command,
@@ -358,8 +359,9 @@ export async function handleInlineActions(params: {
directives = { ...directives, hasStatusDirective: false };
}
const runCommands = (commandInput: typeof command) =>
handleCommands({
const runCommands = async (commandInput: typeof command) => {
const { handleCommands } = await import("./commands.runtime.js");
return handleCommands({
// Pass sessionCtx so command handlers can mutate stripped body for same-turn continuation.
ctx: sessionCtx,
// Keep original finalized context in sync when command handlers need outer-dispatch side effects.
@@ -397,6 +399,7 @@ export async function handleInlineActions(params: {
skillCommands,
typing,
});
};
if (inlineCommand) {
const inlineCommandContext = {

View File

@@ -15,8 +15,7 @@ import { resolveCommandAuthorization } from "../command-auth.js";
import type { MsgContext } from "../templating.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import { emitResetCommandHooks, type ResetCommandAction } from "./commands-core.js";
import { resolveDefaultModel } from "./directive-handling.persist.js";
import { resolveDefaultModel } from "./directive-handling.defaults.js";
import { resolveReplyDirectives } from "./get-reply-directives.js";
import { handleInlineActions } from "./get-reply-inline-actions.js";
import { runPreparedReply } from "./get-reply-run.js";
@@ -31,6 +30,8 @@ function shouldLogCoreIngressTiming(): boolean {
return process.env.OPENCLAW_DEBUG_INGRESS_TIMING === "1";
}
type ResetCommandAction = "new" | "reset";
function mergeSkillFilters(channelFilter?: string[], agentFilter?: string[]): string[] | undefined {
const normalize = (list?: string[]) => {
if (!Array.isArray(list)) {
@@ -355,6 +356,7 @@ export async function getReplyFromConfig(
if (!resetMatch) {
return;
}
const { emitResetCommandHooks } = await import("./commands-core.runtime.js");
const action: ResetCommandAction = resetMatch[1] === "reset" ? "reset" : "new";
await emitResetCommandHooks({
action,

View File

@@ -0,0 +1,52 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
import { resolveSessionFilePath } from "../../config/sessions/paths.js";
import type { SessionEntry } from "../../config/sessions/types.js";
export function forkSessionFromParentRuntime(params: {
parentEntry: SessionEntry;
agentId: string;
sessionsDir: string;
}): { sessionId: string; sessionFile: string } | null {
const parentSessionFile = resolveSessionFilePath(
params.parentEntry.sessionId,
params.parentEntry,
{ agentId: params.agentId, sessionsDir: params.sessionsDir },
);
if (!parentSessionFile || !fs.existsSync(parentSessionFile)) {
return null;
}
try {
const manager = SessionManager.open(parentSessionFile);
const leafId = manager.getLeafId();
if (leafId) {
const sessionFile = manager.createBranchedSession(leafId) ?? manager.getSessionFile();
const sessionId = manager.getSessionId();
if (sessionFile && sessionId) {
return { sessionId, sessionFile };
}
}
const sessionId = crypto.randomUUID();
const timestamp = new Date().toISOString();
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
const sessionFile = path.join(manager.getSessionDir(), `${fileTimestamp}_${sessionId}.jsonl`);
const header = {
type: "session",
version: CURRENT_SESSION_VERSION,
id: sessionId,
timestamp,
cwd: manager.getCwd(),
parentSession: parentSessionFile,
};
fs.writeFileSync(sessionFile, `${JSON.stringify(header)}\n`, {
encoding: "utf-8",
mode: 0o600,
flag: "wx",
});
return { sessionId, sessionFile };
} catch {
return null;
}
}

View File

@@ -1,9 +1,4 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
import type { OpenClawConfig } from "../../config/config.js";
import { resolveSessionFilePath } from "../../config/sessions/paths.js";
import type { SessionEntry } from "../../config/sessions/types.js";
/**
@@ -21,44 +16,11 @@ export function resolveParentForkMaxTokens(cfg: OpenClawConfig): number {
return DEFAULT_PARENT_FORK_MAX_TOKENS;
}
export function forkSessionFromParent(params: {
export async function forkSessionFromParent(params: {
parentEntry: SessionEntry;
agentId: string;
sessionsDir: string;
}): { sessionId: string; sessionFile: string } | null {
const parentSessionFile = resolveSessionFilePath(
params.parentEntry.sessionId,
params.parentEntry,
{ agentId: params.agentId, sessionsDir: params.sessionsDir },
);
if (!parentSessionFile || !fs.existsSync(parentSessionFile)) {
return null;
}
try {
const manager = SessionManager.open(parentSessionFile);
const leafId = manager.getLeafId();
if (leafId) {
const sessionFile = manager.createBranchedSession(leafId) ?? manager.getSessionFile();
const sessionId = manager.getSessionId();
if (sessionFile && sessionId) {
return { sessionId, sessionFile };
}
}
const sessionId = crypto.randomUUID();
const timestamp = new Date().toISOString();
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
const sessionFile = path.join(manager.getSessionDir(), `${fileTimestamp}_${sessionId}.jsonl`);
const header = {
type: "session",
version: CURRENT_SESSION_VERSION,
id: sessionId,
timestamp,
cwd: manager.getCwd(),
parentSession: parentSessionFile,
};
fs.writeFileSync(sessionFile, `${JSON.stringify(header)}\n`, "utf-8");
return { sessionId, sessionFile };
} catch {
return null;
}
}): Promise<{ sessionId: string; sessionFile: string } | null> {
const runtime = await import("./session-fork.runtime.js");
return runtime.forkSessionFromParentRuntime(params);
}

View File

@@ -501,7 +501,7 @@ export async function initSessionState(params: {
`forking from parent session: parentKey=${parentSessionKey} → sessionKey=${sessionKey} ` +
`parentTokens=${parentTokens}`,
);
const forked = forkSessionFromParent({
const forked = await forkSessionFromParent({
parentEntry: sessionStore[parentSessionKey],
agentId,
sessionsDir: path.dirname(storePath),

View File

@@ -0,0 +1,96 @@
import type { SkillCommandSpec } from "../agents/skills.js";
import { getChatCommands } from "./commands-registry.data.js";
export function listReservedChatSlashCommandNames(extraNames: string[] = []): Set<string> {
const reserved = new Set<string>();
for (const command of getChatCommands()) {
if (command.nativeName) {
reserved.add(command.nativeName.toLowerCase());
}
for (const alias of command.textAliases) {
const trimmed = alias.trim();
if (!trimmed.startsWith("/")) {
continue;
}
reserved.add(trimmed.slice(1).toLowerCase());
}
}
for (const name of extraNames) {
const trimmed = name.trim().toLowerCase();
if (trimmed) {
reserved.add(trimmed);
}
}
return reserved;
}
function normalizeSkillCommandLookup(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[\s_]+/g, "-");
}
function findSkillCommand(
skillCommands: SkillCommandSpec[],
rawName: string,
): SkillCommandSpec | undefined {
const trimmed = rawName.trim();
if (!trimmed) {
return undefined;
}
const lowered = trimmed.toLowerCase();
const normalized = normalizeSkillCommandLookup(trimmed);
return skillCommands.find((entry) => {
if (entry.name.toLowerCase() === lowered) {
return true;
}
if (entry.skillName.toLowerCase() === lowered) {
return true;
}
return (
normalizeSkillCommandLookup(entry.name) === normalized ||
normalizeSkillCommandLookup(entry.skillName) === normalized
);
});
}
export function resolveSkillCommandInvocation(params: {
commandBodyNormalized: string;
skillCommands: SkillCommandSpec[];
}): { command: SkillCommandSpec; args?: string } | null {
const trimmed = params.commandBodyNormalized.trim();
if (!trimmed.startsWith("/")) {
return null;
}
const match = trimmed.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/);
if (!match) {
return null;
}
const commandName = match[1]?.trim().toLowerCase();
if (!commandName) {
return null;
}
if (commandName === "skill") {
const remainder = match[2]?.trim();
if (!remainder) {
return null;
}
const skillMatch = remainder.match(/^([^\s]+)(?:\s+([\s\S]+))?$/);
if (!skillMatch) {
return null;
}
const skillCommand = findSkillCommand(params.skillCommands, skillMatch[1] ?? "");
if (!skillCommand) {
return null;
}
const args = skillMatch[2]?.trim();
return { command: skillCommand, args: args || undefined };
}
const command = params.skillCommands.find((entry) => entry.name.toLowerCase() === commandName);
if (!command) {
return null;
}
const args = match[2]?.trim();
return { command, args: args || undefined };
}

View File

@@ -1,5 +1 @@
export {
listReservedChatSlashCommandNames,
listSkillCommandsForWorkspace,
resolveSkillCommandInvocation,
} from "./skill-commands.js";
export { listSkillCommandsForAgents, listSkillCommandsForWorkspace } from "./skill-commands.js";

View File

@@ -8,30 +8,11 @@ import { buildWorkspaceSkillCommandSpecs, type SkillCommandSpec } from "../agent
import type { OpenClawConfig } from "../config/config.js";
import { logVerbose } from "../globals.js";
import { getRemoteSkillEligibility } from "../infra/skills-remote.js";
import { listChatCommands } from "./commands-registry.js";
export function listReservedChatSlashCommandNames(extraNames: string[] = []): Set<string> {
const reserved = new Set<string>();
for (const command of listChatCommands()) {
if (command.nativeName) {
reserved.add(command.nativeName.toLowerCase());
}
for (const alias of command.textAliases) {
const trimmed = alias.trim();
if (!trimmed.startsWith("/")) {
continue;
}
reserved.add(trimmed.slice(1).toLowerCase());
}
}
for (const name of extraNames) {
const trimmed = name.trim().toLowerCase();
if (trimmed) {
reserved.add(trimmed);
}
}
return reserved;
}
import { listReservedChatSlashCommandNames } from "./skill-commands-base.js";
export {
listReservedChatSlashCommandNames,
resolveSkillCommandInvocation,
} from "./skill-commands-base.js";
export function listSkillCommandsForWorkspace(params: {
workspaceDir: string;
@@ -131,74 +112,3 @@ export function listSkillCommandsForAgents(params: {
export const __testing = {
dedupeBySkillName,
};
function normalizeSkillCommandLookup(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[\s_]+/g, "-");
}
function findSkillCommand(
skillCommands: SkillCommandSpec[],
rawName: string,
): SkillCommandSpec | undefined {
const trimmed = rawName.trim();
if (!trimmed) {
return undefined;
}
const lowered = trimmed.toLowerCase();
const normalized = normalizeSkillCommandLookup(trimmed);
return skillCommands.find((entry) => {
if (entry.name.toLowerCase() === lowered) {
return true;
}
if (entry.skillName.toLowerCase() === lowered) {
return true;
}
return (
normalizeSkillCommandLookup(entry.name) === normalized ||
normalizeSkillCommandLookup(entry.skillName) === normalized
);
});
}
export function resolveSkillCommandInvocation(params: {
commandBodyNormalized: string;
skillCommands: SkillCommandSpec[];
}): { command: SkillCommandSpec; args?: string } | null {
const trimmed = params.commandBodyNormalized.trim();
if (!trimmed.startsWith("/")) {
return null;
}
const match = trimmed.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/);
if (!match) {
return null;
}
const commandName = match[1]?.trim().toLowerCase();
if (!commandName) {
return null;
}
if (commandName === "skill") {
const remainder = match[2]?.trim();
if (!remainder) {
return null;
}
const skillMatch = remainder.match(/^([^\s]+)(?:\s+([\s\S]+))?$/);
if (!skillMatch) {
return null;
}
const skillCommand = findSkillCommand(params.skillCommands, skillMatch[1] ?? "");
if (!skillCommand) {
return null;
}
const args = skillMatch[2]?.trim();
return { command: skillCommand, args: args || undefined };
}
const command = params.skillCommands.find((entry) => entry.name.toLowerCase() === commandName);
if (!command) {
return null;
}
const args = match[2]?.trim();
return { command, args: args || undefined };
}

View File

@@ -1,4 +1,4 @@
import { isAbortRequestText } from "../auto-reply/reply/abort.js";
import { isAbortRequestText } from "../auto-reply/reply/abort-primitives.js";
export type ChatAbortControllerEntry = {
controller: AbortController;

View File

@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
resolveSessionAgentId: vi.fn(() => "agent-from-key"),
resolveSessionDeliveryTarget: vi.fn(() => ({
deliveryContextFromSession: vi.fn(() => ({
channel: "whatsapp",
to: "+15550001",
accountId: "acct-1",
@@ -50,7 +50,7 @@ describe("deliverSessionMaintenanceWarning", () => {
process.env.NODE_ENV = "development";
vi.resetModules();
mocks.resolveSessionAgentId.mockClear();
mocks.resolveSessionDeliveryTarget.mockClear();
mocks.deliveryContextFromSession.mockClear();
mocks.normalizeMessageChannel.mockClear();
mocks.isDeliverableMessageChannel.mockClear();
mocks.deliverOutboundPayloads.mockClear();
@@ -62,10 +62,10 @@ describe("deliverSessionMaintenanceWarning", () => {
normalizeMessageChannel: mocks.normalizeMessageChannel,
isDeliverableMessageChannel: mocks.isDeliverableMessageChannel,
}));
vi.doMock("./outbound/targets.js", () => ({
resolveSessionDeliveryTarget: mocks.resolveSessionDeliveryTarget,
vi.doMock("../utils/delivery-context.js", () => ({
deliveryContextFromSession: mocks.deliveryContextFromSession,
}));
vi.doMock("./outbound/deliver.js", () => ({
vi.doMock("./outbound/deliver-runtime.js", () => ({
deliverOutboundPayloads: mocks.deliverOutboundPayloads,
}));
vi.doMock("./system-events.js", () => ({
@@ -112,7 +112,7 @@ describe("deliverSessionMaintenanceWarning", () => {
});
it("falls back to a system event when the last target is not deliverable", async () => {
mocks.resolveSessionDeliveryTarget.mockReturnValueOnce({
mocks.deliveryContextFromSession.mockReturnValueOnce({
channel: "debug",
to: "+15550001",
accountId: "acct-1",
@@ -143,7 +143,7 @@ describe("deliverSessionMaintenanceWarning", () => {
await deliverSessionMaintenanceWarning(createParams());
expect(mocks.resolveSessionDeliveryTarget).not.toHaveBeenCalled();
expect(mocks.deliveryContextFromSession).not.toHaveBeenCalled();
expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
expect(mocks.enqueueSystemEvent).not.toHaveBeenCalled();
});

View File

@@ -2,9 +2,9 @@ import type { OpenClawConfig } from "../config/config.js";
import type { SessionMaintenanceWarning } from "../config/sessions/store-maintenance.js";
import type { SessionEntry } from "../config/sessions/types.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { deliveryContextFromSession } from "../utils/delivery-context.js";
import { isDeliverableMessageChannel, normalizeMessageChannel } from "../utils/message-channel.js";
import { buildOutboundSessionContext } from "./outbound/session-context.js";
import { resolveSessionDeliveryTarget } from "./outbound/targets.js";
import { enqueueSystemEvent } from "./system-events.js";
type WarningParams = {
@@ -73,6 +73,24 @@ function buildWarningText(warning: SessionMaintenanceWarning): string {
);
}
function resolveWarningDeliveryTarget(entry: SessionEntry): {
channel?: string;
to?: string;
accountId?: string;
threadId?: string | number;
} {
const context = deliveryContextFromSession(entry);
const channel = context?.channel
? (normalizeMessageChannel(context.channel) ?? context.channel)
: undefined;
return {
channel: channel && isDeliverableMessageChannel(channel) ? channel : undefined,
to: context?.to,
accountId: context?.accountId,
threadId: context?.threadId,
};
}
export async function deliverSessionMaintenanceWarning(params: WarningParams): Promise<void> {
if (!shouldSendWarning()) {
return;
@@ -85,10 +103,7 @@ export async function deliverSessionMaintenanceWarning(params: WarningParams): P
warnedContexts.set(params.sessionKey, contextKey);
const text = buildWarningText(params.warning);
const target = resolveSessionDeliveryTarget({
entry: params.entry,
requestedChannel: "last",
});
const target = resolveWarningDeliveryTarget(params.entry);
if (!target.channel || !target.to) {
enqueueSystemEvent(text, { sessionKey: params.sessionKey });