import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { resolveAgentDir, resolveAgentIdFromSessionKey, resolveAgentWorkspaceDir, } from "../agents/agent-scope.js"; import { resolveModelRefFromString } from "../agents/model-selection.js"; import { abortEmbeddedPiRun, isEmbeddedPiRunActive, isEmbeddedPiRunStreaming, resolveEmbeddedSessionLane, } from "../agents/pi-embedded.js"; import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js"; import { resolveAgentTimeoutMs } from "../agents/timeout.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace, } from "../agents/workspace.js"; import { type AgentElevatedAllowFromConfig, type ClawdbotConfig, loadConfig, } from "../config/config.js"; import { resolveSessionFilePath } from "../config/sessions.js"; import { logVerbose } from "../globals.js"; import { clearCommandLane, getQueueSize } from "../process/command-queue.js"; import { defaultRuntime } from "../runtime.js"; import { resolveCommandAuthorization } from "./command-auth.js"; import { hasControlCommand } from "./command-detection.js"; import { listChatCommands, shouldHandleTextCommands, } from "./commands-registry.js"; import { buildInboundMediaNote } from "./media-note.js"; import { getAbortMemory } from "./reply/abort.js"; import { runReplyAgent } from "./reply/agent-runner.js"; import { resolveBlockStreamingChunking } from "./reply/block-streaming.js"; import { applySessionHints } from "./reply/body.js"; import { buildCommandContext, buildStatusReply, handleCommands, } from "./reply/commands.js"; import { handleDirectiveOnly, type InlineDirectives, isDirectiveOnly, parseInlineDirectives, persistInlineDirectives, resolveDefaultModel, } from "./reply/directive-handling.js"; import { buildGroupIntro, defaultGroupActivation, resolveGroupRequireMention, } from "./reply/groups.js"; import { stripMentions, stripStructuralPrefixes } from "./reply/mentions.js"; import { createModelSelectionState, resolveContextTokens, } from "./reply/model-selection.js"; import { resolveQueueSettings } from "./reply/queue.js"; import { initSessionState } from "./reply/session.js"; import { ensureSkillSnapshot, prependSystemEvents, } from "./reply/session-updates.js"; import { createTypingController } from "./reply/typing.js"; import { createTypingSignaler, resolveTypingMode, } from "./reply/typing-mode.js"; import type { MsgContext, TemplateContext } from "./templating.js"; import { type ElevatedLevel, normalizeThinkLevel, type ReasoningLevel, type ThinkLevel, type VerboseLevel, } from "./thinking.js"; import { SILENT_REPLY_TOKEN } from "./tokens.js"; import { isAudio, transcribeInboundAudio } from "./transcription.js"; import type { GetReplyOptions, ReplyPayload } from "./types.js"; export { extractElevatedDirective, extractReasoningDirective, extractThinkDirective, extractVerboseDirective, } from "./reply/directives.js"; export { extractQueueDirective } from "./reply/queue.js"; export { extractReplyToTag } from "./reply/reply-tags.js"; export type { GetReplyOptions, ReplyPayload } from "./types.js"; const BARE_SESSION_RESET_PROMPT = "A new session was started via /new or /reset. Say hi briefly (1-2 sentences) and ask what the user wants to do next. Do not mention internal steps, files, tools, or reasoning."; function normalizeAllowToken(value?: string) { if (!value) return ""; return value.trim().toLowerCase(); } function slugAllowToken(value?: string) { if (!value) return ""; let text = value.trim().toLowerCase(); if (!text) return ""; text = text.replace(/^[@#]+/, ""); text = text.replace(/[\s_]+/g, "-"); text = text.replace(/[^a-z0-9-]+/g, "-"); return text.replace(/-{2,}/g, "-").replace(/^-+|-+$/g, ""); } function stripSenderPrefix(value?: string) { if (!value) return ""; const trimmed = value.trim(); return trimmed.replace( /^(whatsapp|telegram|discord|signal|imessage|webchat|user|group|channel):/i, "", ); } function resolveElevatedAllowList( allowFrom: AgentElevatedAllowFromConfig | undefined, provider: string, discordFallback?: Array, ): Array | undefined { switch (provider) { case "whatsapp": return allowFrom?.whatsapp; case "telegram": return allowFrom?.telegram; case "discord": { const hasExplicit = Boolean( allowFrom && Object.hasOwn(allowFrom, "discord"), ); if (hasExplicit) return allowFrom?.discord; return discordFallback; } case "signal": return allowFrom?.signal; case "imessage": return allowFrom?.imessage; case "webchat": return allowFrom?.webchat; default: return undefined; } } function isApprovedElevatedSender(params: { provider: string; ctx: MsgContext; allowFrom?: AgentElevatedAllowFromConfig; discordFallback?: Array; }): boolean { const rawAllow = resolveElevatedAllowList( params.allowFrom, params.provider, params.discordFallback, ); if (!rawAllow || rawAllow.length === 0) return false; const allowTokens = rawAllow .map((entry) => String(entry).trim()) .filter(Boolean); if (allowTokens.length === 0) return false; if (allowTokens.some((entry) => entry === "*")) return true; const tokens = new Set(); const addToken = (value?: string) => { if (!value) return; const trimmed = value.trim(); if (!trimmed) return; tokens.add(trimmed); const normalized = normalizeAllowToken(trimmed); if (normalized) tokens.add(normalized); const slugged = slugAllowToken(trimmed); if (slugged) tokens.add(slugged); }; addToken(params.ctx.SenderName); addToken(params.ctx.SenderUsername); addToken(params.ctx.SenderTag); addToken(params.ctx.SenderE164); addToken(params.ctx.From); addToken(stripSenderPrefix(params.ctx.From)); addToken(params.ctx.To); addToken(stripSenderPrefix(params.ctx.To)); for (const rawEntry of allowTokens) { const entry = rawEntry.trim(); if (!entry) continue; const stripped = stripSenderPrefix(entry); if (tokens.has(entry) || tokens.has(stripped)) return true; const normalized = normalizeAllowToken(stripped); if (normalized && tokens.has(normalized)) return true; const slugged = slugAllowToken(stripped); if (slugged && tokens.has(slugged)) return true; } return false; } export async function getReplyFromConfig( ctx: MsgContext, opts?: GetReplyOptions, configOverride?: ClawdbotConfig, ): Promise { const cfg = configOverride ?? loadConfig(); const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey); const agentCfg = cfg.agents?.defaults; const sessionCfg = cfg.session; const { defaultProvider, defaultModel, aliasIndex } = resolveDefaultModel({ cfg, agentId, }); let provider = defaultProvider; let model = defaultModel; if (opts?.isHeartbeat) { const heartbeatRaw = agentCfg?.heartbeat?.model?.trim() ?? ""; const heartbeatRef = heartbeatRaw ? resolveModelRefFromString({ raw: heartbeatRaw, defaultProvider, aliasIndex, }) : null; if (heartbeatRef) { provider = heartbeatRef.ref.provider; model = heartbeatRef.ref.model; } } const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR; const workspace = await ensureAgentWorkspace({ dir: workspaceDirRaw, ensureBootstrapFiles: !agentCfg?.skipBootstrap, }); const workspaceDir = workspace.dir; const agentDir = resolveAgentDir(cfg, agentId); const timeoutMs = resolveAgentTimeoutMs({ cfg }); const configuredTypingSeconds = agentCfg?.typingIntervalSeconds ?? sessionCfg?.typingIntervalSeconds; const typingIntervalSeconds = typeof configuredTypingSeconds === "number" ? configuredTypingSeconds : 6; const typing = createTypingController({ onReplyStart: opts?.onReplyStart, typingIntervalSeconds, silentToken: SILENT_REPLY_TOKEN, log: defaultRuntime.log, }); opts?.onTypingController?.(typing); let transcribedText: string | undefined; if (cfg.audio?.transcription && isAudio(ctx.MediaType)) { const transcribed = await transcribeInboundAudio(cfg, ctx, defaultRuntime); if (transcribed?.text) { transcribedText = transcribed.text; ctx.Body = transcribed.text; ctx.Transcript = transcribed.text; logVerbose("Replaced Body with audio transcript for reply flow"); } } const commandAuthorized = ctx.CommandAuthorized ?? true; resolveCommandAuthorization({ ctx, cfg, commandAuthorized, }); const sessionState = await initSessionState({ ctx, cfg, commandAuthorized, }); let { sessionCtx, sessionEntry, sessionStore, sessionKey, sessionId, isNewSession, systemSent, abortedLastRun, storePath, sessionScope, groupResolution, isGroup, triggerBodyNormalized, } = sessionState; const rawBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""; const clearInlineDirectives = (cleaned: string): InlineDirectives => ({ cleaned, hasThinkDirective: false, thinkLevel: undefined, rawThinkLevel: undefined, hasVerboseDirective: false, verboseLevel: undefined, rawVerboseLevel: undefined, hasReasoningDirective: false, reasoningLevel: undefined, rawReasoningLevel: undefined, hasElevatedDirective: false, elevatedLevel: undefined, rawElevatedLevel: undefined, hasStatusDirective: false, hasModelDirective: false, rawModelDirective: undefined, hasQueueDirective: false, queueMode: undefined, queueReset: false, rawQueueMode: undefined, debounceMs: undefined, cap: undefined, dropPolicy: undefined, rawDebounce: undefined, rawCap: undefined, rawDrop: undefined, hasQueueOptions: false, }); const reservedCommands = new Set( listChatCommands().flatMap((cmd) => cmd.textAliases.map((a) => a.replace(/^\//, "").toLowerCase()), ), ); const configuredAliases = Object.values(cfg.agents?.defaults?.models ?? {}) .map((entry) => entry.alias?.trim()) .filter((alias): alias is string => Boolean(alias)) .filter((alias) => !reservedCommands.has(alias.toLowerCase())); let parsedDirectives = parseInlineDirectives(rawBody, { modelAliases: configuredAliases, }); if ( isGroup && ctx.WasMentioned !== true && parsedDirectives.hasElevatedDirective ) { if (parsedDirectives.elevatedLevel !== "off") { parsedDirectives = { ...parsedDirectives, hasElevatedDirective: false, elevatedLevel: undefined, rawElevatedLevel: undefined, }; } } const hasDirective = parsedDirectives.hasThinkDirective || parsedDirectives.hasVerboseDirective || parsedDirectives.hasReasoningDirective || parsedDirectives.hasElevatedDirective || parsedDirectives.hasStatusDirective || parsedDirectives.hasModelDirective || parsedDirectives.hasQueueDirective; if (hasDirective) { const stripped = stripStructuralPrefixes(parsedDirectives.cleaned); const noMentions = isGroup ? stripMentions(stripped, ctx, cfg, agentId) : stripped; if (noMentions.trim().length > 0) { const directiveOnlyCheck = parseInlineDirectives(noMentions, { modelAliases: configuredAliases, }); if (directiveOnlyCheck.cleaned.trim().length > 0) { parsedDirectives = clearInlineDirectives(parsedDirectives.cleaned); } } } const directives = commandAuthorized ? parsedDirectives : { ...parsedDirectives, hasThinkDirective: false, hasVerboseDirective: false, hasReasoningDirective: false, hasStatusDirective: false, hasModelDirective: false, hasQueueDirective: false, queueReset: false, }; sessionCtx.Body = parsedDirectives.cleaned; sessionCtx.BodyStripped = parsedDirectives.cleaned; const messageProviderKey = sessionCtx.Provider?.trim().toLowerCase() ?? ctx.Provider?.trim().toLowerCase() ?? ""; const elevatedConfig = cfg.tools?.elevated; const discordElevatedFallback = messageProviderKey === "discord" ? cfg.discord?.dm?.allowFrom : undefined; const elevatedEnabled = elevatedConfig?.enabled !== false; const elevatedAllowed = elevatedEnabled && Boolean( messageProviderKey && isApprovedElevatedSender({ provider: messageProviderKey, ctx, allowFrom: elevatedConfig?.allowFrom, discordFallback: discordElevatedFallback, }), ); if ( directives.hasElevatedDirective && (!elevatedEnabled || !elevatedAllowed) ) { typing.cleanup(); return { text: "elevated is not available right now." }; } const requireMention = resolveGroupRequireMention({ cfg, ctx: sessionCtx, groupResolution, }); const defaultActivation = defaultGroupActivation(requireMention); let resolvedThinkLevel = (directives.thinkLevel as ThinkLevel | undefined) ?? (sessionEntry?.thinkingLevel as ThinkLevel | undefined) ?? (agentCfg?.thinkingDefault as ThinkLevel | undefined); const resolvedVerboseLevel = (directives.verboseLevel as VerboseLevel | undefined) ?? (sessionEntry?.verboseLevel as VerboseLevel | undefined) ?? (agentCfg?.verboseDefault as VerboseLevel | undefined); const resolvedReasoningLevel: ReasoningLevel = (directives.reasoningLevel as ReasoningLevel | undefined) ?? (sessionEntry?.reasoningLevel as ReasoningLevel | undefined) ?? "off"; const resolvedElevatedLevel = elevatedAllowed ? ((directives.elevatedLevel as ElevatedLevel | undefined) ?? (sessionEntry?.elevatedLevel as ElevatedLevel | undefined) ?? (agentCfg?.elevatedDefault as ElevatedLevel | undefined) ?? "on") : "off"; const resolvedBlockStreaming = agentCfg?.blockStreamingDefault === "off" ? "off" : "on"; const resolvedBlockStreamingBreak: "text_end" | "message_end" = agentCfg?.blockStreamingBreak === "message_end" ? "message_end" : "text_end"; const blockStreamingEnabled = resolvedBlockStreaming === "on" && opts?.disableBlockStreaming !== true; const blockReplyChunking = blockStreamingEnabled ? resolveBlockStreamingChunking(cfg, sessionCtx.Provider) : undefined; const modelState = await createModelSelectionState({ cfg, agentCfg, sessionEntry, sessionStore, sessionKey, storePath, defaultProvider, defaultModel, provider, model, hasModelDirective: directives.hasModelDirective, }); provider = modelState.provider; model = modelState.model; let contextTokens = resolveContextTokens({ agentCfg, model, }); const initialModelLabel = `${provider}/${model}`; const formatModelSwitchEvent = (label: string, alias?: string) => alias ? `Model switched to ${alias} (${label}).` : `Model switched to ${label}.`; const isModelListAlias = directives.hasModelDirective && ["status", "list"].includes( directives.rawModelDirective?.trim().toLowerCase() ?? "", ); const effectiveModelDirective = isModelListAlias ? undefined : directives.rawModelDirective; const command = buildCommandContext({ ctx, cfg, agentId, sessionKey, isGroup, triggerBodyNormalized, commandAuthorized, }); const allowTextCommands = shouldHandleTextCommands({ cfg, surface: command.surface, commandSource: ctx.CommandSource, }); if ( isDirectiveOnly({ directives, cleanedBody: directives.cleaned, ctx, cfg, agentId, isGroup, }) ) { const currentThinkLevel = (sessionEntry?.thinkingLevel as ThinkLevel | undefined) ?? (agentCfg?.thinkingDefault as ThinkLevel | undefined); const currentVerboseLevel = (sessionEntry?.verboseLevel as VerboseLevel | undefined) ?? (agentCfg?.verboseDefault as VerboseLevel | undefined); const currentReasoningLevel = (sessionEntry?.reasoningLevel as ReasoningLevel | undefined) ?? "off"; const currentElevatedLevel = (sessionEntry?.elevatedLevel as ElevatedLevel | undefined) ?? (agentCfg?.elevatedDefault as ElevatedLevel | undefined); const directiveReply = await handleDirectiveOnly({ cfg, directives, sessionEntry, sessionStore, sessionKey, storePath, elevatedEnabled, elevatedAllowed, defaultProvider, defaultModel, aliasIndex, allowedModelKeys: modelState.allowedModelKeys, allowedModelCatalog: modelState.allowedModelCatalog, resetModelOverride: modelState.resetModelOverride, provider, model, initialModelLabel, formatModelSwitchEvent, currentThinkLevel, currentVerboseLevel, currentReasoningLevel, currentElevatedLevel, }); let statusReply: ReplyPayload | undefined; if (directives.hasStatusDirective && allowTextCommands) { statusReply = await buildStatusReply({ cfg, command, sessionEntry, sessionKey, sessionScope, provider, model, contextTokens, resolvedThinkLevel: currentThinkLevel ?? (agentCfg?.thinkingDefault as ThinkLevel | undefined), resolvedVerboseLevel: (currentVerboseLevel ?? "off") as VerboseLevel, resolvedReasoningLevel: (currentReasoningLevel ?? "off") as ReasoningLevel, resolvedElevatedLevel: currentElevatedLevel, resolveDefaultThinkingLevel: async () => currentThinkLevel ?? (agentCfg?.thinkingDefault as ThinkLevel | undefined), isGroup, defaultGroupActivation: () => defaultActivation, }); } typing.cleanup(); if (statusReply?.text && directiveReply?.text) { return { text: `${directiveReply.text}\n${statusReply.text}` }; } return statusReply ?? directiveReply; } const persisted = await persistInlineDirectives({ directives, effectiveModelDirective, cfg, agentDir, sessionEntry, sessionStore, sessionKey, storePath, elevatedEnabled, elevatedAllowed, defaultProvider, defaultModel, aliasIndex, allowedModelKeys: modelState.allowedModelKeys, provider, model, initialModelLabel, formatModelSwitchEvent, agentCfg, }); provider = persisted.provider; model = persisted.model; contextTokens = persisted.contextTokens; const perMessageQueueMode = directives.hasQueueDirective && !directives.queueReset ? directives.queueMode : undefined; const perMessageQueueOptions = directives.hasQueueDirective && !directives.queueReset ? { debounceMs: directives.debounceMs, cap: directives.cap, dropPolicy: directives.dropPolicy, } : undefined; const isEmptyConfig = Object.keys(cfg).length === 0; if ( command.isWhatsAppProvider && isEmptyConfig && command.from && command.to && command.from !== command.to ) { typing.cleanup(); return undefined; } if (!sessionEntry && command.abortKey) { abortedLastRun = getAbortMemory(command.abortKey) ?? false; } const commandResult = await handleCommands({ ctx, cfg, command, agentId, directives, sessionEntry, sessionStore, sessionKey, storePath, sessionScope, workspaceDir, defaultGroupActivation: () => defaultActivation, resolvedThinkLevel, resolvedVerboseLevel: resolvedVerboseLevel ?? "off", resolvedReasoningLevel, resolvedElevatedLevel, resolveDefaultThinkingLevel: modelState.resolveDefaultThinkingLevel, provider, model, contextTokens, isGroup, }); if (!commandResult.shouldContinue) { typing.cleanup(); return commandResult.reply; } await stageSandboxMedia({ ctx, sessionCtx, cfg, sessionKey, workspaceDir, }); const isFirstTurnInSession = isNewSession || !systemSent; const isGroupChat = sessionCtx.ChatType === "group"; const wasMentioned = ctx.WasMentioned === true; const isHeartbeat = opts?.isHeartbeat === true; const typingMode = resolveTypingMode({ configured: sessionCfg?.typingMode ?? agentCfg?.typingMode, isGroupChat, wasMentioned, isHeartbeat, }); const typingSignals = createTypingSignaler({ typing, mode: typingMode, isHeartbeat, }); const shouldInjectGroupIntro = Boolean( isGroupChat && (isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro), ); const groupIntro = shouldInjectGroupIntro ? buildGroupIntro({ sessionCtx, sessionEntry, defaultActivation, silentToken: SILENT_REPLY_TOKEN, }) : ""; const groupSystemPrompt = sessionCtx.GroupSystemPrompt?.trim() ?? ""; const extraSystemPrompt = [groupIntro, groupSystemPrompt] .filter(Boolean) .join("\n\n"); const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""; const rawBodyTrimmed = (ctx.Body ?? "").trim(); const baseBodyTrimmedRaw = baseBody.trim(); if ( allowTextCommands && !commandAuthorized && !baseBodyTrimmedRaw && hasControlCommand(rawBody) ) { typing.cleanup(); return undefined; } const isBareSessionReset = isNewSession && baseBodyTrimmedRaw.length === 0 && rawBodyTrimmed.length > 0; const baseBodyFinal = isBareSessionReset ? BARE_SESSION_RESET_PROMPT : baseBody; const baseBodyTrimmed = baseBodyFinal.trim(); if (!baseBodyTrimmed) { await typing.onReplyStart(); logVerbose("Inbound body empty after normalization; skipping agent run"); typing.cleanup(); return { text: "I didn't receive any text in your message. Please resend or add a caption.", }; } let prefixedBodyBase = await applySessionHints({ baseBody: baseBodyFinal, abortedLastRun, sessionEntry, sessionStore, sessionKey, storePath, abortKey: command.abortKey, messageId: sessionCtx.MessageSid, }); const isGroupSession = sessionEntry?.chatType === "group" || sessionEntry?.chatType === "room"; const isMainSession = !isGroupSession && sessionKey === (sessionCfg?.mainKey ?? "main"); prefixedBodyBase = await prependSystemEvents({ cfg, sessionKey, isMainSession, isNewSession, prefixedBodyBase, }); const threadStarterBody = ctx.ThreadStarterBody?.trim(); const threadStarterNote = isNewSession && threadStarterBody ? `[Thread starter - for context]\n${threadStarterBody}` : undefined; const skillResult = await ensureSkillSnapshot({ sessionEntry, sessionStore, sessionKey, storePath, sessionId, isFirstTurnInSession, workspaceDir, cfg, skillFilter: opts?.skillFilter, }); sessionEntry = skillResult.sessionEntry ?? sessionEntry; systemSent = skillResult.systemSent; const skillsSnapshot = skillResult.skillsSnapshot; const prefixedBody = transcribedText ? [threadStarterNote, prefixedBodyBase, `Transcript:\n${transcribedText}`] .filter(Boolean) .join("\n\n") : [threadStarterNote, prefixedBodyBase].filter(Boolean).join("\n\n"); const mediaNote = buildInboundMediaNote(ctx); const mediaReplyHint = mediaNote ? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body." : undefined; let commandBody = mediaNote ? [mediaNote, mediaReplyHint, prefixedBody ?? ""] .filter(Boolean) .join("\n") .trim() : prefixedBody; if (!resolvedThinkLevel && commandBody) { const parts = commandBody.split(/\s+/); const maybeLevel = normalizeThinkLevel(parts[0]); if (maybeLevel) { resolvedThinkLevel = maybeLevel; commandBody = parts.slice(1).join(" ").trim(); } } if (!resolvedThinkLevel) { resolvedThinkLevel = await modelState.resolveDefaultThinkingLevel(); } const sessionIdFinal = sessionId ?? crypto.randomUUID(); const sessionFile = resolveSessionFilePath(sessionIdFinal, sessionEntry); const queueBodyBase = transcribedText ? [threadStarterNote, baseBodyFinal, `Transcript:\n${transcribedText}`] .filter(Boolean) .join("\n\n") : [threadStarterNote, baseBodyFinal].filter(Boolean).join("\n\n"); const queuedBody = mediaNote ? [mediaNote, mediaReplyHint, queueBodyBase] .filter(Boolean) .join("\n") .trim() : queueBodyBase; const resolvedQueue = resolveQueueSettings({ cfg, provider: sessionCtx.Provider, sessionEntry, inlineMode: perMessageQueueMode, inlineOptions: perMessageQueueOptions, }); const sessionLaneKey = resolveEmbeddedSessionLane( sessionKey ?? sessionIdFinal, ); const laneSize = getQueueSize(sessionLaneKey); if (resolvedQueue.mode === "interrupt" && laneSize > 0) { const cleared = clearCommandLane(sessionLaneKey); const aborted = abortEmbeddedPiRun(sessionIdFinal); logVerbose( `Interrupting ${sessionLaneKey} (cleared ${cleared}, aborted=${aborted})`, ); } const queueKey = sessionKey ?? sessionIdFinal; const isActive = isEmbeddedPiRunActive(sessionIdFinal); const isStreaming = isEmbeddedPiRunStreaming(sessionIdFinal); const shouldSteer = resolvedQueue.mode === "steer" || resolvedQueue.mode === "steer-backlog"; const shouldFollowup = resolvedQueue.mode === "followup" || resolvedQueue.mode === "collect" || resolvedQueue.mode === "steer-backlog"; const authProfileId = sessionEntry?.authProfileOverride; const followupRun = { prompt: queuedBody, summaryLine: baseBodyTrimmedRaw, enqueuedAt: Date.now(), // Originating channel for reply routing. originatingChannel: ctx.OriginatingChannel, originatingTo: ctx.OriginatingTo, originatingAccountId: ctx.AccountId, originatingThreadId: ctx.MessageThreadId, run: { agentId, agentDir, sessionId: sessionIdFinal, sessionKey, messageProvider: sessionCtx.Provider?.trim().toLowerCase() || undefined, agentAccountId: sessionCtx.AccountId, sessionFile, workspaceDir, config: cfg, skillsSnapshot, provider, model, authProfileId, thinkLevel: resolvedThinkLevel, verboseLevel: resolvedVerboseLevel, reasoningLevel: resolvedReasoningLevel, elevatedLevel: resolvedElevatedLevel, bashElevated: { enabled: elevatedEnabled, allowed: elevatedAllowed, defaultLevel: resolvedElevatedLevel ?? "off", }, timeoutMs, blockReplyBreak: resolvedBlockStreamingBreak, ownerNumbers: command.ownerList.length > 0 ? command.ownerList : undefined, extraSystemPrompt: extraSystemPrompt || undefined, ...(provider === "ollama" ? { enforceFinalTag: true } : {}), }, }; if (typingSignals.shouldStartImmediately) { await typingSignals.signalRunStart(); } return runReplyAgent({ commandBody, followupRun, queueKey, resolvedQueue, shouldSteer, shouldFollowup, isActive, isStreaming, opts, typing, sessionEntry, sessionStore, sessionKey, storePath, defaultModel, agentCfgContextTokens: agentCfg?.contextTokens, resolvedVerboseLevel: resolvedVerboseLevel ?? "off", isNewSession, blockStreamingEnabled, blockReplyChunking, resolvedBlockStreamingBreak, sessionCtx, shouldInjectGroupIntro, typingMode, }); } async function stageSandboxMedia(params: { ctx: MsgContext; sessionCtx: TemplateContext; cfg: ClawdbotConfig; sessionKey?: string; workspaceDir: string; }) { const { ctx, sessionCtx, cfg, sessionKey, workspaceDir } = params; const hasPathsArray = Array.isArray(ctx.MediaPaths) && ctx.MediaPaths.length > 0; const pathsFromArray = Array.isArray(ctx.MediaPaths) ? ctx.MediaPaths : undefined; const rawPaths = pathsFromArray && pathsFromArray.length > 0 ? pathsFromArray : ctx.MediaPath?.trim() ? [ctx.MediaPath.trim()] : []; if (rawPaths.length === 0 || !sessionKey) return; const sandbox = await ensureSandboxWorkspaceForSession({ config: cfg, sessionKey, workspaceDir, }); if (!sandbox) return; const resolveAbsolutePath = (value: string): string | null => { let resolved = value.trim(); if (!resolved) return null; if (resolved.startsWith("file://")) { try { resolved = fileURLToPath(resolved); } catch { return null; } } if (!path.isAbsolute(resolved)) return null; return resolved; }; try { const destDir = path.join(sandbox.workspaceDir, "media", "inbound"); await fs.mkdir(destDir, { recursive: true }); const usedNames = new Set(); const staged = new Map(); // absolute source -> relative sandbox path for (const raw of rawPaths) { const source = resolveAbsolutePath(raw); if (!source) continue; if (staged.has(source)) continue; const baseName = path.basename(source); if (!baseName) continue; const parsed = path.parse(baseName); let fileName = baseName; let suffix = 1; while (usedNames.has(fileName)) { fileName = `${parsed.name}-${suffix}${parsed.ext}`; suffix += 1; } usedNames.add(fileName); const dest = path.join(destDir, fileName); await fs.copyFile(source, dest); const relative = path.posix.join("media", "inbound", fileName); staged.set(source, relative); } const rewriteIfStaged = (value: string | undefined): string | undefined => { const raw = value?.trim(); if (!raw) return value; const abs = resolveAbsolutePath(raw); if (!abs) return value; const mapped = staged.get(abs); return mapped ?? value; }; const nextMediaPaths = hasPathsArray ? rawPaths.map((p) => rewriteIfStaged(p) ?? p) : undefined; if (nextMediaPaths) { ctx.MediaPaths = nextMediaPaths; sessionCtx.MediaPaths = nextMediaPaths; ctx.MediaPath = nextMediaPaths[0]; sessionCtx.MediaPath = nextMediaPaths[0]; } else { const rewritten = rewriteIfStaged(ctx.MediaPath); if (rewritten && rewritten !== ctx.MediaPath) { ctx.MediaPath = rewritten; sessionCtx.MediaPath = rewritten; } } if (Array.isArray(ctx.MediaUrls) && ctx.MediaUrls.length > 0) { const nextUrls = ctx.MediaUrls.map((u) => rewriteIfStaged(u) ?? u); ctx.MediaUrls = nextUrls; sessionCtx.MediaUrls = nextUrls; } const rewrittenUrl = rewriteIfStaged(ctx.MediaUrl); if (rewrittenUrl && rewrittenUrl !== ctx.MediaUrl) { ctx.MediaUrl = rewrittenUrl; sessionCtx.MediaUrl = rewrittenUrl; } } catch (err) { logVerbose(`Failed to stage inbound media for sandbox: ${String(err)}`); } }