From ea018a68ccb92dbc735bc1df9880d5c95c63ca35 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 14 Jan 2026 09:11:16 +0000 Subject: [PATCH] refactor(auto-reply): split reply pipeline --- src/auto-reply/reply.ts | 1457 +---------------- .../reply/agent-runner-execution.ts | 475 ++++++ src/auto-reply/reply/agent-runner-helpers.ts | 60 + src/auto-reply/reply/agent-runner-memory.ts | 195 +++ src/auto-reply/reply/agent-runner-payloads.ts | 118 ++ src/auto-reply/reply/agent-runner-utils.ts | 122 ++ src/auto-reply/reply/agent-runner.ts | 845 +--------- src/auto-reply/reply/commands-bash.ts | 36 + src/auto-reply/reply/commands-compact.ts | 119 ++ src/auto-reply/reply/commands-config.ts | 255 +++ src/auto-reply/reply/commands-context.ts | 48 + src/auto-reply/reply/commands-core.ts | 81 + src/auto-reply/reply/commands-info.ts | 109 ++ src/auto-reply/reply/commands-session.ts | 252 +++ src/auto-reply/reply/commands-status.ts | 223 +++ src/auto-reply/reply/commands-types.ts | 65 + src/auto-reply/reply/commands.ts | 1076 +----------- .../reply/get-reply-directives-apply.ts | 309 ++++ .../reply/get-reply-directives-utils.ts | 33 + src/auto-reply/reply/get-reply-directives.ts | 473 ++++++ .../reply/get-reply-inline-actions.ts | 256 +++ src/auto-reply/reply/get-reply-run.ts | 425 +++++ src/auto-reply/reply/get-reply.ts | 272 +++ src/auto-reply/reply/reply-elevated.ts | 206 +++ src/auto-reply/reply/reply-inline.ts | 37 + src/auto-reply/reply/stage-sandbox-media.ts | 118 ++ 26 files changed, 4380 insertions(+), 3285 deletions(-) create mode 100644 src/auto-reply/reply/agent-runner-execution.ts create mode 100644 src/auto-reply/reply/agent-runner-helpers.ts create mode 100644 src/auto-reply/reply/agent-runner-memory.ts create mode 100644 src/auto-reply/reply/agent-runner-payloads.ts create mode 100644 src/auto-reply/reply/agent-runner-utils.ts create mode 100644 src/auto-reply/reply/commands-bash.ts create mode 100644 src/auto-reply/reply/commands-compact.ts create mode 100644 src/auto-reply/reply/commands-config.ts create mode 100644 src/auto-reply/reply/commands-context.ts create mode 100644 src/auto-reply/reply/commands-core.ts create mode 100644 src/auto-reply/reply/commands-info.ts create mode 100644 src/auto-reply/reply/commands-session.ts create mode 100644 src/auto-reply/reply/commands-status.ts create mode 100644 src/auto-reply/reply/commands-types.ts create mode 100644 src/auto-reply/reply/get-reply-directives-apply.ts create mode 100644 src/auto-reply/reply/get-reply-directives-utils.ts create mode 100644 src/auto-reply/reply/get-reply-directives.ts create mode 100644 src/auto-reply/reply/get-reply-inline-actions.ts create mode 100644 src/auto-reply/reply/get-reply-run.ts create mode 100644 src/auto-reply/reply/get-reply.ts create mode 100644 src/auto-reply/reply/reply-elevated.ts create mode 100644 src/auto-reply/reply/reply-inline.ts create mode 100644 src/auto-reply/reply/stage-sandbox-media.ts diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 0679d5420..dc52cae4c 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -1,1465 +1,10 @@ -import crypto from "node:crypto"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { - resolveAgentConfig, - resolveAgentDir, - resolveAgentWorkspaceDir, - resolveSessionAgentId, -} from "../agents/agent-scope.js"; -import { resolveModelRefFromString } from "../agents/model-selection.js"; -import { - abortEmbeddedPiRun, - isEmbeddedPiRunActive, - isEmbeddedPiRunStreaming, - resolveEmbeddedSessionLane, -} from "../agents/pi-embedded.js"; -import { - ensureSandboxWorkspaceForSession, - resolveSandboxRuntimeStatus, -} from "../agents/sandbox.js"; -import { resolveAgentTimeoutMs } from "../agents/timeout.js"; -import { - DEFAULT_AGENT_WORKSPACE_DIR, - ensureAgentWorkspace, -} from "../agents/workspace.js"; -import { getChannelDock } from "../channels/dock.js"; -import { - CHAT_CHANNEL_ORDER, - normalizeChannelId, -} from "../channels/registry.js"; -import { - type AgentElevatedAllowFromConfig, - type ClawdbotConfig, - loadConfig, -} from "../config/config.js"; -import { - resolveSessionFilePath, - saveSessionStore, -} from "../config/sessions.js"; -import { logVerbose } from "../globals.js"; -import { clearCommandLane, getQueueSize } from "../process/command-queue.js"; -import { normalizeMainKey } from "../routing/session-key.js"; -import { defaultRuntime } from "../runtime.js"; -import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js"; -import { isReasoningTagProvider } from "../utils/provider-utils.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 { - applyInlineDirectivesFastLane, - handleDirectiveOnly, - type InlineDirectives, - isDirectiveOnly, - parseInlineDirectives, - persistInlineDirectives, - resolveDefaultModel, -} from "./reply/directive-handling.js"; -import { - buildGroupIntro, - defaultGroupActivation, - resolveGroupRequireMention, -} from "./reply/groups.js"; -import { - CURRENT_MESSAGE_MARKER, - 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, - formatXHighModelHint, - normalizeThinkLevel, - type ReasoningLevel, - supportsXHighThinking, - type ThinkLevel, - type VerboseLevel, -} from "./thinking.js"; -import { SILENT_REPLY_TOKEN } from "./tokens.js"; -import { - hasAudioTranscriptionConfig, - isAudio, - transcribeInboundAudio, -} from "./transcription.js"; -import type { GetReplyOptions, ReplyPayload } from "./types.js"; - export { extractElevatedDirective, extractReasoningDirective, extractThinkDirective, extractVerboseDirective, } from "./reply/directives.js"; +export { getReplyFromConfig } from "./reply/get-reply.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, ""); -} - -const SENDER_PREFIXES = [ - ...CHAT_CHANNEL_ORDER, - INTERNAL_MESSAGE_CHANNEL, - "user", - "group", - "channel", -]; -const SENDER_PREFIX_RE = new RegExp(`^(${SENDER_PREFIXES.join("|")}):`, "i"); - -function stripSenderPrefix(value?: string) { - if (!value) return ""; - const trimmed = value.trim(); - return trimmed.replace(SENDER_PREFIX_RE, ""); -} - -const INLINE_SIMPLE_COMMAND_ALIASES = new Map([ - ["/help", "/help"], - ["/commands", "/commands"], - ["/whoami", "/whoami"], - ["/id", "/whoami"], -]); -const INLINE_SIMPLE_COMMAND_RE = - /(?:^|\s)\/(help|commands|whoami|id)(?=$|\s|:)/i; - -const INLINE_STATUS_RE = /(?:^|\s)\/(?:status|usage)(?=$|\s|:)(?:\s*:\s*)?/gi; - -function extractInlineSimpleCommand(body?: string): { - command: string; - cleaned: string; -} | null { - if (!body) return null; - const match = body.match(INLINE_SIMPLE_COMMAND_RE); - if (!match || match.index === undefined) return null; - const alias = `/${match[1].toLowerCase()}`; - const command = INLINE_SIMPLE_COMMAND_ALIASES.get(alias); - if (!command) return null; - const cleaned = body.replace(match[0], " ").replace(/\s+/g, " ").trim(); - return { command, cleaned }; -} - -function stripInlineStatus(body: string): { - cleaned: string; - didStrip: boolean; -} { - const trimmed = body.trim(); - if (!trimmed) return { cleaned: "", didStrip: false }; - const cleaned = trimmed - .replace(INLINE_STATUS_RE, " ") - .replace(/\s+/g, " ") - .trim(); - return { cleaned, didStrip: cleaned !== trimmed }; -} - -function resolveElevatedAllowList( - allowFrom: AgentElevatedAllowFromConfig | undefined, - provider: string, - fallbackAllowFrom?: Array, -): Array | undefined { - if (!allowFrom) return fallbackAllowFrom; - const value = allowFrom[provider]; - return Array.isArray(value) ? value : fallbackAllowFrom; -} - -function isApprovedElevatedSender(params: { - provider: string; - ctx: MsgContext; - allowFrom?: AgentElevatedAllowFromConfig; - fallbackAllowFrom?: Array; -}): boolean { - const rawAllow = resolveElevatedAllowList( - params.allowFrom, - params.provider, - params.fallbackAllowFrom, - ); - 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; -} - -function resolveElevatedPermissions(params: { - cfg: ClawdbotConfig; - agentId: string; - ctx: MsgContext; - provider: string; -}): { - enabled: boolean; - allowed: boolean; - failures: Array<{ gate: string; key: string }>; -} { - const globalConfig = params.cfg.tools?.elevated; - const agentConfig = resolveAgentConfig(params.cfg, params.agentId)?.tools - ?.elevated; - const globalEnabled = globalConfig?.enabled !== false; - const agentEnabled = agentConfig?.enabled !== false; - const enabled = globalEnabled && agentEnabled; - const failures: Array<{ gate: string; key: string }> = []; - if (!globalEnabled) - failures.push({ gate: "enabled", key: "tools.elevated.enabled" }); - if (!agentEnabled) - failures.push({ - gate: "enabled", - key: "agents.list[].tools.elevated.enabled", - }); - if (!enabled) return { enabled, allowed: false, failures }; - if (!params.provider) { - failures.push({ gate: "provider", key: "ctx.Provider" }); - return { enabled, allowed: false, failures }; - } - - const normalizedProvider = normalizeChannelId(params.provider); - const dockFallbackAllowFrom = normalizedProvider - ? getChannelDock(normalizedProvider)?.elevated?.allowFromFallback?.({ - cfg: params.cfg, - accountId: params.ctx.AccountId, - }) - : undefined; - const fallbackAllowFrom = dockFallbackAllowFrom; - const globalAllowed = isApprovedElevatedSender({ - provider: params.provider, - ctx: params.ctx, - allowFrom: globalConfig?.allowFrom, - fallbackAllowFrom, - }); - if (!globalAllowed) { - failures.push({ - gate: "allowFrom", - key: `tools.elevated.allowFrom.${params.provider}`, - }); - return { enabled, allowed: false, failures }; - } - - const agentAllowed = agentConfig?.allowFrom - ? isApprovedElevatedSender({ - provider: params.provider, - ctx: params.ctx, - allowFrom: agentConfig.allowFrom, - fallbackAllowFrom, - }) - : true; - if (!agentAllowed) { - failures.push({ - gate: "allowFrom", - key: `agents.list[].tools.elevated.allowFrom.${params.provider}`, - }); - } - return { enabled, allowed: globalAllowed && agentAllowed, failures }; -} - -function formatElevatedUnavailableMessage(params: { - runtimeSandboxed: boolean; - failures: Array<{ gate: string; key: string }>; - sessionKey?: string; -}): string { - const lines: string[] = []; - lines.push( - `elevated is not available right now (runtime=${params.runtimeSandboxed ? "sandboxed" : "direct"}).`, - ); - if (params.failures.length > 0) { - lines.push( - `Failing gates: ${params.failures - .map((f) => `${f.gate} (${f.key})`) - .join(", ")}`, - ); - } else { - lines.push( - "Failing gates: enabled (tools.elevated.enabled / agents.list[].tools.elevated.enabled), allowFrom (tools.elevated.allowFrom.).", - ); - } - lines.push("Fix-it keys:"); - lines.push("- tools.elevated.enabled"); - lines.push("- tools.elevated.allowFrom."); - lines.push("- agents.list[].tools.elevated.enabled"); - lines.push("- agents.list[].tools.elevated.allowFrom."); - if (params.sessionKey) { - lines.push(`See: clawdbot sandbox explain --session ${params.sessionKey}`); - } - return lines.join("\n"); -} - -export async function getReplyFromConfig( - ctx: MsgContext, - opts?: GetReplyOptions, - configOverride?: ClawdbotConfig, -): Promise { - const cfg = configOverride ?? loadConfig(); - const agentId = resolveSessionAgentId({ - sessionKey: ctx.SessionKey, - config: cfg, - }); - 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 (hasAudioTranscriptionConfig(cfg) && 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; - - // Prefer CommandBody/RawBody (clean message without structural context) for directive parsing. - // Keep `Body`/`BodyStripped` as the best-available prompt text (may include context). - const commandSource = - sessionCtx.CommandBody ?? - sessionCtx.RawBody ?? - sessionCtx.BodyStripped ?? - sessionCtx.Body ?? - ""; - const command = buildCommandContext({ - ctx, - cfg, - agentId, - sessionKey, - isGroup, - triggerBodyNormalized, - commandAuthorized, - }); - const allowTextCommands = shouldHandleTextCommands({ - cfg, - surface: command.surface, - commandSource: ctx.CommandSource, - }); - 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())); - const allowStatusDirective = allowTextCommands && command.isAuthorizedSender; - let parsedDirectives = parseInlineDirectives(commandSource, { - modelAliases: configuredAliases, - allowStatusDirective, - }); - const hasInlineStatus = - parsedDirectives.hasStatusDirective && - parsedDirectives.cleaned.trim().length > 0; - if (hasInlineStatus) { - parsedDirectives = { - ...parsedDirectives, - hasStatusDirective: false, - }; - } - if ( - isGroup && - ctx.WasMentioned !== true && - parsedDirectives.hasElevatedDirective - ) { - if (parsedDirectives.elevatedLevel !== "off") { - parsedDirectives = { - ...parsedDirectives, - hasElevatedDirective: false, - elevatedLevel: undefined, - rawElevatedLevel: undefined, - }; - } - } - const hasInlineDirective = - parsedDirectives.hasThinkDirective || - parsedDirectives.hasVerboseDirective || - parsedDirectives.hasReasoningDirective || - parsedDirectives.hasElevatedDirective || - parsedDirectives.hasModelDirective || - parsedDirectives.hasQueueDirective; - if (hasInlineDirective) { - 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) { - const allowInlineStatus = - parsedDirectives.hasStatusDirective && - allowTextCommands && - command.isAuthorizedSender; - parsedDirectives = allowInlineStatus - ? { - ...clearInlineDirectives(parsedDirectives.cleaned), - hasStatusDirective: true, - } - : clearInlineDirectives(parsedDirectives.cleaned); - } - } - } - let directives = commandAuthorized - ? parsedDirectives - : { - ...parsedDirectives, - hasThinkDirective: false, - hasVerboseDirective: false, - hasReasoningDirective: false, - hasStatusDirective: false, - hasModelDirective: false, - hasQueueDirective: false, - queueReset: false, - }; - const existingBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""; - let cleanedBody = (() => { - if (!existingBody) return parsedDirectives.cleaned; - if (!sessionCtx.CommandBody && !sessionCtx.RawBody) { - return parseInlineDirectives(existingBody, { - modelAliases: configuredAliases, - allowStatusDirective, - }).cleaned; - } - - const markerIndex = existingBody.indexOf(CURRENT_MESSAGE_MARKER); - if (markerIndex < 0) { - return parseInlineDirectives(existingBody, { - modelAliases: configuredAliases, - allowStatusDirective, - }).cleaned; - } - - const head = existingBody.slice( - 0, - markerIndex + CURRENT_MESSAGE_MARKER.length, - ); - const tail = existingBody.slice( - markerIndex + CURRENT_MESSAGE_MARKER.length, - ); - const cleanedTail = parseInlineDirectives(tail, { - modelAliases: configuredAliases, - allowStatusDirective, - }).cleaned; - return `${head}${cleanedTail}`; - })(); - - if (allowStatusDirective) { - cleanedBody = stripInlineStatus(cleanedBody).cleaned; - } - - sessionCtx.Body = cleanedBody; - sessionCtx.BodyStripped = cleanedBody; - - const messageProviderKey = - sessionCtx.Provider?.trim().toLowerCase() ?? - ctx.Provider?.trim().toLowerCase() ?? - ""; - const elevated = resolveElevatedPermissions({ - cfg, - agentId, - ctx, - provider: messageProviderKey, - }); - const elevatedEnabled = elevated.enabled; - const elevatedAllowed = elevated.allowed; - const elevatedFailures = elevated.failures; - if ( - directives.hasElevatedDirective && - (!elevatedEnabled || !elevatedAllowed) - ) { - typing.cleanup(); - const runtimeSandboxed = resolveSandboxRuntimeStatus({ - cfg, - sessionKey: ctx.SessionKey, - }).sandboxed; - return { - text: formatElevatedUnavailableMessage({ - runtimeSandboxed, - failures: elevatedFailures, - sessionKey: ctx.SessionKey, - }), - }; - } - - 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 = - opts?.disableBlockStreaming === true - ? "off" - : opts?.disableBlockStreaming === false - ? "on" - : agentCfg?.blockStreamingDefault === "on" - ? "on" - : "off"; - 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, - sessionCtx.AccountId, - ) - : 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 inlineStatusRequested = - hasInlineStatus && allowTextCommands && command.isAuthorizedSender; - - // Inline control directives should apply immediately, even when mixed with text. - let directiveAck: ReplyPayload | undefined; - - if (!command.isAuthorizedSender) { - directives = { - ...directives, - hasThinkDirective: false, - hasVerboseDirective: false, - hasReasoningDirective: false, - hasElevatedDirective: false, - hasStatusDirective: false, - hasModelDirective: false, - hasQueueDirective: false, - queueReset: false, - }; - } - - if ( - isDirectiveOnly({ - directives, - cleanedBody: directives.cleaned, - ctx, - cfg, - agentId, - isGroup, - }) - ) { - if (!command.isAuthorizedSender) { - typing.cleanup(); - return undefined; - } - const resolvedDefaultThinkLevel = - (sessionEntry?.thinkingLevel as ThinkLevel | undefined) ?? - (agentCfg?.thinkingDefault as ThinkLevel | undefined) ?? - (await modelState.resolveDefaultThinkingLevel()); - const currentThinkLevel = resolvedDefaultThinkLevel; - 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, - elevatedFailures, - messageProviderKey, - 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 && - command.isAuthorizedSender - ) { - statusReply = await buildStatusReply({ - cfg, - command, - sessionEntry, - sessionKey, - sessionScope, - provider, - model, - contextTokens, - resolvedThinkLevel: resolvedDefaultThinkLevel, - resolvedVerboseLevel: (currentVerboseLevel ?? "off") as VerboseLevel, - resolvedReasoningLevel: (currentReasoningLevel ?? - "off") as ReasoningLevel, - resolvedElevatedLevel, - resolveDefaultThinkingLevel: async () => resolvedDefaultThinkLevel, - isGroup, - defaultGroupActivation: () => defaultActivation, - }); - } - typing.cleanup(); - if (statusReply?.text && directiveReply?.text) { - return { text: `${directiveReply.text}\n${statusReply.text}` }; - } - return statusReply ?? directiveReply; - } - - const hasAnyDirective = - directives.hasThinkDirective || - directives.hasVerboseDirective || - directives.hasReasoningDirective || - directives.hasElevatedDirective || - directives.hasModelDirective || - directives.hasQueueDirective || - directives.hasStatusDirective; - - if (hasAnyDirective && command.isAuthorizedSender) { - const fastLane = await applyInlineDirectivesFastLane({ - directives, - commandAuthorized: command.isAuthorizedSender, - ctx, - cfg, - agentId, - isGroup, - sessionEntry, - sessionStore, - sessionKey, - storePath, - elevatedEnabled, - elevatedAllowed, - elevatedFailures, - messageProviderKey, - defaultProvider, - defaultModel, - aliasIndex, - allowedModelKeys: modelState.allowedModelKeys, - allowedModelCatalog: modelState.allowedModelCatalog, - resetModelOverride: modelState.resetModelOverride, - provider, - model, - initialModelLabel, - formatModelSwitchEvent, - agentCfg, - modelState: { - resolveDefaultThinkingLevel: modelState.resolveDefaultThinkingLevel, - allowedModelKeys: modelState.allowedModelKeys, - allowedModelCatalog: modelState.allowedModelCatalog, - resetModelOverride: modelState.resetModelOverride, - }, - }); - directiveAck = fastLane.directiveAck; - provider = fastLane.provider; - model = fastLane.model; - } - - 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 sendInlineReply = async (reply?: ReplyPayload) => { - if (!reply) return; - if (!opts?.onBlockReply) return; - await opts.onBlockReply(reply); - }; - - const inlineCommand = - allowTextCommands && command.isAuthorizedSender - ? extractInlineSimpleCommand(cleanedBody) - : null; - if (inlineCommand) { - cleanedBody = inlineCommand.cleaned; - sessionCtx.Body = cleanedBody; - sessionCtx.BodyStripped = cleanedBody; - } - - const handleInlineStatus = - !isDirectiveOnly({ - directives, - cleanedBody: directives.cleaned, - ctx, - cfg, - agentId, - isGroup, - }) && inlineStatusRequested; - if (handleInlineStatus) { - const inlineStatusReply = await buildStatusReply({ - cfg, - command, - sessionEntry, - sessionKey, - sessionScope, - provider, - model, - contextTokens, - resolvedThinkLevel, - resolvedVerboseLevel: resolvedVerboseLevel ?? "off", - resolvedReasoningLevel, - resolvedElevatedLevel, - resolveDefaultThinkingLevel: modelState.resolveDefaultThinkingLevel, - isGroup, - defaultGroupActivation: () => defaultActivation, - }); - await sendInlineReply(inlineStatusReply); - directives = { ...directives, hasStatusDirective: false }; - } - - if (inlineCommand) { - const inlineCommandContext = { - ...command, - rawBodyNormalized: inlineCommand.command, - commandBodyNormalized: inlineCommand.command, - }; - const inlineResult = await handleCommands({ - ctx, - cfg, - command: inlineCommandContext, - agentId, - directives, - elevated: { - enabled: elevatedEnabled, - allowed: elevatedAllowed, - failures: elevatedFailures, - }, - sessionEntry, - sessionStore, - sessionKey, - storePath, - sessionScope, - workspaceDir, - defaultGroupActivation: () => defaultActivation, - resolvedThinkLevel, - resolvedVerboseLevel: resolvedVerboseLevel ?? "off", - resolvedReasoningLevel, - resolvedElevatedLevel, - resolveDefaultThinkingLevel: modelState.resolveDefaultThinkingLevel, - provider, - model, - contextTokens, - isGroup, - }); - if (inlineResult.reply) { - if (!inlineCommand.cleaned) { - typing.cleanup(); - return inlineResult.reply; - } - await sendInlineReply(inlineResult.reply); - } - } - - if (directiveAck) { - await sendInlineReply(directiveAck); - } - - const isEmptyConfig = Object.keys(cfg).length === 0; - const skipWhenConfigEmpty = command.channelId - ? Boolean(getChannelDock(command.channelId)?.commands?.skipWhenConfigEmpty) - : false; - if ( - skipWhenConfigEmpty && - 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, - elevated: { - enabled: elevatedEnabled, - allowed: elevatedAllowed, - failures: elevatedFailures, - }, - 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({ - cfg, - 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 ?? ""; - // Use CommandBody/RawBody for bare reset detection (clean message without structural context). - const rawBodyTrimmed = ( - ctx.CommandBody ?? - ctx.RawBody ?? - ctx.Body ?? - "" - ).trim(); - const baseBodyTrimmedRaw = baseBody.trim(); - if ( - allowTextCommands && - (!commandAuthorized || !command.isAuthorizedSender) && - !baseBodyTrimmedRaw && - hasControlCommand(commandSource, cfg) - ) { - 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 === normalizeMainKey(sessionCfg?.mainKey); - 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 prefixedCommandBody = mediaNote - ? [mediaNote, mediaReplyHint, prefixedBody ?? ""] - .filter(Boolean) - .join("\n") - .trim() - : prefixedBody; - if (!resolvedThinkLevel && prefixedCommandBody) { - const parts = prefixedCommandBody.split(/\s+/); - const maybeLevel = normalizeThinkLevel(parts[0]); - if ( - maybeLevel && - (maybeLevel !== "xhigh" || supportsXHighThinking(provider, model)) - ) { - resolvedThinkLevel = maybeLevel; - prefixedCommandBody = parts.slice(1).join(" ").trim(); - } - } - if (!resolvedThinkLevel) { - resolvedThinkLevel = await modelState.resolveDefaultThinkingLevel(); - } - if ( - resolvedThinkLevel === "xhigh" && - !supportsXHighThinking(provider, model) - ) { - const explicitThink = - directives.hasThinkDirective && directives.thinkLevel !== undefined; - if (explicitThink) { - typing.cleanup(); - return { - text: `Thinking level "xhigh" is only supported for ${formatXHighModelHint()}. Use /think high or switch to one of those models.`, - }; - } - resolvedThinkLevel = "high"; - if ( - sessionEntry && - sessionStore && - sessionKey && - sessionEntry.thinkingLevel === "xhigh" - ) { - sessionEntry.thinkingLevel = "high"; - sessionEntry.updatedAt = Date.now(); - sessionStore[sessionKey] = sessionEntry; - if (storePath) { - await saveSessionStore(storePath, sessionStore); - } - } - } - 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, - channel: 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, - messageId: sessionCtx.MessageSid, - 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, - ...(isReasoningTagProvider(provider) ? { enforceFinalTag: true } : {}), - }, - }; - - if (typingSignals.shouldStartImmediately) { - await typingSignals.signalRunStart(); - } - - return runReplyAgent({ - commandBody: prefixedCommandBody, - 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)}`); - } -} diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts new file mode 100644 index 000000000..648359cb8 --- /dev/null +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -0,0 +1,475 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js"; +import { runCliAgent } from "../../agents/cli-runner.js"; +import { getCliSessionId } from "../../agents/cli-session.js"; +import { runWithModelFallback } from "../../agents/model-fallback.js"; +import { isCliProvider } from "../../agents/model-selection.js"; +import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; +import { + isCompactionFailureError, + isContextOverflowError, +} from "../../agents/pi-embedded-helpers.js"; +import { + resolveAgentIdFromSessionKey, + resolveSessionTranscriptPath, + type SessionEntry, + saveSessionStore, +} from "../../config/sessions.js"; +import { logVerbose } from "../../globals.js"; +import { + emitAgentEvent, + registerAgentRunContext, +} from "../../infra/agent-events.js"; +import { defaultRuntime } from "../../runtime.js"; +import { stripHeartbeatToken } from "../heartbeat.js"; +import type { TemplateContext } from "../templating.js"; +import type { VerboseLevel } from "../thinking.js"; +import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js"; +import type { GetReplyOptions, ReplyPayload } from "../types.js"; +import { + buildThreadingToolContext, + resolveEnforceFinalTag, +} from "./agent-runner-utils.js"; +import type { BlockReplyPipeline } from "./block-reply-pipeline.js"; +import type { FollowupRun } from "./queue.js"; +import { parseReplyDirectives } from "./reply-directives.js"; +import { + applyReplyTagsToPayload, + isRenderablePayload, +} from "./reply-payloads.js"; +import type { TypingSignaler } from "./typing-mode.js"; + +export type AgentRunLoopResult = + | { + kind: "success"; + runResult: Awaited>; + fallbackProvider?: string; + fallbackModel?: string; + didLogHeartbeatStrip: boolean; + autoCompactionCompleted: boolean; + } + | { kind: "final"; payload: ReplyPayload }; + +export async function runAgentTurnWithFallback(params: { + commandBody: string; + followupRun: FollowupRun; + sessionCtx: TemplateContext; + opts?: GetReplyOptions; + typingSignals: TypingSignaler; + blockReplyPipeline: BlockReplyPipeline | null; + blockStreamingEnabled: boolean; + blockReplyChunking?: { + minChars: number; + maxChars: number; + breakPreference: "paragraph" | "newline" | "sentence"; + }; + resolvedBlockStreamingBreak: "text_end" | "message_end"; + applyReplyToMode: (payload: ReplyPayload) => ReplyPayload; + shouldEmitToolResult: () => boolean; + pendingToolTasks: Set>; + resetSessionAfterCompactionFailure: (reason: string) => Promise; + isHeartbeat: boolean; + sessionKey?: string; + getActiveSessionEntry: () => SessionEntry | undefined; + activeSessionStore?: Record; + storePath?: string; + resolvedVerboseLevel: VerboseLevel; +}): Promise { + let didLogHeartbeatStrip = false; + let autoCompactionCompleted = false; + + const runId = crypto.randomUUID(); + if (params.sessionKey) { + registerAgentRunContext(runId, { + sessionKey: params.sessionKey, + verboseLevel: params.resolvedVerboseLevel, + }); + } + let runResult: Awaited>; + let fallbackProvider = params.followupRun.run.provider; + let fallbackModel = params.followupRun.run.model; + let didResetAfterCompactionFailure = false; + + while (true) { + try { + const allowPartialStream = !( + params.followupRun.run.reasoningLevel === "stream" && + params.opts?.onReasoningStream + ); + const normalizeStreamingText = ( + payload: ReplyPayload, + ): { text?: string; skip: boolean } => { + if (!allowPartialStream) return { skip: true }; + let text = payload.text; + if (!params.isHeartbeat && text?.includes("HEARTBEAT_OK")) { + const stripped = stripHeartbeatToken(text, { + mode: "message", + }); + if (stripped.didStrip && !didLogHeartbeatStrip) { + didLogHeartbeatStrip = true; + logVerbose("Stripped stray HEARTBEAT_OK token from reply"); + } + if (stripped.shouldSkip && (payload.mediaUrls?.length ?? 0) === 0) { + return { skip: true }; + } + text = stripped.text; + } + if (isSilentReplyText(text, SILENT_REPLY_TOKEN)) { + return { skip: true }; + } + return { text, skip: false }; + }; + const handlePartialForTyping = async ( + payload: ReplyPayload, + ): Promise => { + const { text, skip } = normalizeStreamingText(payload); + if (skip || !text) return undefined; + await params.typingSignals.signalTextDelta(text); + return text; + }; + const blockReplyPipeline = params.blockReplyPipeline; + const onToolResult = params.opts?.onToolResult; + const fallbackResult = await runWithModelFallback({ + cfg: params.followupRun.run.config, + provider: params.followupRun.run.provider, + model: params.followupRun.run.model, + fallbacksOverride: resolveAgentModelFallbacksOverride( + params.followupRun.run.config, + resolveAgentIdFromSessionKey(params.followupRun.run.sessionKey), + ), + run: (provider, model) => { + if (isCliProvider(provider, params.followupRun.run.config)) { + const startedAt = Date.now(); + emitAgentEvent({ + runId, + stream: "lifecycle", + data: { + phase: "start", + startedAt, + }, + }); + const cliSessionId = getCliSessionId( + params.getActiveSessionEntry(), + provider, + ); + return runCliAgent({ + sessionId: params.followupRun.run.sessionId, + sessionKey: params.sessionKey, + sessionFile: params.followupRun.run.sessionFile, + workspaceDir: params.followupRun.run.workspaceDir, + config: params.followupRun.run.config, + prompt: params.commandBody, + provider, + model, + thinkLevel: params.followupRun.run.thinkLevel, + timeoutMs: params.followupRun.run.timeoutMs, + runId, + extraSystemPrompt: params.followupRun.run.extraSystemPrompt, + ownerNumbers: params.followupRun.run.ownerNumbers, + cliSessionId, + }) + .then((result) => { + emitAgentEvent({ + runId, + stream: "lifecycle", + data: { + phase: "end", + startedAt, + endedAt: Date.now(), + }, + }); + return result; + }) + .catch((err) => { + emitAgentEvent({ + runId, + stream: "lifecycle", + data: { + phase: "error", + startedAt, + endedAt: Date.now(), + error: err instanceof Error ? err.message : String(err), + }, + }); + throw err; + }); + } + return runEmbeddedPiAgent({ + sessionId: params.followupRun.run.sessionId, + sessionKey: params.sessionKey, + messageProvider: + params.sessionCtx.Provider?.trim().toLowerCase() || undefined, + agentAccountId: params.sessionCtx.AccountId, + // Provider threading context for tool auto-injection + ...buildThreadingToolContext({ + sessionCtx: params.sessionCtx, + config: params.followupRun.run.config, + hasRepliedRef: params.opts?.hasRepliedRef, + }), + sessionFile: params.followupRun.run.sessionFile, + workspaceDir: params.followupRun.run.workspaceDir, + agentDir: params.followupRun.run.agentDir, + config: params.followupRun.run.config, + skillsSnapshot: params.followupRun.run.skillsSnapshot, + prompt: params.commandBody, + extraSystemPrompt: params.followupRun.run.extraSystemPrompt, + ownerNumbers: params.followupRun.run.ownerNumbers, + enforceFinalTag: resolveEnforceFinalTag( + params.followupRun.run, + provider, + ), + provider, + model, + authProfileId: params.followupRun.run.authProfileId, + thinkLevel: params.followupRun.run.thinkLevel, + verboseLevel: params.followupRun.run.verboseLevel, + reasoningLevel: params.followupRun.run.reasoningLevel, + bashElevated: params.followupRun.run.bashElevated, + timeoutMs: params.followupRun.run.timeoutMs, + runId, + blockReplyBreak: params.resolvedBlockStreamingBreak, + blockReplyChunking: params.blockReplyChunking, + onPartialReply: allowPartialStream + ? async (payload) => { + const textForTyping = await handlePartialForTyping(payload); + if ( + !params.opts?.onPartialReply || + textForTyping === undefined + ) + return; + await params.opts.onPartialReply({ + text: textForTyping, + mediaUrls: payload.mediaUrls, + }); + } + : undefined, + onAssistantMessageStart: async () => { + await params.typingSignals.signalMessageStart(); + }, + onReasoningStream: + params.typingSignals.shouldStartOnReasoning || + params.opts?.onReasoningStream + ? async (payload) => { + await params.typingSignals.signalReasoningDelta(); + await params.opts?.onReasoningStream?.({ + text: payload.text, + mediaUrls: payload.mediaUrls, + }); + } + : undefined, + onAgentEvent: (evt) => { + // Trigger typing when tools start executing + if (evt.stream === "tool") { + const phase = + typeof evt.data.phase === "string" ? evt.data.phase : ""; + if (phase === "start" || phase === "update") { + void params.typingSignals.signalToolStart(); + } + } + // Track auto-compaction completion + if (evt.stream === "compaction") { + const phase = + typeof evt.data.phase === "string" ? evt.data.phase : ""; + const willRetry = Boolean(evt.data.willRetry); + if (phase === "end" && !willRetry) { + autoCompactionCompleted = true; + } + } + }, + onBlockReply: + params.blockStreamingEnabled && params.opts?.onBlockReply + ? async (payload) => { + const { text, skip } = normalizeStreamingText(payload); + const hasPayloadMedia = + (payload.mediaUrls?.length ?? 0) > 0; + if (skip && !hasPayloadMedia) return; + const taggedPayload = applyReplyTagsToPayload( + { + text, + mediaUrls: payload.mediaUrls, + mediaUrl: payload.mediaUrls?.[0], + }, + params.sessionCtx.MessageSid, + ); + // Let through payloads with audioAsVoice flag even if empty (need to track it) + if ( + !isRenderablePayload(taggedPayload) && + !payload.audioAsVoice + ) + return; + const parsed = parseReplyDirectives( + taggedPayload.text ?? "", + { + currentMessageId: params.sessionCtx.MessageSid, + silentToken: SILENT_REPLY_TOKEN, + }, + ); + const cleaned = parsed.text || undefined; + const hasRenderableMedia = + Boolean(taggedPayload.mediaUrl) || + (taggedPayload.mediaUrls?.length ?? 0) > 0; + // Skip empty payloads unless they have audioAsVoice flag (need to track it) + if ( + !cleaned && + !hasRenderableMedia && + !payload.audioAsVoice && + !parsed.audioAsVoice + ) + return; + if (parsed.isSilent && !hasRenderableMedia) return; + + const blockPayload: ReplyPayload = params.applyReplyToMode({ + ...taggedPayload, + text: cleaned, + audioAsVoice: Boolean( + parsed.audioAsVoice || payload.audioAsVoice, + ), + replyToId: taggedPayload.replyToId ?? parsed.replyToId, + replyToTag: taggedPayload.replyToTag || parsed.replyToTag, + replyToCurrent: + taggedPayload.replyToCurrent || parsed.replyToCurrent, + }); + + void params.typingSignals + .signalTextDelta(cleaned ?? taggedPayload.text) + .catch((err) => { + logVerbose( + `block reply typing signal failed: ${String(err)}`, + ); + }); + + params.blockReplyPipeline?.enqueue(blockPayload); + } + : undefined, + onBlockReplyFlush: + params.blockStreamingEnabled && blockReplyPipeline + ? async () => { + await blockReplyPipeline.flush({ force: true }); + } + : undefined, + shouldEmitToolResult: params.shouldEmitToolResult, + onToolResult: onToolResult + ? (payload) => { + // `subscribeEmbeddedPiSession` may invoke tool callbacks without awaiting them. + // If a tool callback starts typing after the run finalized, we can end up with + // a typing loop that never sees a matching markRunComplete(). Track and drain. + const task = (async () => { + const { text, skip } = normalizeStreamingText(payload); + if (skip) return; + await params.typingSignals.signalTextDelta(text); + await onToolResult({ + text, + mediaUrls: payload.mediaUrls, + }); + })() + .catch((err) => { + logVerbose(`tool result delivery failed: ${String(err)}`); + }) + .finally(() => { + params.pendingToolTasks.delete(task); + }); + params.pendingToolTasks.add(task); + } + : undefined, + }); + }, + }); + runResult = fallbackResult.result; + fallbackProvider = fallbackResult.provider; + fallbackModel = fallbackResult.model; + + // Some embedded runs surface context overflow as an error payload instead of throwing. + // Treat those as a session-level failure and auto-recover by starting a fresh session. + const embeddedError = runResult.meta?.error; + if ( + embeddedError && + isContextOverflowError(embeddedError.message) && + !didResetAfterCompactionFailure && + (await params.resetSessionAfterCompactionFailure(embeddedError.message)) + ) { + didResetAfterCompactionFailure = true; + continue; + } + + break; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const isContextOverflow = + isContextOverflowError(message) || + /context.*overflow|too large|context window/i.test(message); + const isCompactionFailure = isCompactionFailureError(message); + const isSessionCorruption = + /function call turn comes immediately after/i.test(message); + + if ( + isCompactionFailure && + !didResetAfterCompactionFailure && + (await params.resetSessionAfterCompactionFailure(message)) + ) { + didResetAfterCompactionFailure = true; + continue; + } + + // Auto-recover from Gemini session corruption by resetting the session + if ( + isSessionCorruption && + params.sessionKey && + params.activeSessionStore && + params.storePath + ) { + const corruptedSessionId = params.getActiveSessionEntry()?.sessionId; + defaultRuntime.error( + `Session history corrupted (Gemini function call ordering). Resetting session: ${params.sessionKey}`, + ); + + try { + // Delete transcript file if it exists + if (corruptedSessionId) { + const transcriptPath = + resolveSessionTranscriptPath(corruptedSessionId); + try { + fs.unlinkSync(transcriptPath); + } catch { + // Ignore if file doesn't exist + } + } + + // Remove session entry from store + delete params.activeSessionStore[params.sessionKey]; + await saveSessionStore(params.storePath, params.activeSessionStore); + } catch (cleanupErr) { + defaultRuntime.error( + `Failed to reset corrupted session ${params.sessionKey}: ${String(cleanupErr)}`, + ); + } + + return { + kind: "final", + payload: { + text: "⚠️ Session history was corrupted. I've reset the conversation - please try again!", + }, + }; + } + + defaultRuntime.error(`Embedded agent failed before reply: ${message}`); + return { + kind: "final", + payload: { + text: isContextOverflow + ? "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model." + : `⚠️ Agent failed before reply: ${message}. Check gateway logs for details.`, + }, + }; + } + } + + return { + kind: "success", + runResult, + fallbackProvider, + fallbackModel, + didLogHeartbeatStrip, + autoCompactionCompleted, + }; +} diff --git a/src/auto-reply/reply/agent-runner-helpers.ts b/src/auto-reply/reply/agent-runner-helpers.ts new file mode 100644 index 000000000..402a860af --- /dev/null +++ b/src/auto-reply/reply/agent-runner-helpers.ts @@ -0,0 +1,60 @@ +import { loadSessionStore } from "../../config/sessions.js"; +import { isAudioFileName } from "../../media/mime.js"; +import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js"; +import type { ReplyPayload } from "../types.js"; +import { scheduleFollowupDrain } from "./queue.js"; +import type { TypingSignaler } from "./typing-mode.js"; + +const hasAudioMedia = (urls?: string[]): boolean => + Boolean(urls?.some((url) => isAudioFileName(url))); + +export const isAudioPayload = (payload: ReplyPayload): boolean => + hasAudioMedia( + payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined), + ); + +export const createShouldEmitToolResult = (params: { + sessionKey?: string; + storePath?: string; + resolvedVerboseLevel: VerboseLevel; +}): (() => boolean) => { + return () => { + if (!params.sessionKey || !params.storePath) { + return params.resolvedVerboseLevel === "on"; + } + try { + const store = loadSessionStore(params.storePath); + const entry = store[params.sessionKey]; + const current = normalizeVerboseLevel(entry?.verboseLevel); + if (current) return current === "on"; + } catch { + // ignore store read failures + } + return params.resolvedVerboseLevel === "on"; + }; +}; + +export const finalizeWithFollowup = ( + value: T, + queueKey: string, + runFollowupTurn: Parameters[1], +): T => { + scheduleFollowupDrain(queueKey, runFollowupTurn); + return value; +}; + +export const signalTypingIfNeeded = async ( + payloads: ReplyPayload[], + typingSignals: TypingSignaler, +): Promise => { + const shouldSignalTyping = payloads.some((payload) => { + const trimmed = payload.text?.trim(); + if (trimmed) return true; + if (payload.mediaUrl) return true; + if (payload.mediaUrls && payload.mediaUrls.length > 0) return true; + return false; + }); + if (shouldSignalTyping) { + await typingSignals.signalRunStart(); + } +}; diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts new file mode 100644 index 000000000..224b8ffbd --- /dev/null +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -0,0 +1,195 @@ +import crypto from "node:crypto"; +import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js"; +import { runWithModelFallback } from "../../agents/model-fallback.js"; +import { isCliProvider } from "../../agents/model-selection.js"; +import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; +import { + resolveSandboxConfigForAgent, + resolveSandboxRuntimeStatus, +} from "../../agents/sandbox.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { + resolveAgentIdFromSessionKey, + type SessionEntry, + updateSessionStoreEntry, +} from "../../config/sessions.js"; +import { logVerbose } from "../../globals.js"; +import { registerAgentRunContext } from "../../infra/agent-events.js"; +import type { TemplateContext } from "../templating.js"; +import type { VerboseLevel } from "../thinking.js"; +import type { GetReplyOptions } from "../types.js"; +import { + buildThreadingToolContext, + resolveEnforceFinalTag, +} from "./agent-runner-utils.js"; +import { + resolveMemoryFlushContextWindowTokens, + resolveMemoryFlushSettings, + shouldRunMemoryFlush, +} from "./memory-flush.js"; +import type { FollowupRun } from "./queue.js"; +import { incrementCompactionCount } from "./session-updates.js"; + +export async function runMemoryFlushIfNeeded(params: { + cfg: ClawdbotConfig; + followupRun: FollowupRun; + sessionCtx: TemplateContext; + opts?: GetReplyOptions; + defaultModel: string; + agentCfgContextTokens?: number; + resolvedVerboseLevel: VerboseLevel; + sessionEntry?: SessionEntry; + sessionStore?: Record; + sessionKey?: string; + storePath?: string; + isHeartbeat: boolean; +}): Promise { + const memoryFlushSettings = resolveMemoryFlushSettings(params.cfg); + if (!memoryFlushSettings) return params.sessionEntry; + + const memoryFlushWritable = (() => { + if (!params.sessionKey) return true; + const runtime = resolveSandboxRuntimeStatus({ + cfg: params.cfg, + sessionKey: params.sessionKey, + }); + if (!runtime.sandboxed) return true; + const sandboxCfg = resolveSandboxConfigForAgent( + params.cfg, + runtime.agentId, + ); + return sandboxCfg.workspaceAccess === "rw"; + })(); + + const shouldFlushMemory = + memoryFlushSettings && + memoryFlushWritable && + !params.isHeartbeat && + !isCliProvider(params.followupRun.run.provider, params.cfg) && + shouldRunMemoryFlush({ + entry: + params.sessionEntry ?? + (params.sessionKey + ? params.sessionStore?.[params.sessionKey] + : undefined), + contextWindowTokens: resolveMemoryFlushContextWindowTokens({ + modelId: params.followupRun.run.model ?? params.defaultModel, + agentCfgContextTokens: params.agentCfgContextTokens, + }), + reserveTokensFloor: memoryFlushSettings.reserveTokensFloor, + softThresholdTokens: memoryFlushSettings.softThresholdTokens, + }); + + if (!shouldFlushMemory) return params.sessionEntry; + + let activeSessionEntry = params.sessionEntry; + const activeSessionStore = params.sessionStore; + const flushRunId = crypto.randomUUID(); + if (params.sessionKey) { + registerAgentRunContext(flushRunId, { + sessionKey: params.sessionKey, + verboseLevel: params.resolvedVerboseLevel, + }); + } + let memoryCompactionCompleted = false; + const flushSystemPrompt = [ + params.followupRun.run.extraSystemPrompt, + memoryFlushSettings.systemPrompt, + ] + .filter(Boolean) + .join("\n\n"); + try { + await runWithModelFallback({ + cfg: params.followupRun.run.config, + provider: params.followupRun.run.provider, + model: params.followupRun.run.model, + fallbacksOverride: resolveAgentModelFallbacksOverride( + params.followupRun.run.config, + resolveAgentIdFromSessionKey(params.followupRun.run.sessionKey), + ), + run: (provider, model) => + runEmbeddedPiAgent({ + sessionId: params.followupRun.run.sessionId, + sessionKey: params.sessionKey, + messageProvider: + params.sessionCtx.Provider?.trim().toLowerCase() || undefined, + agentAccountId: params.sessionCtx.AccountId, + // Provider threading context for tool auto-injection + ...buildThreadingToolContext({ + sessionCtx: params.sessionCtx, + config: params.followupRun.run.config, + hasRepliedRef: params.opts?.hasRepliedRef, + }), + sessionFile: params.followupRun.run.sessionFile, + workspaceDir: params.followupRun.run.workspaceDir, + agentDir: params.followupRun.run.agentDir, + config: params.followupRun.run.config, + skillsSnapshot: params.followupRun.run.skillsSnapshot, + prompt: memoryFlushSettings.prompt, + extraSystemPrompt: flushSystemPrompt, + ownerNumbers: params.followupRun.run.ownerNumbers, + enforceFinalTag: resolveEnforceFinalTag( + params.followupRun.run, + provider, + ), + provider, + model, + authProfileId: params.followupRun.run.authProfileId, + thinkLevel: params.followupRun.run.thinkLevel, + verboseLevel: params.followupRun.run.verboseLevel, + reasoningLevel: params.followupRun.run.reasoningLevel, + bashElevated: params.followupRun.run.bashElevated, + timeoutMs: params.followupRun.run.timeoutMs, + runId: flushRunId, + onAgentEvent: (evt) => { + if (evt.stream === "compaction") { + const phase = + typeof evt.data.phase === "string" ? evt.data.phase : ""; + const willRetry = Boolean(evt.data.willRetry); + if (phase === "end" && !willRetry) { + memoryCompactionCompleted = true; + } + } + }, + }), + }); + let memoryFlushCompactionCount = + activeSessionEntry?.compactionCount ?? + (params.sessionKey + ? activeSessionStore?.[params.sessionKey]?.compactionCount + : 0) ?? + 0; + if (memoryCompactionCompleted) { + const nextCount = await incrementCompactionCount({ + sessionEntry: activeSessionEntry, + sessionStore: activeSessionStore, + sessionKey: params.sessionKey, + storePath: params.storePath, + }); + if (typeof nextCount === "number") { + memoryFlushCompactionCount = nextCount; + } + } + if (params.storePath && params.sessionKey) { + try { + const updatedEntry = await updateSessionStoreEntry({ + storePath: params.storePath, + sessionKey: params.sessionKey, + update: async () => ({ + memoryFlushAt: Date.now(), + memoryFlushCompactionCount, + }), + }); + if (updatedEntry) { + activeSessionEntry = updatedEntry; + } + } catch (err) { + logVerbose(`failed to persist memory flush metadata: ${String(err)}`); + } + } + } catch (err) { + logVerbose(`memory flush run failed: ${String(err)}`); + } + + return activeSessionEntry; +} diff --git a/src/auto-reply/reply/agent-runner-payloads.ts b/src/auto-reply/reply/agent-runner-payloads.ts new file mode 100644 index 000000000..caf0a237e --- /dev/null +++ b/src/auto-reply/reply/agent-runner-payloads.ts @@ -0,0 +1,118 @@ +import type { ReplyToMode } from "../../config/types.js"; +import { logVerbose } from "../../globals.js"; +import { stripHeartbeatToken } from "../heartbeat.js"; +import type { OriginatingChannelType } from "../templating.js"; +import { SILENT_REPLY_TOKEN } from "../tokens.js"; +import type { ReplyPayload } from "../types.js"; +import { + formatBunFetchSocketError, + isBunFetchSocketError, +} from "./agent-runner-utils.js"; +import type { BlockReplyPipeline } from "./block-reply-pipeline.js"; +import { parseReplyDirectives } from "./reply-directives.js"; +import { + applyReplyThreading, + filterMessagingToolDuplicates, + isRenderablePayload, + shouldSuppressMessagingToolReplies, +} from "./reply-payloads.js"; + +export function buildReplyPayloads(params: { + payloads: ReplyPayload[]; + isHeartbeat: boolean; + didLogHeartbeatStrip: boolean; + blockStreamingEnabled: boolean; + blockReplyPipeline: BlockReplyPipeline | null; + replyToMode: ReplyToMode; + replyToChannel?: OriginatingChannelType; + currentMessageId?: string; + messageProvider?: string; + messagingToolSentTexts?: string[]; + messagingToolSentTargets?: Parameters< + typeof shouldSuppressMessagingToolReplies + >[0]["messagingToolSentTargets"]; + originatingTo?: string; + accountId?: string; +}): { replyPayloads: ReplyPayload[]; didLogHeartbeatStrip: boolean } { + let didLogHeartbeatStrip = params.didLogHeartbeatStrip; + const sanitizedPayloads = params.isHeartbeat + ? params.payloads + : params.payloads.flatMap((payload) => { + let text = payload.text; + + if (payload.isError && text && isBunFetchSocketError(text)) { + text = formatBunFetchSocketError(text); + } + + if (!text || !text.includes("HEARTBEAT_OK")) { + return [{ ...payload, text }]; + } + const stripped = stripHeartbeatToken(text, { mode: "message" }); + if (stripped.didStrip && !didLogHeartbeatStrip) { + didLogHeartbeatStrip = true; + logVerbose("Stripped stray HEARTBEAT_OK token from reply"); + } + const hasMedia = + Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; + if (stripped.shouldSkip && !hasMedia) return []; + return [{ ...payload, text: stripped.text }]; + }); + + const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({ + payloads: sanitizedPayloads, + replyToMode: params.replyToMode, + replyToChannel: params.replyToChannel, + currentMessageId: params.currentMessageId, + }) + .map((payload) => { + const parsed = parseReplyDirectives(payload.text ?? "", { + currentMessageId: params.currentMessageId, + silentToken: SILENT_REPLY_TOKEN, + }); + const mediaUrls = payload.mediaUrls ?? parsed.mediaUrls; + const mediaUrl = payload.mediaUrl ?? parsed.mediaUrl ?? mediaUrls?.[0]; + return { + ...payload, + text: parsed.text ? parsed.text : undefined, + mediaUrls, + mediaUrl, + replyToId: payload.replyToId ?? parsed.replyToId, + replyToTag: payload.replyToTag || parsed.replyToTag, + replyToCurrent: payload.replyToCurrent || parsed.replyToCurrent, + audioAsVoice: Boolean(payload.audioAsVoice || parsed.audioAsVoice), + }; + }) + .filter(isRenderablePayload); + + // Drop final payloads only when block streaming succeeded end-to-end. + // If streaming aborted (e.g., timeout), fall back to final payloads. + const shouldDropFinalPayloads = + params.blockStreamingEnabled && + Boolean(params.blockReplyPipeline?.didStream()) && + !params.blockReplyPipeline?.isAborted(); + const messagingToolSentTexts = params.messagingToolSentTexts ?? []; + const messagingToolSentTargets = params.messagingToolSentTargets ?? []; + const suppressMessagingToolReplies = shouldSuppressMessagingToolReplies({ + messageProvider: params.messageProvider, + messagingToolSentTargets, + originatingTo: params.originatingTo, + accountId: params.accountId, + }); + const dedupedPayloads = filterMessagingToolDuplicates({ + payloads: replyTaggedPayloads, + sentTexts: messagingToolSentTexts, + }); + const filteredPayloads = shouldDropFinalPayloads + ? [] + : params.blockStreamingEnabled + ? dedupedPayloads.filter( + (payload) => !params.blockReplyPipeline?.hasSentPayload(payload), + ) + : dedupedPayloads; + const replyPayloads = suppressMessagingToolReplies ? [] : filteredPayloads; + + return { + replyPayloads, + didLogHeartbeatStrip, + }; +} diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts new file mode 100644 index 000000000..ebbfdc0a0 --- /dev/null +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -0,0 +1,122 @@ +import type { NormalizedUsage } from "../../agents/usage.js"; +import { getChannelDock } from "../../channels/dock.js"; +import type { ChannelThreadingToolContext } from "../../channels/plugins/types.js"; +import { normalizeChannelId } from "../../channels/registry.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { isReasoningTagProvider } from "../../utils/provider-utils.js"; +import { + estimateUsageCost, + formatTokenCount, + formatUsd, +} from "../../utils/usage-format.js"; +import type { TemplateContext } from "../templating.js"; +import type { ReplyPayload } from "../types.js"; +import type { FollowupRun } from "./queue.js"; + +const BUN_FETCH_SOCKET_ERROR_RE = /socket connection was closed unexpectedly/i; + +/** + * Build provider-specific threading context for tool auto-injection. + */ +export function buildThreadingToolContext(params: { + sessionCtx: TemplateContext; + config: ClawdbotConfig | undefined; + hasRepliedRef: { value: boolean } | undefined; +}): ChannelThreadingToolContext { + const { sessionCtx, config, hasRepliedRef } = params; + if (!config) return {}; + const provider = normalizeChannelId(sessionCtx.Provider); + if (!provider) return {}; + const dock = getChannelDock(provider); + if (!dock?.threading?.buildToolContext) return {}; + return ( + dock.threading.buildToolContext({ + cfg: config, + accountId: sessionCtx.AccountId, + context: { + Channel: sessionCtx.Provider, + To: sessionCtx.To, + ReplyToId: sessionCtx.ReplyToId, + ThreadLabel: sessionCtx.ThreadLabel, + }, + hasRepliedRef, + }) ?? {} + ); +} + +export const isBunFetchSocketError = (message?: string) => + Boolean(message && BUN_FETCH_SOCKET_ERROR_RE.test(message)); + +export const formatBunFetchSocketError = (message: string) => { + const trimmed = message.trim(); + return [ + "⚠️ LLM connection failed. This could be due to server issues, network problems, or context length exceeded (e.g., with local LLMs like LM Studio). Original error:", + "```", + trimmed || "Unknown error", + "```", + ].join("\n"); +}; + +export const formatResponseUsageLine = (params: { + usage?: NormalizedUsage; + showCost: boolean; + costConfig?: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + }; +}): string | null => { + const usage = params.usage; + if (!usage) return null; + const input = usage.input; + const output = usage.output; + if (typeof input !== "number" && typeof output !== "number") return null; + const inputLabel = typeof input === "number" ? formatTokenCount(input) : "?"; + const outputLabel = + typeof output === "number" ? formatTokenCount(output) : "?"; + const cost = + params.showCost && typeof input === "number" && typeof output === "number" + ? estimateUsageCost({ + usage: { + input, + output, + cacheRead: usage.cacheRead, + cacheWrite: usage.cacheWrite, + }, + cost: params.costConfig, + }) + : undefined; + const costLabel = params.showCost ? formatUsd(cost) : undefined; + const suffix = costLabel ? ` · est ${costLabel}` : ""; + return `Usage: ${inputLabel} in / ${outputLabel} out${suffix}`; +}; + +export const appendUsageLine = ( + payloads: ReplyPayload[], + line: string, +): ReplyPayload[] => { + let index = -1; + for (let i = payloads.length - 1; i >= 0; i -= 1) { + if (payloads[i]?.text) { + index = i; + break; + } + } + if (index === -1) return [...payloads, { text: line }]; + const existing = payloads[index]; + const existingText = existing.text ?? ""; + const separator = existingText.endsWith("\n") ? "" : "\n"; + const next = { + ...existing, + text: `${existingText}${separator}${line}`, + }; + const updated = payloads.slice(); + updated[index] = next; + return updated; +}; + +export const resolveEnforceFinalTag = ( + run: FollowupRun["run"], + provider: string, +) => Boolean(run.enforceFinalTag || isReasoningTagProvider(provider)); diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 4305c3c93..fbe23e0d5 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -1,32 +1,12 @@ import crypto from "node:crypto"; -import fs from "node:fs"; -import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js"; -import { runCliAgent } from "../../agents/cli-runner.js"; -import { getCliSessionId, setCliSessionId } from "../../agents/cli-session.js"; +import { setCliSessionId } from "../../agents/cli-session.js"; import { lookupContextTokens } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { resolveModelAuthMode } from "../../agents/model-auth.js"; -import { runWithModelFallback } from "../../agents/model-fallback.js"; import { isCliProvider } from "../../agents/model-selection.js"; +import { queueEmbeddedPiMessage } from "../../agents/pi-embedded.js"; +import { hasNonzeroUsage } from "../../agents/usage.js"; import { - queueEmbeddedPiMessage, - runEmbeddedPiAgent, -} from "../../agents/pi-embedded.js"; -import { - isCompactionFailureError, - isContextOverflowError, -} from "../../agents/pi-embedded-helpers.js"; -import { - resolveSandboxConfigForAgent, - resolveSandboxRuntimeStatus, -} from "../../agents/sandbox.js"; -import { hasNonzeroUsage, type NormalizedUsage } from "../../agents/usage.js"; -import { getChannelDock } from "../../channels/dock.js"; -import type { ChannelThreadingToolContext } from "../../channels/plugins/types.js"; -import { normalizeChannelId } from "../../channels/registry.js"; -import type { ClawdbotConfig } from "../../config/config.js"; -import { - loadSessionStore, resolveAgentIdFromSessionKey, resolveSessionTranscriptPath, type SessionEntry, @@ -35,49 +15,35 @@ import { } from "../../config/sessions.js"; import type { TypingMode } from "../../config/types.js"; import { logVerbose } from "../../globals.js"; -import { - emitAgentEvent, - registerAgentRunContext, -} from "../../infra/agent-events.js"; -import { isAudioFileName } from "../../media/mime.js"; import { defaultRuntime } from "../../runtime.js"; -import { isReasoningTagProvider } from "../../utils/provider-utils.js"; -import { - estimateUsageCost, - formatTokenCount, - formatUsd, - resolveModelCostConfig, -} from "../../utils/usage-format.js"; -import { stripHeartbeatToken } from "../heartbeat.js"; +import { resolveModelCostConfig } from "../../utils/usage-format.js"; import type { OriginatingChannelType, TemplateContext } from "../templating.js"; -import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js"; -import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js"; +import type { VerboseLevel } from "../thinking.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; +import { runAgentTurnWithFallback } from "./agent-runner-execution.js"; +import { + createShouldEmitToolResult, + finalizeWithFollowup, + isAudioPayload, + signalTypingIfNeeded, +} from "./agent-runner-helpers.js"; +import { runMemoryFlushIfNeeded } from "./agent-runner-memory.js"; +import { buildReplyPayloads } from "./agent-runner-payloads.js"; +import { + appendUsageLine, + formatResponseUsageLine, +} from "./agent-runner-utils.js"; import { createAudioAsVoiceBuffer, createBlockReplyPipeline, } from "./block-reply-pipeline.js"; import { resolveBlockStreamingCoalescing } from "./block-streaming.js"; import { createFollowupRunner } from "./followup-runner.js"; -import { - resolveMemoryFlushContextWindowTokens, - resolveMemoryFlushSettings, - shouldRunMemoryFlush, -} from "./memory-flush.js"; import { enqueueFollowupRun, type FollowupRun, type QueueSettings, - scheduleFollowupDrain, } from "./queue.js"; -import { parseReplyDirectives } from "./reply-directives.js"; -import { - applyReplyTagsToPayload, - applyReplyThreading, - filterMessagingToolDuplicates, - isRenderablePayload, - shouldSuppressMessagingToolReplies, -} from "./reply-payloads.js"; import { createReplyToModeFilterForChannel, resolveReplyToMode, @@ -86,113 +52,8 @@ import { incrementCompactionCount } from "./session-updates.js"; import type { TypingController } from "./typing.js"; import { createTypingSignaler } from "./typing-mode.js"; -const BUN_FETCH_SOCKET_ERROR_RE = /socket connection was closed unexpectedly/i; const BLOCK_REPLY_SEND_TIMEOUT_MS = 15_000; -/** - * Build provider-specific threading context for tool auto-injection. - */ -function buildThreadingToolContext(params: { - sessionCtx: TemplateContext; - config: ClawdbotConfig | undefined; - hasRepliedRef: { value: boolean } | undefined; -}): ChannelThreadingToolContext { - const { sessionCtx, config, hasRepliedRef } = params; - if (!config) return {}; - const provider = normalizeChannelId(sessionCtx.Provider); - if (!provider) return {}; - const dock = getChannelDock(provider); - if (!dock?.threading?.buildToolContext) return {}; - return ( - dock.threading.buildToolContext({ - cfg: config, - accountId: sessionCtx.AccountId, - context: { - Channel: sessionCtx.Provider, - To: sessionCtx.To, - ReplyToId: sessionCtx.ReplyToId, - ThreadLabel: sessionCtx.ThreadLabel, - }, - hasRepliedRef, - }) ?? {} - ); -} - -const isBunFetchSocketError = (message?: string) => - Boolean(message && BUN_FETCH_SOCKET_ERROR_RE.test(message)); - -const formatBunFetchSocketError = (message: string) => { - const trimmed = message.trim(); - return [ - "⚠️ LLM connection failed. This could be due to server issues, network problems, or context length exceeded (e.g., with local LLMs like LM Studio). Original error:", - "```", - trimmed || "Unknown error", - "```", - ].join("\n"); -}; - -const formatResponseUsageLine = (params: { - usage?: NormalizedUsage; - showCost: boolean; - costConfig?: { - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - }; -}): string | null => { - const usage = params.usage; - if (!usage) return null; - const input = usage.input; - const output = usage.output; - if (typeof input !== "number" && typeof output !== "number") return null; - const inputLabel = typeof input === "number" ? formatTokenCount(input) : "?"; - const outputLabel = - typeof output === "number" ? formatTokenCount(output) : "?"; - const cost = - params.showCost && typeof input === "number" && typeof output === "number" - ? estimateUsageCost({ - usage: { - input, - output, - cacheRead: usage.cacheRead, - cacheWrite: usage.cacheWrite, - }, - cost: params.costConfig, - }) - : undefined; - const costLabel = params.showCost ? formatUsd(cost) : undefined; - const suffix = costLabel ? ` · est ${costLabel}` : ""; - return `Usage: ${inputLabel} in / ${outputLabel} out${suffix}`; -}; - -const appendUsageLine = ( - payloads: ReplyPayload[], - line: string, -): ReplyPayload[] => { - let index = -1; - for (let i = payloads.length - 1; i >= 0; i -= 1) { - if (payloads[i]?.text) { - index = i; - break; - } - } - if (index === -1) return [...payloads, { text: line }]; - const existing = payloads[index]; - const existingText = existing.text ?? ""; - const separator = existingText.endsWith("\n") ? "" : "\n"; - const next = { - ...existing, - text: `${existingText}${separator}${line}`, - }; - const updated = payloads.slice(); - updated[index] = next; - return updated; -}; - -const resolveEnforceFinalTag = (run: FollowupRun["run"], provider: string) => - Boolean(run.enforceFinalTag || isReasoningTagProvider(provider)); - export async function runReplyAgent(params: { commandBody: string; followupRun: FollowupRun; @@ -261,31 +122,16 @@ export async function runReplyAgent(params: { isHeartbeat, }); - const shouldEmitToolResult = () => { - if (!sessionKey || !storePath) { - return resolvedVerboseLevel === "on"; - } - try { - const store = loadSessionStore(storePath); - const entry = store[sessionKey]; - const current = normalizeVerboseLevel(entry?.verboseLevel); - if (current) return current === "on"; - } catch { - // ignore store read failures - } - return resolvedVerboseLevel === "on"; - }; + const shouldEmitToolResult = createShouldEmitToolResult({ + sessionKey, + storePath, + resolvedVerboseLevel, + }); const pendingToolTasks = new Set>(); const blockReplyTimeoutMs = opts?.blockReplyTimeoutMs ?? BLOCK_REPLY_SEND_TIMEOUT_MS; - const hasAudioMedia = (urls?: string[]): boolean => - Boolean(urls?.some((u) => isAudioFileName(u))); - const isAudioPayload = (payload: ReplyPayload) => - hasAudioMedia( - payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined), - ); const replyToChannel = sessionCtx.OriginatingChannel ?? ((sessionCtx.Surface ?? sessionCtx.Provider)?.toLowerCase() as @@ -351,133 +197,20 @@ export async function runReplyAgent(params: { return undefined; } - const memoryFlushSettings = resolveMemoryFlushSettings(cfg); - const memoryFlushWritable = (() => { - if (!sessionKey) return true; - const runtime = resolveSandboxRuntimeStatus({ cfg, sessionKey }); - if (!runtime.sandboxed) return true; - const sandboxCfg = resolveSandboxConfigForAgent(cfg, runtime.agentId); - return sandboxCfg.workspaceAccess === "rw"; - })(); - const shouldFlushMemory = - memoryFlushSettings && - memoryFlushWritable && - !isHeartbeat && - !isCliProvider(followupRun.run.provider, cfg) && - shouldRunMemoryFlush({ - entry: - activeSessionEntry ?? - (sessionKey ? activeSessionStore?.[sessionKey] : undefined), - contextWindowTokens: resolveMemoryFlushContextWindowTokens({ - modelId: followupRun.run.model ?? defaultModel, - agentCfgContextTokens, - }), - reserveTokensFloor: memoryFlushSettings.reserveTokensFloor, - softThresholdTokens: memoryFlushSettings.softThresholdTokens, - }); - if (shouldFlushMemory) { - const flushRunId = crypto.randomUUID(); - if (sessionKey) { - registerAgentRunContext(flushRunId, { - sessionKey, - verboseLevel: resolvedVerboseLevel, - }); - } - let memoryCompactionCompleted = false; - const flushSystemPrompt = [ - followupRun.run.extraSystemPrompt, - memoryFlushSettings.systemPrompt, - ] - .filter(Boolean) - .join("\n\n"); - try { - await runWithModelFallback({ - cfg: followupRun.run.config, - provider: followupRun.run.provider, - model: followupRun.run.model, - fallbacksOverride: resolveAgentModelFallbacksOverride( - followupRun.run.config, - resolveAgentIdFromSessionKey(followupRun.run.sessionKey), - ), - run: (provider, model) => - runEmbeddedPiAgent({ - sessionId: followupRun.run.sessionId, - sessionKey, - messageProvider: - sessionCtx.Provider?.trim().toLowerCase() || undefined, - agentAccountId: sessionCtx.AccountId, - // Provider threading context for tool auto-injection - ...buildThreadingToolContext({ - sessionCtx, - config: followupRun.run.config, - hasRepliedRef: opts?.hasRepliedRef, - }), - sessionFile: followupRun.run.sessionFile, - workspaceDir: followupRun.run.workspaceDir, - agentDir: followupRun.run.agentDir, - config: followupRun.run.config, - skillsSnapshot: followupRun.run.skillsSnapshot, - prompt: memoryFlushSettings.prompt, - extraSystemPrompt: flushSystemPrompt, - ownerNumbers: followupRun.run.ownerNumbers, - enforceFinalTag: resolveEnforceFinalTag(followupRun.run, provider), - provider, - model, - authProfileId: followupRun.run.authProfileId, - thinkLevel: followupRun.run.thinkLevel, - verboseLevel: followupRun.run.verboseLevel, - reasoningLevel: followupRun.run.reasoningLevel, - bashElevated: followupRun.run.bashElevated, - timeoutMs: followupRun.run.timeoutMs, - runId: flushRunId, - onAgentEvent: (evt) => { - if (evt.stream === "compaction") { - const phase = - typeof evt.data.phase === "string" ? evt.data.phase : ""; - const willRetry = Boolean(evt.data.willRetry); - if (phase === "end" && !willRetry) { - memoryCompactionCompleted = true; - } - } - }, - }), - }); - let memoryFlushCompactionCount = - activeSessionEntry?.compactionCount ?? - (sessionKey ? activeSessionStore?.[sessionKey]?.compactionCount : 0) ?? - 0; - if (memoryCompactionCompleted) { - const nextCount = await incrementCompactionCount({ - sessionEntry: activeSessionEntry, - sessionStore: activeSessionStore, - sessionKey, - storePath, - }); - if (typeof nextCount === "number") { - memoryFlushCompactionCount = nextCount; - } - } - if (storePath && sessionKey) { - try { - const updatedEntry = await updateSessionStoreEntry({ - storePath, - sessionKey, - update: async () => ({ - memoryFlushAt: Date.now(), - memoryFlushCompactionCount, - }), - }); - if (updatedEntry) { - activeSessionEntry = updatedEntry; - } - } catch (err) { - logVerbose(`failed to persist memory flush metadata: ${String(err)}`); - } - } - } catch (err) { - logVerbose(`memory flush run failed: ${String(err)}`); - } - } + activeSessionEntry = await runMemoryFlushIfNeeded({ + cfg, + followupRun, + sessionCtx, + opts, + defaultModel, + agentCfgContextTokens, + resolvedVerboseLevel, + sessionEntry: activeSessionEntry, + sessionStore: activeSessionStore, + sessionKey, + storePath, + isHeartbeat, + }); const runFollowupTurn = createFollowupRunner({ opts, @@ -491,13 +224,6 @@ export async function runReplyAgent(params: { agentCfgContextTokens, }); - const finalizeWithFollowup = (value: T): T => { - scheduleFollowupDrain(queueKey, runFollowupTurn); - return value; - }; - - let didLogHeartbeatStrip = false; - let autoCompactionCompleted = false; let responseUsageLine: string | undefined; const resetSessionAfterCompactionFailure = async ( reason: string, @@ -540,379 +266,38 @@ export async function runReplyAgent(params: { return true; }; try { - const runId = crypto.randomUUID(); - if (sessionKey) { - registerAgentRunContext(runId, { - sessionKey, - verboseLevel: resolvedVerboseLevel, - }); + const runOutcome = await runAgentTurnWithFallback({ + commandBody, + followupRun, + sessionCtx, + opts, + typingSignals, + blockReplyPipeline, + blockStreamingEnabled, + blockReplyChunking, + resolvedBlockStreamingBreak, + applyReplyToMode, + shouldEmitToolResult, + pendingToolTasks, + resetSessionAfterCompactionFailure, + isHeartbeat, + sessionKey, + getActiveSessionEntry: () => activeSessionEntry, + activeSessionStore, + storePath, + resolvedVerboseLevel, + }); + + if (runOutcome.kind === "final") { + return finalizeWithFollowup( + runOutcome.payload, + queueKey, + runFollowupTurn, + ); } - let runResult: Awaited>; - let fallbackProvider = followupRun.run.provider; - let fallbackModel = followupRun.run.model; - let didResetAfterCompactionFailure = false; - while (true) { - try { - const allowPartialStream = !( - followupRun.run.reasoningLevel === "stream" && opts?.onReasoningStream - ); - const normalizeStreamingText = ( - payload: ReplyPayload, - ): { text?: string; skip: boolean } => { - if (!allowPartialStream) return { skip: true }; - let text = payload.text; - if (!isHeartbeat && text?.includes("HEARTBEAT_OK")) { - const stripped = stripHeartbeatToken(text, { - mode: "message", - }); - if (stripped.didStrip && !didLogHeartbeatStrip) { - didLogHeartbeatStrip = true; - logVerbose("Stripped stray HEARTBEAT_OK token from reply"); - } - if (stripped.shouldSkip && (payload.mediaUrls?.length ?? 0) === 0) { - return { skip: true }; - } - text = stripped.text; - } - if (isSilentReplyText(text, SILENT_REPLY_TOKEN)) { - return { skip: true }; - } - return { text, skip: false }; - }; - const handlePartialForTyping = async ( - payload: ReplyPayload, - ): Promise => { - const { text, skip } = normalizeStreamingText(payload); - if (skip || !text) return undefined; - await typingSignals.signalTextDelta(text); - return text; - }; - const fallbackResult = await runWithModelFallback({ - cfg: followupRun.run.config, - provider: followupRun.run.provider, - model: followupRun.run.model, - fallbacksOverride: resolveAgentModelFallbacksOverride( - followupRun.run.config, - resolveAgentIdFromSessionKey(followupRun.run.sessionKey), - ), - run: (provider, model) => { - if (isCliProvider(provider, followupRun.run.config)) { - const startedAt = Date.now(); - emitAgentEvent({ - runId, - stream: "lifecycle", - data: { - phase: "start", - startedAt, - }, - }); - const cliSessionId = getCliSessionId( - activeSessionEntry, - provider, - ); - return runCliAgent({ - sessionId: followupRun.run.sessionId, - sessionKey, - sessionFile: followupRun.run.sessionFile, - workspaceDir: followupRun.run.workspaceDir, - config: followupRun.run.config, - prompt: commandBody, - provider, - model, - thinkLevel: followupRun.run.thinkLevel, - timeoutMs: followupRun.run.timeoutMs, - runId, - extraSystemPrompt: followupRun.run.extraSystemPrompt, - ownerNumbers: followupRun.run.ownerNumbers, - cliSessionId, - }) - .then((result) => { - emitAgentEvent({ - runId, - stream: "lifecycle", - data: { - phase: "end", - startedAt, - endedAt: Date.now(), - }, - }); - return result; - }) - .catch((err) => { - emitAgentEvent({ - runId, - stream: "lifecycle", - data: { - phase: "error", - startedAt, - endedAt: Date.now(), - error: err instanceof Error ? err.message : String(err), - }, - }); - throw err; - }); - } - return runEmbeddedPiAgent({ - sessionId: followupRun.run.sessionId, - sessionKey, - messageProvider: - sessionCtx.Provider?.trim().toLowerCase() || undefined, - agentAccountId: sessionCtx.AccountId, - // Provider threading context for tool auto-injection - ...buildThreadingToolContext({ - sessionCtx, - config: followupRun.run.config, - hasRepliedRef: opts?.hasRepliedRef, - }), - sessionFile: followupRun.run.sessionFile, - workspaceDir: followupRun.run.workspaceDir, - agentDir: followupRun.run.agentDir, - config: followupRun.run.config, - skillsSnapshot: followupRun.run.skillsSnapshot, - prompt: commandBody, - extraSystemPrompt: followupRun.run.extraSystemPrompt, - ownerNumbers: followupRun.run.ownerNumbers, - enforceFinalTag: resolveEnforceFinalTag( - followupRun.run, - provider, - ), - provider, - model, - authProfileId: followupRun.run.authProfileId, - thinkLevel: followupRun.run.thinkLevel, - verboseLevel: followupRun.run.verboseLevel, - reasoningLevel: followupRun.run.reasoningLevel, - bashElevated: followupRun.run.bashElevated, - timeoutMs: followupRun.run.timeoutMs, - runId, - blockReplyBreak: resolvedBlockStreamingBreak, - blockReplyChunking, - onPartialReply: allowPartialStream - ? async (payload) => { - const textForTyping = await handlePartialForTyping(payload); - if (!opts?.onPartialReply || textForTyping === undefined) - return; - await opts.onPartialReply({ - text: textForTyping, - mediaUrls: payload.mediaUrls, - }); - } - : undefined, - onAssistantMessageStart: async () => { - await typingSignals.signalMessageStart(); - }, - onReasoningStream: - typingSignals.shouldStartOnReasoning || opts?.onReasoningStream - ? async (payload) => { - await typingSignals.signalReasoningDelta(); - await opts?.onReasoningStream?.({ - text: payload.text, - mediaUrls: payload.mediaUrls, - }); - } - : undefined, - onAgentEvent: (evt) => { - // Trigger typing when tools start executing - if (evt.stream === "tool") { - const phase = - typeof evt.data.phase === "string" ? evt.data.phase : ""; - if (phase === "start" || phase === "update") { - void typingSignals.signalToolStart(); - } - } - // Track auto-compaction completion - if (evt.stream === "compaction") { - const phase = - typeof evt.data.phase === "string" ? evt.data.phase : ""; - const willRetry = Boolean(evt.data.willRetry); - if (phase === "end" && !willRetry) { - autoCompactionCompleted = true; - } - } - }, - onBlockReply: - blockStreamingEnabled && opts?.onBlockReply - ? async (payload) => { - const { text, skip } = normalizeStreamingText(payload); - const hasPayloadMedia = - (payload.mediaUrls?.length ?? 0) > 0; - if (skip && !hasPayloadMedia) return; - const taggedPayload = applyReplyTagsToPayload( - { - text, - mediaUrls: payload.mediaUrls, - mediaUrl: payload.mediaUrls?.[0], - }, - sessionCtx.MessageSid, - ); - // Let through payloads with audioAsVoice flag even if empty (need to track it) - if ( - !isRenderablePayload(taggedPayload) && - !payload.audioAsVoice - ) - return; - const parsed = parseReplyDirectives( - taggedPayload.text ?? "", - { - currentMessageId: sessionCtx.MessageSid, - silentToken: SILENT_REPLY_TOKEN, - }, - ); - const cleaned = parsed.text || undefined; - const hasRenderableMedia = - Boolean(taggedPayload.mediaUrl) || - (taggedPayload.mediaUrls?.length ?? 0) > 0; - // Skip empty payloads unless they have audioAsVoice flag (need to track it) - if ( - !cleaned && - !hasRenderableMedia && - !payload.audioAsVoice && - !parsed.audioAsVoice - ) - return; - if (parsed.isSilent && !hasRenderableMedia) return; - const blockPayload: ReplyPayload = applyReplyToMode({ - ...taggedPayload, - text: cleaned, - audioAsVoice: Boolean( - parsed.audioAsVoice || payload.audioAsVoice, - ), - replyToId: taggedPayload.replyToId ?? parsed.replyToId, - replyToTag: - taggedPayload.replyToTag || parsed.replyToTag, - replyToCurrent: - taggedPayload.replyToCurrent || parsed.replyToCurrent, - }); - - void typingSignals - .signalTextDelta(cleaned ?? taggedPayload.text) - .catch((err) => { - logVerbose( - `block reply typing signal failed: ${String(err)}`, - ); - }); - - blockReplyPipeline?.enqueue(blockPayload); - } - : undefined, - onBlockReplyFlush: - blockStreamingEnabled && blockReplyPipeline - ? async () => { - await blockReplyPipeline.flush({ force: true }); - } - : undefined, - shouldEmitToolResult, - onToolResult: opts?.onToolResult - ? (payload) => { - // `subscribeEmbeddedPiSession` may invoke tool callbacks without awaiting them. - // If a tool callback starts typing after the run finalized, we can end up with - // a typing loop that never sees a matching markRunComplete(). Track and drain. - const task = (async () => { - const { text, skip } = normalizeStreamingText(payload); - if (skip) return; - await typingSignals.signalTextDelta(text); - await opts.onToolResult?.({ - text, - mediaUrls: payload.mediaUrls, - }); - })() - .catch((err) => { - logVerbose( - `tool result delivery failed: ${String(err)}`, - ); - }) - .finally(() => { - pendingToolTasks.delete(task); - }); - pendingToolTasks.add(task); - } - : undefined, - }); - }, - }); - runResult = fallbackResult.result; - fallbackProvider = fallbackResult.provider; - fallbackModel = fallbackResult.model; - - // Some embedded runs surface context overflow as an error payload instead of throwing. - // Treat those as a session-level failure and auto-recover by starting a fresh session. - const embeddedError = runResult.meta?.error; - if ( - embeddedError && - isContextOverflowError(embeddedError.message) && - !didResetAfterCompactionFailure && - (await resetSessionAfterCompactionFailure(embeddedError.message)) - ) { - didResetAfterCompactionFailure = true; - continue; - } - - break; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const isContextOverflow = - isContextOverflowError(message) || - /context.*overflow|too large|context window/i.test(message); - const isCompactionFailure = isCompactionFailureError(message); - const isSessionCorruption = - /function call turn comes immediately after/i.test(message); - - if ( - isCompactionFailure && - !didResetAfterCompactionFailure && - (await resetSessionAfterCompactionFailure(message)) - ) { - didResetAfterCompactionFailure = true; - continue; - } - - // Auto-recover from Gemini session corruption by resetting the session - if ( - isSessionCorruption && - sessionKey && - activeSessionStore && - storePath - ) { - const corruptedSessionId = activeSessionEntry?.sessionId; - defaultRuntime.error( - `Session history corrupted (Gemini function call ordering). Resetting session: ${sessionKey}`, - ); - - try { - // Delete transcript file if it exists - if (corruptedSessionId) { - const transcriptPath = - resolveSessionTranscriptPath(corruptedSessionId); - try { - fs.unlinkSync(transcriptPath); - } catch { - // Ignore if file doesn't exist - } - } - - // Remove session entry from store - delete activeSessionStore[sessionKey]; - await saveSessionStore(storePath, activeSessionStore); - } catch (cleanupErr) { - defaultRuntime.error( - `Failed to reset corrupted session ${sessionKey}: ${String(cleanupErr)}`, - ); - } - - return finalizeWithFollowup({ - text: "⚠️ Session history was corrupted. I've reset the conversation - please try again!", - }); - } - - defaultRuntime.error(`Embedded agent failed before reply: ${message}`); - return finalizeWithFollowup({ - text: isContextOverflow - ? "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model." - : `⚠️ Agent failed before reply: ${message}. Check gateway logs for details.`, - }); - } - } + const { runResult, fallbackProvider, fallbackModel } = runOutcome; + let { didLogHeartbeatStrip, autoCompactionCompleted } = runOutcome; if ( shouldInjectGroupIntro && @@ -942,95 +327,31 @@ export async function runReplyAgent(params: { // Drain any late tool/block deliveries before deciding there's "nothing to send". // Otherwise, a late typing trigger (e.g. from a tool callback) can outlive the run and // keep the typing indicator stuck. - if (payloadArray.length === 0) return finalizeWithFollowup(undefined); + if (payloadArray.length === 0) + return finalizeWithFollowup(undefined, queueKey, runFollowupTurn); - const sanitizedPayloads = isHeartbeat - ? payloadArray - : payloadArray.flatMap((payload) => { - let text = payload.text; - - if (payload.isError && text && isBunFetchSocketError(text)) { - text = formatBunFetchSocketError(text); - } - - if (!text || !text.includes("HEARTBEAT_OK")) - return [{ ...payload, text }]; - const stripped = stripHeartbeatToken(text, { mode: "message" }); - if (stripped.didStrip && !didLogHeartbeatStrip) { - didLogHeartbeatStrip = true; - logVerbose("Stripped stray HEARTBEAT_OK token from reply"); - } - const hasMedia = - Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; - if (stripped.shouldSkip && !hasMedia) return []; - return [{ ...payload, text: stripped.text }]; - }); - - const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({ - payloads: sanitizedPayloads, + const payloadResult = buildReplyPayloads({ + payloads: payloadArray, + isHeartbeat, + didLogHeartbeatStrip, + blockStreamingEnabled, + blockReplyPipeline, replyToMode, replyToChannel, currentMessageId: sessionCtx.MessageSid, - }) - .map((payload) => { - const parsed = parseReplyDirectives(payload.text ?? "", { - currentMessageId: sessionCtx.MessageSid, - silentToken: SILENT_REPLY_TOKEN, - }); - const mediaUrls = payload.mediaUrls ?? parsed.mediaUrls; - const mediaUrl = payload.mediaUrl ?? parsed.mediaUrl ?? mediaUrls?.[0]; - return { - ...payload, - text: parsed.text ? parsed.text : undefined, - mediaUrls, - mediaUrl, - replyToId: payload.replyToId ?? parsed.replyToId, - replyToTag: payload.replyToTag || parsed.replyToTag, - replyToCurrent: payload.replyToCurrent || parsed.replyToCurrent, - audioAsVoice: Boolean(payload.audioAsVoice || parsed.audioAsVoice), - }; - }) - .filter(isRenderablePayload); - - // Drop final payloads only when block streaming succeeded end-to-end. - // If streaming aborted (e.g., timeout), fall back to final payloads. - const shouldDropFinalPayloads = - blockStreamingEnabled && - Boolean(blockReplyPipeline?.didStream()) && - !blockReplyPipeline?.isAborted(); - const messagingToolSentTexts = runResult.messagingToolSentTexts ?? []; - const messagingToolSentTargets = runResult.messagingToolSentTargets ?? []; - const suppressMessagingToolReplies = shouldSuppressMessagingToolReplies({ messageProvider: followupRun.run.messageProvider, - messagingToolSentTargets, + messagingToolSentTexts: runResult.messagingToolSentTexts, + messagingToolSentTargets: runResult.messagingToolSentTargets, originatingTo: sessionCtx.OriginatingTo ?? sessionCtx.To, accountId: sessionCtx.AccountId, }); - const dedupedPayloads = filterMessagingToolDuplicates({ - payloads: replyTaggedPayloads, - sentTexts: messagingToolSentTexts, - }); - const filteredPayloads = shouldDropFinalPayloads - ? [] - : blockStreamingEnabled - ? dedupedPayloads.filter( - (payload) => !blockReplyPipeline?.hasSentPayload(payload), - ) - : dedupedPayloads; - const replyPayloads = suppressMessagingToolReplies ? [] : filteredPayloads; + const { replyPayloads } = payloadResult; + didLogHeartbeatStrip = payloadResult.didLogHeartbeatStrip; - if (replyPayloads.length === 0) return finalizeWithFollowup(undefined); + if (replyPayloads.length === 0) + return finalizeWithFollowup(undefined, queueKey, runFollowupTurn); - const shouldSignalTyping = replyPayloads.some((payload) => { - const trimmed = payload.text?.trim(); - if (trimmed) return true; - if (payload.mediaUrl) return true; - if (payload.mediaUrls && payload.mediaUrls.length > 0) return true; - return false; - }); - if (shouldSignalTyping) { - await typingSignals.signalRunStart(); - } + await signalTypingIfNeeded(replyPayloads, typingSignals); const usage = runResult.meta.agentMeta?.usage; const modelUsed = @@ -1166,6 +487,8 @@ export async function runReplyAgent(params: { return finalizeWithFollowup( finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads, + queueKey, + runFollowupTurn, ); } finally { blockReplyPipeline?.stop(); diff --git a/src/auto-reply/reply/commands-bash.ts b/src/auto-reply/reply/commands-bash.ts new file mode 100644 index 000000000..cb7fab301 --- /dev/null +++ b/src/auto-reply/reply/commands-bash.ts @@ -0,0 +1,36 @@ +import { logVerbose } from "../../globals.js"; +import { handleBashChatCommand } from "./bash-command.js"; +import type { CommandHandler } from "./commands-types.js"; + +export const handleBashCommand: CommandHandler = async ( + params, + allowTextCommands, +) => { + if (!allowTextCommands) return null; + const { command } = params; + const bashSlashRequested = + command.commandBodyNormalized === "/bash" || + command.commandBodyNormalized.startsWith("/bash "); + const bashBangRequested = command.commandBodyNormalized.startsWith("!"); + if ( + !bashSlashRequested && + !(bashBangRequested && command.isAuthorizedSender) + ) { + return null; + } + if (!command.isAuthorizedSender) { + logVerbose( + `Ignoring /bash from unauthorized sender: ${command.senderId || ""}`, + ); + return { shouldContinue: false }; + } + const reply = await handleBashChatCommand({ + ctx: params.ctx, + cfg: params.cfg, + agentId: params.agentId, + sessionKey: params.sessionKey, + isGroup: params.isGroup, + elevated: params.elevated, + }); + return { shouldContinue: false, reply }; +}; diff --git a/src/auto-reply/reply/commands-compact.ts b/src/auto-reply/reply/commands-compact.ts new file mode 100644 index 000000000..7ced15ca9 --- /dev/null +++ b/src/auto-reply/reply/commands-compact.ts @@ -0,0 +1,119 @@ +import { + abortEmbeddedPiRun, + compactEmbeddedPiSession, + isEmbeddedPiRunActive, + waitForEmbeddedPiRunEnd, +} from "../../agents/pi-embedded.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { resolveSessionFilePath } from "../../config/sessions.js"; +import { logVerbose } from "../../globals.js"; +import { enqueueSystemEvent } from "../../infra/system-events.js"; +import { formatContextUsageShort, formatTokenCount } from "../status.js"; +import type { CommandHandler } from "./commands-types.js"; +import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; +import { incrementCompactionCount } from "./session-updates.js"; + +function extractCompactInstructions(params: { + rawBody?: string; + ctx: import("../templating.js").MsgContext; + cfg: ClawdbotConfig; + agentId?: string; + isGroup: boolean; +}): string | undefined { + const raw = stripStructuralPrefixes(params.rawBody ?? ""); + const stripped = params.isGroup + ? stripMentions(raw, params.ctx, params.cfg, params.agentId) + : raw; + const trimmed = stripped.trim(); + if (!trimmed) return undefined; + const lowered = trimmed.toLowerCase(); + const prefix = lowered.startsWith("/compact") ? "/compact" : null; + if (!prefix) return undefined; + let rest = trimmed.slice(prefix.length).trimStart(); + if (rest.startsWith(":")) rest = rest.slice(1).trimStart(); + return rest.length ? rest : undefined; +} + +export const handleCompactCommand: CommandHandler = async (params) => { + const compactRequested = + params.command.commandBodyNormalized === "/compact" || + params.command.commandBodyNormalized.startsWith("/compact "); + if (!compactRequested) return null; + if (!params.command.isAuthorizedSender) { + logVerbose( + `Ignoring /compact from unauthorized sender: ${params.command.senderId || ""}`, + ); + return { shouldContinue: false }; + } + if (!params.sessionEntry?.sessionId) { + return { + shouldContinue: false, + reply: { text: "⚙️ Compaction unavailable (missing session id)." }, + }; + } + const sessionId = params.sessionEntry.sessionId; + if (isEmbeddedPiRunActive(sessionId)) { + abortEmbeddedPiRun(sessionId); + await waitForEmbeddedPiRunEnd(sessionId, 15_000); + } + const customInstructions = extractCompactInstructions({ + rawBody: params.ctx.CommandBody ?? params.ctx.RawBody ?? params.ctx.Body, + ctx: params.ctx, + cfg: params.cfg, + agentId: params.agentId, + isGroup: params.isGroup, + }); + const result = await compactEmbeddedPiSession({ + sessionId, + sessionKey: params.sessionKey, + messageChannel: params.command.channel, + sessionFile: resolveSessionFilePath(sessionId, params.sessionEntry), + workspaceDir: params.workspaceDir, + config: params.cfg, + skillsSnapshot: params.sessionEntry.skillsSnapshot, + provider: params.provider, + model: params.model, + thinkLevel: + params.resolvedThinkLevel ?? (await params.resolveDefaultThinkingLevel()), + bashElevated: { + enabled: false, + allowed: false, + defaultLevel: "off", + }, + customInstructions, + ownerNumbers: + params.command.ownerList.length > 0 + ? params.command.ownerList + : undefined, + }); + + const totalTokens = + params.sessionEntry.totalTokens ?? + (params.sessionEntry.inputTokens ?? 0) + + (params.sessionEntry.outputTokens ?? 0); + const contextSummary = formatContextUsageShort( + totalTokens > 0 ? totalTokens : null, + params.contextTokens ?? params.sessionEntry.contextTokens ?? null, + ); + const compactLabel = result.ok + ? result.compacted + ? result.result?.tokensBefore + ? `Compacted (${formatTokenCount(result.result.tokensBefore)} before)` + : "Compacted" + : "Compaction skipped" + : "Compaction failed"; + if (result.ok && result.compacted) { + await incrementCompactionCount({ + sessionEntry: params.sessionEntry, + sessionStore: params.sessionStore, + sessionKey: params.sessionKey, + storePath: params.storePath, + }); + } + const reason = result.reason?.trim(); + const line = reason + ? `${compactLabel}: ${reason} • ${contextSummary}` + : `${compactLabel} • ${contextSummary}`; + enqueueSystemEvent(line, { sessionKey: params.sessionKey }); + return { shouldContinue: false, reply: { text: `⚙️ ${line}` } }; +}; diff --git a/src/auto-reply/reply/commands-config.ts b/src/auto-reply/reply/commands-config.ts new file mode 100644 index 000000000..1f70a09fd --- /dev/null +++ b/src/auto-reply/reply/commands-config.ts @@ -0,0 +1,255 @@ +import { + readConfigFileSnapshot, + validateConfigObject, + writeConfigFile, +} from "../../config/config.js"; +import { + getConfigValueAtPath, + parseConfigPath, + setConfigValueAtPath, + unsetConfigValueAtPath, +} from "../../config/config-paths.js"; +import { + getConfigOverrides, + resetConfigOverrides, + setConfigOverride, + unsetConfigOverride, +} from "../../config/runtime-overrides.js"; +import { logVerbose } from "../../globals.js"; +import type { CommandHandler } from "./commands-types.js"; +import { parseConfigCommand } from "./config-commands.js"; +import { parseDebugCommand } from "./debug-commands.js"; + +export const handleConfigCommand: CommandHandler = async ( + params, + allowTextCommands, +) => { + if (!allowTextCommands) return null; + const configCommand = parseConfigCommand( + params.command.commandBodyNormalized, + ); + if (!configCommand) return null; + if (!params.command.isAuthorizedSender) { + logVerbose( + `Ignoring /config from unauthorized sender: ${params.command.senderId || ""}`, + ); + return { shouldContinue: false }; + } + if (params.cfg.commands?.config !== true) { + return { + shouldContinue: false, + reply: { + text: "⚠️ /config is disabled. Set commands.config=true to enable.", + }, + }; + } + if (configCommand.action === "error") { + return { + shouldContinue: false, + reply: { text: `⚠️ ${configCommand.message}` }, + }; + } + const snapshot = await readConfigFileSnapshot(); + if ( + !snapshot.valid || + !snapshot.parsed || + typeof snapshot.parsed !== "object" + ) { + return { + shouldContinue: false, + reply: { + text: "⚠️ Config file is invalid; fix it before using /config.", + }, + }; + } + const parsedBase = structuredClone( + snapshot.parsed as Record, + ); + + if (configCommand.action === "show") { + const pathRaw = configCommand.path?.trim(); + if (pathRaw) { + const parsedPath = parseConfigPath(pathRaw); + if (!parsedPath.ok || !parsedPath.path) { + return { + shouldContinue: false, + reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` }, + }; + } + const value = getConfigValueAtPath(parsedBase, parsedPath.path); + const rendered = JSON.stringify(value ?? null, null, 2); + return { + shouldContinue: false, + reply: { + text: `⚙️ Config ${pathRaw}:\n\`\`\`json\n${rendered}\n\`\`\``, + }, + }; + } + const json = JSON.stringify(parsedBase, null, 2); + return { + shouldContinue: false, + reply: { text: `⚙️ Config (raw):\n\`\`\`json\n${json}\n\`\`\`` }, + }; + } + + if (configCommand.action === "unset") { + const parsedPath = parseConfigPath(configCommand.path); + if (!parsedPath.ok || !parsedPath.path) { + return { + shouldContinue: false, + reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` }, + }; + } + const removed = unsetConfigValueAtPath(parsedBase, parsedPath.path); + if (!removed) { + return { + shouldContinue: false, + reply: { text: `⚙️ No config value found for ${configCommand.path}.` }, + }; + } + const validated = validateConfigObject(parsedBase); + if (!validated.ok) { + const issue = validated.issues[0]; + return { + shouldContinue: false, + reply: { + text: `⚠️ Config invalid after unset (${issue.path}: ${issue.message}).`, + }, + }; + } + await writeConfigFile(validated.config); + return { + shouldContinue: false, + reply: { text: `⚙️ Config updated: ${configCommand.path} removed.` }, + }; + } + + if (configCommand.action === "set") { + const parsedPath = parseConfigPath(configCommand.path); + if (!parsedPath.ok || !parsedPath.path) { + return { + shouldContinue: false, + reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` }, + }; + } + setConfigValueAtPath(parsedBase, parsedPath.path, configCommand.value); + const validated = validateConfigObject(parsedBase); + if (!validated.ok) { + const issue = validated.issues[0]; + return { + shouldContinue: false, + reply: { + text: `⚠️ Config invalid after set (${issue.path}: ${issue.message}).`, + }, + }; + } + await writeConfigFile(validated.config); + const valueLabel = + typeof configCommand.value === "string" + ? `"${configCommand.value}"` + : JSON.stringify(configCommand.value); + return { + shouldContinue: false, + reply: { + text: `⚙️ Config updated: ${configCommand.path}=${valueLabel ?? "null"}`, + }, + }; + } + + return null; +}; + +export const handleDebugCommand: CommandHandler = async ( + params, + allowTextCommands, +) => { + if (!allowTextCommands) return null; + const debugCommand = parseDebugCommand(params.command.commandBodyNormalized); + if (!debugCommand) return null; + if (!params.command.isAuthorizedSender) { + logVerbose( + `Ignoring /debug from unauthorized sender: ${params.command.senderId || ""}`, + ); + return { shouldContinue: false }; + } + if (params.cfg.commands?.debug !== true) { + return { + shouldContinue: false, + reply: { + text: "⚠️ /debug is disabled. Set commands.debug=true to enable.", + }, + }; + } + if (debugCommand.action === "error") { + return { + shouldContinue: false, + reply: { text: `⚠️ ${debugCommand.message}` }, + }; + } + if (debugCommand.action === "show") { + const overrides = getConfigOverrides(); + const hasOverrides = Object.keys(overrides).length > 0; + if (!hasOverrides) { + return { + shouldContinue: false, + reply: { text: "⚙️ Debug overrides: (none)" }, + }; + } + const json = JSON.stringify(overrides, null, 2); + return { + shouldContinue: false, + reply: { + text: `⚙️ Debug overrides (memory-only):\n\`\`\`json\n${json}\n\`\`\``, + }, + }; + } + if (debugCommand.action === "reset") { + resetConfigOverrides(); + return { + shouldContinue: false, + reply: { text: "⚙️ Debug overrides cleared; using config on disk." }, + }; + } + if (debugCommand.action === "unset") { + const result = unsetConfigOverride(debugCommand.path); + if (!result.ok) { + return { + shouldContinue: false, + reply: { text: `⚠️ ${result.error ?? "Invalid path."}` }, + }; + } + if (!result.removed) { + return { + shouldContinue: false, + reply: { + text: `⚙️ No debug override found for ${debugCommand.path}.`, + }, + }; + } + return { + shouldContinue: false, + reply: { text: `⚙️ Debug override removed for ${debugCommand.path}.` }, + }; + } + if (debugCommand.action === "set") { + const result = setConfigOverride(debugCommand.path, debugCommand.value); + if (!result.ok) { + return { + shouldContinue: false, + reply: { text: `⚠️ ${result.error ?? "Invalid override."}` }, + }; + } + const valueLabel = + typeof debugCommand.value === "string" + ? `"${debugCommand.value}"` + : JSON.stringify(debugCommand.value); + return { + shouldContinue: false, + reply: { + text: `⚙️ Debug override set: ${debugCommand.path}=${valueLabel ?? "null"}`, + }, + }; + } + + return null; +}; diff --git a/src/auto-reply/reply/commands-context.ts b/src/auto-reply/reply/commands-context.ts new file mode 100644 index 000000000..6fa2582a7 --- /dev/null +++ b/src/auto-reply/reply/commands-context.ts @@ -0,0 +1,48 @@ +import type { ClawdbotConfig } from "../../config/config.js"; +import { resolveCommandAuthorization } from "../command-auth.js"; +import { normalizeCommandBody } from "../commands-registry.js"; +import type { MsgContext } from "../templating.js"; +import type { CommandContext } from "./commands-types.js"; +import { stripMentions } from "./mentions.js"; + +export function buildCommandContext(params: { + ctx: MsgContext; + cfg: ClawdbotConfig; + agentId?: string; + sessionKey?: string; + isGroup: boolean; + triggerBodyNormalized: string; + commandAuthorized: boolean; +}): CommandContext { + const { ctx, cfg, agentId, sessionKey, isGroup, triggerBodyNormalized } = + params; + const auth = resolveCommandAuthorization({ + ctx, + cfg, + commandAuthorized: params.commandAuthorized, + }); + const surface = (ctx.Surface ?? ctx.Provider ?? "").trim().toLowerCase(); + const channel = (ctx.Provider ?? surface).trim().toLowerCase(); + const abortKey = + sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined); + const rawBodyNormalized = triggerBodyNormalized; + const commandBodyNormalized = normalizeCommandBody( + isGroup + ? stripMentions(rawBodyNormalized, ctx, cfg, agentId) + : rawBodyNormalized, + ); + + return { + surface, + channel, + channelId: auth.providerId, + ownerList: auth.ownerList, + isAuthorizedSender: auth.isAuthorizedSender, + senderId: auth.senderId, + abortKey, + rawBodyNormalized, + commandBodyNormalized, + from: auth.from, + to: auth.to, + }; +} diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts new file mode 100644 index 000000000..0246693c0 --- /dev/null +++ b/src/auto-reply/reply/commands-core.ts @@ -0,0 +1,81 @@ +import { logVerbose } from "../../globals.js"; +import { resolveSendPolicy } from "../../sessions/send-policy.js"; +import { shouldHandleTextCommands } from "../commands-registry.js"; +import { handleBashCommand } from "./commands-bash.js"; +import { handleCompactCommand } from "./commands-compact.js"; +import { handleConfigCommand, handleDebugCommand } from "./commands-config.js"; +import { + handleCommandsListCommand, + handleHelpCommand, + handleStatusCommand, + handleWhoamiCommand, +} from "./commands-info.js"; +import { + handleAbortTrigger, + handleActivationCommand, + handleRestartCommand, + handleSendPolicyCommand, + handleStopCommand, +} from "./commands-session.js"; +import type { + CommandHandler, + CommandHandlerResult, + HandleCommandsParams, +} from "./commands-types.js"; + +const HANDLERS: CommandHandler[] = [ + handleBashCommand, + handleActivationCommand, + handleSendPolicyCommand, + handleRestartCommand, + handleHelpCommand, + handleCommandsListCommand, + handleStatusCommand, + handleWhoamiCommand, + handleConfigCommand, + handleDebugCommand, + handleStopCommand, + handleCompactCommand, + handleAbortTrigger, +]; + +export async function handleCommands( + params: HandleCommandsParams, +): Promise { + const resetRequested = + params.command.commandBodyNormalized === "/reset" || + params.command.commandBodyNormalized === "/new"; + if (resetRequested && !params.command.isAuthorizedSender) { + logVerbose( + `Ignoring /reset from unauthorized sender: ${params.command.senderId || ""}`, + ); + return { shouldContinue: false }; + } + + const allowTextCommands = shouldHandleTextCommands({ + cfg: params.cfg, + surface: params.command.surface, + commandSource: params.ctx.CommandSource, + }); + + for (const handler of HANDLERS) { + const result = await handler(params, allowTextCommands); + if (result) return result; + } + + const sendPolicy = resolveSendPolicy({ + cfg: params.cfg, + entry: params.sessionEntry, + sessionKey: params.sessionKey, + channel: params.sessionEntry?.channel ?? params.command.channel, + chatType: params.sessionEntry?.chatType, + }); + if (sendPolicy === "deny") { + logVerbose( + `Send blocked by policy for session ${params.sessionKey ?? "unknown"}`, + ); + return { shouldContinue: false }; + } + + return { shouldContinue: true }; +} diff --git a/src/auto-reply/reply/commands-info.ts b/src/auto-reply/reply/commands-info.ts new file mode 100644 index 000000000..1267a29ae --- /dev/null +++ b/src/auto-reply/reply/commands-info.ts @@ -0,0 +1,109 @@ +import { logVerbose } from "../../globals.js"; +import { buildCommandsMessage, buildHelpMessage } from "../status.js"; +import { buildStatusReply } from "./commands-status.js"; +import type { CommandHandler } from "./commands-types.js"; + +export const handleHelpCommand: CommandHandler = async ( + params, + allowTextCommands, +) => { + if (!allowTextCommands) return null; + if (params.command.commandBodyNormalized !== "/help") return null; + if (!params.command.isAuthorizedSender) { + logVerbose( + `Ignoring /help from unauthorized sender: ${params.command.senderId || ""}`, + ); + return { shouldContinue: false }; + } + return { + shouldContinue: false, + reply: { text: buildHelpMessage(params.cfg) }, + }; +}; + +export const handleCommandsListCommand: CommandHandler = async ( + params, + allowTextCommands, +) => { + if (!allowTextCommands) return null; + if (params.command.commandBodyNormalized !== "/commands") return null; + if (!params.command.isAuthorizedSender) { + logVerbose( + `Ignoring /commands from unauthorized sender: ${params.command.senderId || ""}`, + ); + return { shouldContinue: false }; + } + return { + shouldContinue: false, + reply: { text: buildCommandsMessage(params.cfg) }, + }; +}; + +export const handleStatusCommand: CommandHandler = async ( + params, + allowTextCommands, +) => { + if (!allowTextCommands) return null; + const statusRequested = + params.directives.hasStatusDirective || + params.command.commandBodyNormalized === "/status"; + if (!statusRequested) return null; + if (!params.command.isAuthorizedSender) { + logVerbose( + `Ignoring /status from unauthorized sender: ${params.command.senderId || ""}`, + ); + return { shouldContinue: false }; + } + const reply = await buildStatusReply({ + cfg: params.cfg, + command: params.command, + sessionEntry: params.sessionEntry, + sessionKey: params.sessionKey, + sessionScope: params.sessionScope, + provider: params.provider, + model: params.model, + contextTokens: params.contextTokens, + resolvedThinkLevel: params.resolvedThinkLevel, + resolvedVerboseLevel: params.resolvedVerboseLevel, + resolvedReasoningLevel: params.resolvedReasoningLevel, + resolvedElevatedLevel: params.resolvedElevatedLevel, + resolveDefaultThinkingLevel: params.resolveDefaultThinkingLevel, + isGroup: params.isGroup, + defaultGroupActivation: params.defaultGroupActivation, + }); + return { shouldContinue: false, reply }; +}; + +export const handleWhoamiCommand: CommandHandler = async ( + params, + allowTextCommands, +) => { + if (!allowTextCommands) return null; + if (params.command.commandBodyNormalized !== "/whoami") return null; + if (!params.command.isAuthorizedSender) { + logVerbose( + `Ignoring /whoami from unauthorized sender: ${params.command.senderId || ""}`, + ); + return { shouldContinue: false }; + } + const senderId = params.ctx.SenderId ?? ""; + const senderUsername = params.ctx.SenderUsername ?? ""; + const lines = ["🧭 Identity", `Channel: ${params.command.channel}`]; + if (senderId) lines.push(`User id: ${senderId}`); + if (senderUsername) { + const handle = senderUsername.startsWith("@") + ? senderUsername + : `@${senderUsername}`; + lines.push(`Username: ${handle}`); + } + if (params.ctx.ChatType === "group" && params.ctx.From) { + lines.push(`Chat: ${params.ctx.From}`); + } + if (params.ctx.MessageThreadId != null) { + lines.push(`Thread: ${params.ctx.MessageThreadId}`); + } + if (senderId) { + lines.push(`AllowFrom: ${senderId}`); + } + return { shouldContinue: false, reply: { text: lines.join("\n") } }; +}; diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts new file mode 100644 index 000000000..8df6c75cc --- /dev/null +++ b/src/auto-reply/reply/commands-session.ts @@ -0,0 +1,252 @@ +import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js"; +import type { SessionEntry } from "../../config/sessions.js"; +import { saveSessionStore } from "../../config/sessions.js"; +import { logVerbose } from "../../globals.js"; +import { + scheduleGatewaySigusr1Restart, + triggerClawdbotRestart, +} from "../../infra/restart.js"; +import { parseAgentSessionKey } from "../../routing/session-key.js"; +import { parseActivationCommand } from "../group-activation.js"; +import { parseSendPolicyCommand } from "../send-policy.js"; +import { isAbortTrigger, setAbortMemory } from "./abort.js"; +import type { CommandHandler } from "./commands-types.js"; + +function resolveSessionEntryForKey( + store: Record | undefined, + sessionKey: string | undefined, +) { + if (!store || !sessionKey) return {}; + const direct = store[sessionKey]; + if (direct) return { entry: direct, key: sessionKey }; + const parsed = parseAgentSessionKey(sessionKey); + const legacyKey = parsed?.rest; + if (legacyKey && store[legacyKey]) { + return { entry: store[legacyKey], key: legacyKey }; + } + return {}; +} + +function resolveAbortTarget(params: { + ctx: { CommandTargetSessionKey?: string | null }; + sessionKey?: string; + sessionEntry?: SessionEntry; + sessionStore?: Record; +}) { + const targetSessionKey = + params.ctx.CommandTargetSessionKey?.trim() || params.sessionKey; + const { entry, key } = resolveSessionEntryForKey( + params.sessionStore, + targetSessionKey, + ); + if (entry && key) return { entry, key, sessionId: entry.sessionId }; + if (params.sessionEntry && params.sessionKey) { + return { + entry: params.sessionEntry, + key: params.sessionKey, + sessionId: params.sessionEntry.sessionId, + }; + } + return { entry: undefined, key: targetSessionKey, sessionId: undefined }; +} + +export const handleActivationCommand: CommandHandler = async ( + params, + allowTextCommands, +) => { + if (!allowTextCommands) return null; + const activationCommand = parseActivationCommand( + params.command.commandBodyNormalized, + ); + if (!activationCommand.hasCommand) return null; + if (!params.isGroup) { + return { + shouldContinue: false, + reply: { text: "⚙️ Group activation only applies to group chats." }, + }; + } + if (!params.command.isAuthorizedSender) { + logVerbose( + `Ignoring /activation from unauthorized sender in group: ${params.command.senderId || ""}`, + ); + return { shouldContinue: false }; + } + if (!activationCommand.mode) { + return { + shouldContinue: false, + reply: { text: "⚙️ Usage: /activation mention|always" }, + }; + } + if (params.sessionEntry && params.sessionStore && params.sessionKey) { + params.sessionEntry.groupActivation = activationCommand.mode; + params.sessionEntry.groupActivationNeedsSystemIntro = true; + params.sessionEntry.updatedAt = Date.now(); + params.sessionStore[params.sessionKey] = params.sessionEntry; + if (params.storePath) { + await saveSessionStore(params.storePath, params.sessionStore); + } + } + return { + shouldContinue: false, + reply: { + text: `⚙️ Group activation set to ${activationCommand.mode}.`, + }, + }; +}; + +export const handleSendPolicyCommand: CommandHandler = async ( + params, + allowTextCommands, +) => { + if (!allowTextCommands) return null; + const sendPolicyCommand = parseSendPolicyCommand( + params.command.commandBodyNormalized, + ); + if (!sendPolicyCommand.hasCommand) return null; + if (!params.command.isAuthorizedSender) { + logVerbose( + `Ignoring /send from unauthorized sender: ${params.command.senderId || ""}`, + ); + return { shouldContinue: false }; + } + if (!sendPolicyCommand.mode) { + return { + shouldContinue: false, + reply: { text: "⚙️ Usage: /send on|off|inherit" }, + }; + } + if (params.sessionEntry && params.sessionStore && params.sessionKey) { + if (sendPolicyCommand.mode === "inherit") { + delete params.sessionEntry.sendPolicy; + } else { + params.sessionEntry.sendPolicy = sendPolicyCommand.mode; + } + params.sessionEntry.updatedAt = Date.now(); + params.sessionStore[params.sessionKey] = params.sessionEntry; + if (params.storePath) { + await saveSessionStore(params.storePath, params.sessionStore); + } + } + const label = + sendPolicyCommand.mode === "inherit" + ? "inherit" + : sendPolicyCommand.mode === "allow" + ? "on" + : "off"; + return { + shouldContinue: false, + reply: { text: `⚙️ Send policy set to ${label}.` }, + }; +}; + +export const handleRestartCommand: CommandHandler = async ( + params, + allowTextCommands, +) => { + if (!allowTextCommands) return null; + if (params.command.commandBodyNormalized !== "/restart") return null; + if (!params.command.isAuthorizedSender) { + logVerbose( + `Ignoring /restart from unauthorized sender: ${params.command.senderId || ""}`, + ); + return { shouldContinue: false }; + } + if (params.cfg.commands?.restart !== true) { + return { + shouldContinue: false, + reply: { + text: "⚠️ /restart is disabled. Set commands.restart=true to enable.", + }, + }; + } + const hasSigusr1Listener = process.listenerCount("SIGUSR1") > 0; + if (hasSigusr1Listener) { + scheduleGatewaySigusr1Restart({ reason: "/restart" }); + return { + shouldContinue: false, + reply: { + text: "⚙️ Restarting clawdbot in-process (SIGUSR1); back in a few seconds.", + }, + }; + } + const restartMethod = triggerClawdbotRestart(); + if (!restartMethod.ok) { + const detail = restartMethod.detail + ? ` Details: ${restartMethod.detail}` + : ""; + return { + shouldContinue: false, + reply: { + text: `⚠️ Restart failed (${restartMethod.method}).${detail}`, + }, + }; + } + return { + shouldContinue: false, + reply: { + text: `⚙️ Restarting clawdbot via ${restartMethod.method}; give me a few seconds to come back online.`, + }, + }; +}; + +export const handleStopCommand: CommandHandler = async ( + params, + allowTextCommands, +) => { + if (!allowTextCommands) return null; + if (params.command.commandBodyNormalized !== "/stop") return null; + if (!params.command.isAuthorizedSender) { + logVerbose( + `Ignoring /stop from unauthorized sender: ${params.command.senderId || ""}`, + ); + return { shouldContinue: false }; + } + const abortTarget = resolveAbortTarget({ + ctx: params.ctx, + sessionKey: params.sessionKey, + sessionEntry: params.sessionEntry, + sessionStore: params.sessionStore, + }); + if (abortTarget.sessionId) { + abortEmbeddedPiRun(abortTarget.sessionId); + } + if (abortTarget.entry && params.sessionStore && abortTarget.key) { + abortTarget.entry.abortedLastRun = true; + abortTarget.entry.updatedAt = Date.now(); + params.sessionStore[abortTarget.key] = abortTarget.entry; + if (params.storePath) { + await saveSessionStore(params.storePath, params.sessionStore); + } + } else if (params.command.abortKey) { + setAbortMemory(params.command.abortKey, true); + } + return { shouldContinue: false, reply: { text: "⚙️ Agent was aborted." } }; +}; + +export const handleAbortTrigger: CommandHandler = async ( + params, + allowTextCommands, +) => { + if (!allowTextCommands) return null; + if (!isAbortTrigger(params.command.rawBodyNormalized)) return null; + const abortTarget = resolveAbortTarget({ + ctx: params.ctx, + sessionKey: params.sessionKey, + sessionEntry: params.sessionEntry, + sessionStore: params.sessionStore, + }); + if (abortTarget.sessionId) { + abortEmbeddedPiRun(abortTarget.sessionId); + } + if (abortTarget.entry && params.sessionStore && abortTarget.key) { + abortTarget.entry.abortedLastRun = true; + abortTarget.entry.updatedAt = Date.now(); + params.sessionStore[abortTarget.key] = abortTarget.entry; + if (params.storePath) { + await saveSessionStore(params.storePath, params.sessionStore); + } + } else if (params.command.abortKey) { + setAbortMemory(params.command.abortKey, true); + } + return { shouldContinue: false, reply: { text: "⚙️ Agent was aborted." } }; +}; diff --git a/src/auto-reply/reply/commands-status.ts b/src/auto-reply/reply/commands-status.ts new file mode 100644 index 000000000..800d8b210 --- /dev/null +++ b/src/auto-reply/reply/commands-status.ts @@ -0,0 +1,223 @@ +import { + resolveAgentDir, + resolveDefaultAgentId, + resolveSessionAgentId, +} from "../../agents/agent-scope.js"; +import { + ensureAuthProfileStore, + resolveAuthProfileDisplayLabel, + resolveAuthProfileOrder, +} from "../../agents/auth-profiles.js"; +import { + getCustomProviderApiKey, + resolveEnvApiKey, +} from "../../agents/model-auth.js"; +import { normalizeProviderId } from "../../agents/model-selection.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import type { SessionEntry, SessionScope } from "../../config/sessions.js"; +import { logVerbose } from "../../globals.js"; +import { + formatUsageSummaryLine, + loadProviderUsageSummary, + resolveUsageProviderId, +} from "../../infra/provider-usage.js"; +import { normalizeGroupActivation } from "../group-activation.js"; +import { buildStatusMessage } from "../status.js"; +import type { + ElevatedLevel, + ReasoningLevel, + ThinkLevel, + VerboseLevel, +} from "../thinking.js"; +import type { ReplyPayload } from "../types.js"; +import type { CommandContext } from "./commands-types.js"; +import { getFollowupQueueDepth, resolveQueueSettings } from "./queue.js"; + +function formatApiKeySnippet(apiKey: string): string { + const compact = apiKey.replace(/\s+/g, ""); + if (!compact) return "unknown"; + const edge = compact.length >= 12 ? 6 : 4; + const head = compact.slice(0, edge); + const tail = compact.slice(-edge); + return `${head}…${tail}`; +} + +function resolveModelAuthLabel( + provider?: string, + cfg?: ClawdbotConfig, + sessionEntry?: SessionEntry, + agentDir?: string, +): string | undefined { + const resolved = provider?.trim(); + if (!resolved) return undefined; + + const providerKey = normalizeProviderId(resolved); + const store = ensureAuthProfileStore(agentDir, { + allowKeychainPrompt: false, + }); + const profileOverride = sessionEntry?.authProfileOverride?.trim(); + const order = resolveAuthProfileOrder({ + cfg, + store, + provider: providerKey, + preferredProfile: profileOverride, + }); + const candidates = [profileOverride, ...order].filter(Boolean) as string[]; + + for (const profileId of candidates) { + const profile = store.profiles[profileId]; + if (!profile || normalizeProviderId(profile.provider) !== providerKey) { + continue; + } + const label = resolveAuthProfileDisplayLabel({ cfg, store, profileId }); + if (profile.type === "oauth") { + return `oauth${label ? ` (${label})` : ""}`; + } + if (profile.type === "token") { + const snippet = formatApiKeySnippet(profile.token); + return `token ${snippet}${label ? ` (${label})` : ""}`; + } + const snippet = formatApiKeySnippet(profile.key); + return `api-key ${snippet}${label ? ` (${label})` : ""}`; + } + + const envKey = resolveEnvApiKey(providerKey); + if (envKey?.apiKey) { + if (envKey.source.includes("OAUTH_TOKEN")) { + return `oauth (${envKey.source})`; + } + return `api-key ${formatApiKeySnippet(envKey.apiKey)} (${envKey.source})`; + } + + const customKey = getCustomProviderApiKey(cfg, providerKey); + if (customKey) { + return `api-key ${formatApiKeySnippet(customKey)} (models.json)`; + } + + return "unknown"; +} + +export async function buildStatusReply(params: { + cfg: ClawdbotConfig; + command: CommandContext; + sessionEntry?: SessionEntry; + sessionKey: string; + sessionScope?: SessionScope; + provider: string; + model: string; + contextTokens: number; + resolvedThinkLevel?: ThinkLevel; + resolvedVerboseLevel: VerboseLevel; + resolvedReasoningLevel: ReasoningLevel; + resolvedElevatedLevel?: ElevatedLevel; + resolveDefaultThinkingLevel: () => Promise; + isGroup: boolean; + defaultGroupActivation: () => "always" | "mention"; +}): Promise { + const { + cfg, + command, + sessionEntry, + sessionKey, + sessionScope, + provider, + model, + contextTokens, + resolvedThinkLevel, + resolvedVerboseLevel, + resolvedReasoningLevel, + resolvedElevatedLevel, + resolveDefaultThinkingLevel, + isGroup, + defaultGroupActivation, + } = params; + if (!command.isAuthorizedSender) { + logVerbose( + `Ignoring /status from unauthorized sender: ${command.senderId || ""}`, + ); + return undefined; + } + const statusAgentId = sessionKey + ? resolveSessionAgentId({ sessionKey, config: cfg }) + : resolveDefaultAgentId(cfg); + const statusAgentDir = resolveAgentDir(cfg, statusAgentId); + let usageLine: string | null = null; + try { + const usageProvider = resolveUsageProviderId(provider); + if (usageProvider) { + const usageSummary = await loadProviderUsageSummary({ + timeoutMs: 3500, + providers: [usageProvider], + agentDir: statusAgentDir, + }); + usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() }); + if ( + !usageLine && + (resolvedVerboseLevel === "on" || resolvedElevatedLevel === "on") + ) { + const entry = usageSummary.providers[0]; + if (entry?.error) { + usageLine = `📊 Usage: ${entry.displayName} (${entry.error})`; + } + } + } + } catch { + usageLine = null; + } + const queueSettings = resolveQueueSettings({ + cfg, + channel: command.channel, + sessionEntry, + }); + const queueKey = sessionKey ?? sessionEntry?.sessionId; + const queueDepth = queueKey ? getFollowupQueueDepth(queueKey) : 0; + const queueOverrides = Boolean( + sessionEntry?.queueDebounceMs ?? + sessionEntry?.queueCap ?? + sessionEntry?.queueDrop, + ); + const groupActivation = isGroup + ? (normalizeGroupActivation(sessionEntry?.groupActivation) ?? + defaultGroupActivation()) + : undefined; + const agentDefaults = cfg.agents?.defaults ?? {}; + const statusText = buildStatusMessage({ + config: cfg, + agent: { + ...agentDefaults, + model: { + ...agentDefaults.model, + primary: `${provider}/${model}`, + }, + contextTokens, + thinkingDefault: agentDefaults.thinkingDefault, + verboseDefault: agentDefaults.verboseDefault, + elevatedDefault: agentDefaults.elevatedDefault, + }, + sessionEntry, + sessionKey, + sessionScope, + groupActivation, + resolvedThink: resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()), + resolvedVerbose: resolvedVerboseLevel, + resolvedReasoning: resolvedReasoningLevel, + resolvedElevated: resolvedElevatedLevel, + modelAuth: resolveModelAuthLabel( + provider, + cfg, + sessionEntry, + statusAgentDir, + ), + usageLine: usageLine ?? undefined, + queue: { + mode: queueSettings.mode, + depth: queueDepth, + debounceMs: queueSettings.debounceMs, + cap: queueSettings.cap, + dropPolicy: queueSettings.dropPolicy, + showDetails: queueOverrides, + }, + includeTranscriptUsage: false, + }); + return { text: statusText }; +} diff --git a/src/auto-reply/reply/commands-types.ts b/src/auto-reply/reply/commands-types.ts new file mode 100644 index 000000000..b4869b17f --- /dev/null +++ b/src/auto-reply/reply/commands-types.ts @@ -0,0 +1,65 @@ +import type { ChannelId } from "../../channels/plugins/types.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import type { SessionEntry, SessionScope } from "../../config/sessions.js"; +import type { MsgContext } from "../templating.js"; +import type { + ElevatedLevel, + ReasoningLevel, + ThinkLevel, + VerboseLevel, +} from "../thinking.js"; +import type { ReplyPayload } from "../types.js"; +import type { InlineDirectives } from "./directive-handling.js"; + +export type CommandContext = { + surface: string; + channel: string; + channelId?: ChannelId; + ownerList: string[]; + isAuthorizedSender: boolean; + senderId?: string; + abortKey?: string; + rawBodyNormalized: string; + commandBodyNormalized: string; + from?: string; + to?: string; +}; + +export type HandleCommandsParams = { + ctx: MsgContext; + cfg: ClawdbotConfig; + command: CommandContext; + agentId?: string; + directives: InlineDirectives; + elevated: { + enabled: boolean; + allowed: boolean; + failures: Array<{ gate: string; key: string }>; + }; + sessionEntry?: SessionEntry; + sessionStore?: Record; + sessionKey: string; + storePath?: string; + sessionScope?: SessionScope; + workspaceDir: string; + defaultGroupActivation: () => "always" | "mention"; + resolvedThinkLevel?: ThinkLevel; + resolvedVerboseLevel: VerboseLevel; + resolvedReasoningLevel: ReasoningLevel; + resolvedElevatedLevel?: ElevatedLevel; + resolveDefaultThinkingLevel: () => Promise; + provider: string; + model: string; + contextTokens: number; + isGroup: boolean; +}; + +export type CommandHandlerResult = { + reply?: ReplyPayload; + shouldContinue: boolean; +}; + +export type CommandHandler = ( + params: HandleCommandsParams, + allowTextCommands: boolean, +) => Promise; diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index 2afe6abdb..183ed2710 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -1,1068 +1,8 @@ -import { - resolveAgentDir, - resolveDefaultAgentId, - resolveSessionAgentId, -} from "../../agents/agent-scope.js"; -import { - ensureAuthProfileStore, - resolveAuthProfileDisplayLabel, - resolveAuthProfileOrder, -} from "../../agents/auth-profiles.js"; -import { - getCustomProviderApiKey, - resolveEnvApiKey, -} from "../../agents/model-auth.js"; -import { normalizeProviderId } from "../../agents/model-selection.js"; -import { - abortEmbeddedPiRun, - compactEmbeddedPiSession, - isEmbeddedPiRunActive, - waitForEmbeddedPiRunEnd, -} from "../../agents/pi-embedded.js"; -import type { ChannelId } from "../../channels/plugins/types.js"; -import type { ClawdbotConfig } from "../../config/config.js"; -import { - readConfigFileSnapshot, - validateConfigObject, - writeConfigFile, -} from "../../config/config.js"; -import { - getConfigValueAtPath, - parseConfigPath, - setConfigValueAtPath, - unsetConfigValueAtPath, -} from "../../config/config-paths.js"; -import { - getConfigOverrides, - resetConfigOverrides, - setConfigOverride, - unsetConfigOverride, -} from "../../config/runtime-overrides.js"; -import { - resolveSessionFilePath, - type SessionEntry, - type SessionScope, - saveSessionStore, -} from "../../config/sessions.js"; -import { logVerbose } from "../../globals.js"; -import { - formatUsageSummaryLine, - loadProviderUsageSummary, - resolveUsageProviderId, -} from "../../infra/provider-usage.js"; -import { - scheduleGatewaySigusr1Restart, - triggerClawdbotRestart, -} from "../../infra/restart.js"; -import { enqueueSystemEvent } from "../../infra/system-events.js"; -import { parseAgentSessionKey } from "../../routing/session-key.js"; -import { resolveSendPolicy } from "../../sessions/send-policy.js"; -import { resolveCommandAuthorization } from "../command-auth.js"; -import { - normalizeCommandBody, - shouldHandleTextCommands, -} from "../commands-registry.js"; -import { - normalizeGroupActivation, - parseActivationCommand, -} from "../group-activation.js"; -import { parseSendPolicyCommand } from "../send-policy.js"; -import { - buildCommandsMessage, - buildHelpMessage, - buildStatusMessage, - formatContextUsageShort, - formatTokenCount, -} from "../status.js"; -import type { MsgContext } from "../templating.js"; -import type { - ElevatedLevel, - ReasoningLevel, - ThinkLevel, - VerboseLevel, -} from "../thinking.js"; -import type { ReplyPayload } from "../types.js"; -import { isAbortTrigger, setAbortMemory } from "./abort.js"; -import { handleBashChatCommand } from "./bash-command.js"; -import { parseConfigCommand } from "./config-commands.js"; -import { parseDebugCommand } from "./debug-commands.js"; -import type { InlineDirectives } from "./directive-handling.js"; -import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; -import { getFollowupQueueDepth, resolveQueueSettings } from "./queue.js"; -import { incrementCompactionCount } from "./session-updates.js"; - -function resolveSessionEntryForKey( - store: Record | undefined, - sessionKey: string | undefined, -) { - if (!store || !sessionKey) return {}; - const direct = store[sessionKey]; - if (direct) return { entry: direct, key: sessionKey }; - const parsed = parseAgentSessionKey(sessionKey); - const legacyKey = parsed?.rest; - if (legacyKey && store[legacyKey]) { - return { entry: store[legacyKey], key: legacyKey }; - } - return {}; -} - -export type CommandContext = { - surface: string; - channel: string; - channelId?: ChannelId; - ownerList: string[]; - isAuthorizedSender: boolean; - senderId?: string; - abortKey?: string; - rawBodyNormalized: string; - commandBodyNormalized: string; - from?: string; - to?: string; -}; - -export async function buildStatusReply(params: { - cfg: ClawdbotConfig; - command: CommandContext; - sessionEntry?: SessionEntry; - sessionKey: string; - sessionScope?: SessionScope; - provider: string; - model: string; - contextTokens: number; - resolvedThinkLevel?: ThinkLevel; - resolvedVerboseLevel: VerboseLevel; - resolvedReasoningLevel: ReasoningLevel; - resolvedElevatedLevel?: ElevatedLevel; - resolveDefaultThinkingLevel: () => Promise; - isGroup: boolean; - defaultGroupActivation: () => "always" | "mention"; -}): Promise { - const { - cfg, - command, - sessionEntry, - sessionKey, - sessionScope, - provider, - model, - contextTokens, - resolvedThinkLevel, - resolvedVerboseLevel, - resolvedReasoningLevel, - resolvedElevatedLevel, - resolveDefaultThinkingLevel, - isGroup, - defaultGroupActivation, - } = params; - if (!command.isAuthorizedSender) { - logVerbose( - `Ignoring /status from unauthorized sender: ${command.senderId || ""}`, - ); - return undefined; - } - const statusAgentId = sessionKey - ? resolveSessionAgentId({ sessionKey, config: cfg }) - : resolveDefaultAgentId(cfg); - const statusAgentDir = resolveAgentDir(cfg, statusAgentId); - let usageLine: string | null = null; - try { - const usageProvider = resolveUsageProviderId(provider); - if (usageProvider) { - const usageSummary = await loadProviderUsageSummary({ - timeoutMs: 3500, - providers: [usageProvider], - agentDir: statusAgentDir, - }); - usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() }); - if ( - !usageLine && - (resolvedVerboseLevel === "on" || resolvedElevatedLevel === "on") - ) { - const entry = usageSummary.providers[0]; - if (entry?.error) { - usageLine = `📊 Usage: ${entry.displayName} (${entry.error})`; - } - } - } - } catch { - usageLine = null; - } - const queueSettings = resolveQueueSettings({ - cfg, - channel: command.channel, - sessionEntry, - }); - const queueKey = sessionKey ?? sessionEntry?.sessionId; - const queueDepth = queueKey ? getFollowupQueueDepth(queueKey) : 0; - const queueOverrides = Boolean( - sessionEntry?.queueDebounceMs ?? - sessionEntry?.queueCap ?? - sessionEntry?.queueDrop, - ); - const groupActivation = isGroup - ? (normalizeGroupActivation(sessionEntry?.groupActivation) ?? - defaultGroupActivation()) - : undefined; - const agentDefaults = cfg.agents?.defaults ?? {}; - const statusText = buildStatusMessage({ - config: cfg, - agent: { - ...agentDefaults, - model: { - ...agentDefaults.model, - primary: `${provider}/${model}`, - }, - contextTokens, - thinkingDefault: agentDefaults.thinkingDefault, - verboseDefault: agentDefaults.verboseDefault, - elevatedDefault: agentDefaults.elevatedDefault, - }, - sessionEntry, - sessionKey, - sessionScope, - groupActivation, - resolvedThink: resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()), - resolvedVerbose: resolvedVerboseLevel, - resolvedReasoning: resolvedReasoningLevel, - resolvedElevated: resolvedElevatedLevel, - modelAuth: resolveModelAuthLabel( - provider, - cfg, - sessionEntry, - statusAgentDir, - ), - usageLine: usageLine ?? undefined, - queue: { - mode: queueSettings.mode, - depth: queueDepth, - debounceMs: queueSettings.debounceMs, - cap: queueSettings.cap, - dropPolicy: queueSettings.dropPolicy, - showDetails: queueOverrides, - }, - includeTranscriptUsage: false, - }); - return { text: statusText }; -} - -function formatApiKeySnippet(apiKey: string): string { - const compact = apiKey.replace(/\s+/g, ""); - if (!compact) return "unknown"; - const edge = compact.length >= 12 ? 6 : 4; - const head = compact.slice(0, edge); - const tail = compact.slice(-edge); - return `${head}…${tail}`; -} - -function resolveModelAuthLabel( - provider?: string, - cfg?: ClawdbotConfig, - sessionEntry?: SessionEntry, - agentDir?: string, -): string | undefined { - const resolved = provider?.trim(); - if (!resolved) return undefined; - - const providerKey = normalizeProviderId(resolved); - const store = ensureAuthProfileStore(agentDir, { - allowKeychainPrompt: false, - }); - const profileOverride = sessionEntry?.authProfileOverride?.trim(); - const order = resolveAuthProfileOrder({ - cfg, - store, - provider: providerKey, - preferredProfile: profileOverride, - }); - const candidates = [profileOverride, ...order].filter(Boolean) as string[]; - - for (const profileId of candidates) { - const profile = store.profiles[profileId]; - if (!profile || normalizeProviderId(profile.provider) !== providerKey) { - continue; - } - const label = resolveAuthProfileDisplayLabel({ cfg, store, profileId }); - if (profile.type === "oauth") { - return `oauth${label ? ` (${label})` : ""}`; - } - if (profile.type === "token") { - const snippet = formatApiKeySnippet(profile.token); - return `token ${snippet}${label ? ` (${label})` : ""}`; - } - const snippet = formatApiKeySnippet(profile.key); - return `api-key ${snippet}${label ? ` (${label})` : ""}`; - } - - const envKey = resolveEnvApiKey(providerKey); - if (envKey?.apiKey) { - if (envKey.source.includes("OAUTH_TOKEN")) { - return `oauth (${envKey.source})`; - } - return `api-key ${formatApiKeySnippet(envKey.apiKey)} (${envKey.source})`; - } - - const customKey = getCustomProviderApiKey(cfg, providerKey); - if (customKey) { - return `api-key ${formatApiKeySnippet(customKey)} (models.json)`; - } - - return "unknown"; -} - -function extractCompactInstructions(params: { - rawBody?: string; - ctx: MsgContext; - cfg: ClawdbotConfig; - agentId?: string; - isGroup: boolean; -}): string | undefined { - const raw = stripStructuralPrefixes(params.rawBody ?? ""); - const stripped = params.isGroup - ? stripMentions(raw, params.ctx, params.cfg, params.agentId) - : raw; - const trimmed = stripped.trim(); - if (!trimmed) return undefined; - const lowered = trimmed.toLowerCase(); - const prefix = lowered.startsWith("/compact") ? "/compact" : null; - if (!prefix) return undefined; - let rest = trimmed.slice(prefix.length).trimStart(); - if (rest.startsWith(":")) rest = rest.slice(1).trimStart(); - return rest.length ? rest : undefined; -} - -export function buildCommandContext(params: { - ctx: MsgContext; - cfg: ClawdbotConfig; - agentId?: string; - sessionKey?: string; - isGroup: boolean; - triggerBodyNormalized: string; - commandAuthorized: boolean; -}): CommandContext { - const { ctx, cfg, agentId, sessionKey, isGroup, triggerBodyNormalized } = - params; - const auth = resolveCommandAuthorization({ - ctx, - cfg, - commandAuthorized: params.commandAuthorized, - }); - const surface = (ctx.Surface ?? ctx.Provider ?? "").trim().toLowerCase(); - const channel = (ctx.Provider ?? surface).trim().toLowerCase(); - const abortKey = - sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined); - const rawBodyNormalized = triggerBodyNormalized; - const commandBodyNormalized = normalizeCommandBody( - isGroup - ? stripMentions(rawBodyNormalized, ctx, cfg, agentId) - : rawBodyNormalized, - ); - - return { - surface, - channel, - channelId: auth.providerId, - ownerList: auth.ownerList, - isAuthorizedSender: auth.isAuthorizedSender, - senderId: auth.senderId, - abortKey, - rawBodyNormalized, - commandBodyNormalized, - from: auth.from, - to: auth.to, - }; -} - -function resolveAbortTarget(params: { - ctx: MsgContext; - sessionKey?: string; - sessionEntry?: SessionEntry; - sessionStore?: Record; -}) { - const targetSessionKey = - params.ctx.CommandTargetSessionKey?.trim() || params.sessionKey; - const { entry, key } = resolveSessionEntryForKey( - params.sessionStore, - targetSessionKey, - ); - if (entry && key) return { entry, key, sessionId: entry.sessionId }; - if (params.sessionEntry && params.sessionKey) { - return { - entry: params.sessionEntry, - key: params.sessionKey, - sessionId: params.sessionEntry.sessionId, - }; - } - return { entry: undefined, key: targetSessionKey, sessionId: undefined }; -} - -export async function handleCommands(params: { - ctx: MsgContext; - cfg: ClawdbotConfig; - command: CommandContext; - agentId?: string; - directives: InlineDirectives; - elevated: { - enabled: boolean; - allowed: boolean; - failures: Array<{ gate: string; key: string }>; - }; - sessionEntry?: SessionEntry; - sessionStore?: Record; - sessionKey: string; - storePath?: string; - sessionScope?: SessionScope; - workspaceDir: string; - defaultGroupActivation: () => "always" | "mention"; - resolvedThinkLevel?: ThinkLevel; - resolvedVerboseLevel: VerboseLevel; - resolvedReasoningLevel: ReasoningLevel; - resolvedElevatedLevel?: ElevatedLevel; - resolveDefaultThinkingLevel: () => Promise; - provider: string; - model: string; - contextTokens: number; - isGroup: boolean; -}): Promise<{ - reply?: ReplyPayload; - shouldContinue: boolean; -}> { - const { - ctx, - cfg, - command, - directives, - elevated, - sessionEntry, - sessionStore, - sessionKey, - storePath, - sessionScope, - workspaceDir, - defaultGroupActivation, - resolvedThinkLevel, - resolvedVerboseLevel, - resolvedReasoningLevel, - resolvedElevatedLevel, - resolveDefaultThinkingLevel, - provider, - model, - contextTokens, - isGroup, - } = params; - - const resetRequested = - command.commandBodyNormalized === "/reset" || - command.commandBodyNormalized === "/new"; - if (resetRequested && !command.isAuthorizedSender) { - logVerbose( - `Ignoring /reset from unauthorized sender: ${command.senderId || ""}`, - ); - return { shouldContinue: false }; - } - - const activationCommand = parseActivationCommand( - command.commandBodyNormalized, - ); - const sendPolicyCommand = parseSendPolicyCommand( - command.commandBodyNormalized, - ); - const allowTextCommands = shouldHandleTextCommands({ - cfg, - surface: command.surface, - commandSource: ctx.CommandSource, - }); - - const bashSlashRequested = - allowTextCommands && - (command.commandBodyNormalized === "/bash" || - command.commandBodyNormalized.startsWith("/bash ")); - const bashBangRequested = - allowTextCommands && command.commandBodyNormalized.startsWith("!"); - if (bashSlashRequested || (bashBangRequested && command.isAuthorizedSender)) { - if (!command.isAuthorizedSender) { - logVerbose( - `Ignoring /bash from unauthorized sender: ${command.senderId || ""}`, - ); - return { shouldContinue: false }; - } - const reply = await handleBashChatCommand({ - ctx, - cfg, - agentId: params.agentId, - sessionKey, - isGroup, - elevated, - }); - return { shouldContinue: false, reply }; - } - - if (allowTextCommands && activationCommand.hasCommand) { - if (!isGroup) { - return { - shouldContinue: false, - reply: { text: "⚙️ Group activation only applies to group chats." }, - }; - } - if (!command.isAuthorizedSender) { - logVerbose( - `Ignoring /activation from unauthorized sender in group: ${command.senderId || ""}`, - ); - return { shouldContinue: false }; - } - if (!activationCommand.mode) { - return { - shouldContinue: false, - reply: { text: "⚙️ Usage: /activation mention|always" }, - }; - } - if (sessionEntry && sessionStore && sessionKey) { - sessionEntry.groupActivation = activationCommand.mode; - sessionEntry.groupActivationNeedsSystemIntro = true; - sessionEntry.updatedAt = Date.now(); - sessionStore[sessionKey] = sessionEntry; - if (storePath) { - await saveSessionStore(storePath, sessionStore); - } - } - return { - shouldContinue: false, - reply: { text: `⚙️ Group activation set to ${activationCommand.mode}.` }, - }; - } - - if (allowTextCommands && sendPolicyCommand.hasCommand) { - if (!command.isAuthorizedSender) { - logVerbose( - `Ignoring /send from unauthorized sender: ${command.senderId || ""}`, - ); - return { shouldContinue: false }; - } - if (!sendPolicyCommand.mode) { - return { - shouldContinue: false, - reply: { text: "⚙️ Usage: /send on|off|inherit" }, - }; - } - if (sessionEntry && sessionStore && sessionKey) { - if (sendPolicyCommand.mode === "inherit") { - delete sessionEntry.sendPolicy; - } else { - sessionEntry.sendPolicy = sendPolicyCommand.mode; - } - sessionEntry.updatedAt = Date.now(); - sessionStore[sessionKey] = sessionEntry; - if (storePath) { - await saveSessionStore(storePath, sessionStore); - } - } - const label = - sendPolicyCommand.mode === "inherit" - ? "inherit" - : sendPolicyCommand.mode === "allow" - ? "on" - : "off"; - return { - shouldContinue: false, - reply: { text: `⚙️ Send policy set to ${label}.` }, - }; - } - - if (allowTextCommands && command.commandBodyNormalized === "/restart") { - if (!command.isAuthorizedSender) { - logVerbose( - `Ignoring /restart from unauthorized sender: ${command.senderId || ""}`, - ); - return { shouldContinue: false }; - } - if (cfg.commands?.restart !== true) { - return { - shouldContinue: false, - reply: { - text: "⚠️ /restart is disabled. Set commands.restart=true to enable.", - }, - }; - } - const hasSigusr1Listener = process.listenerCount("SIGUSR1") > 0; - if (hasSigusr1Listener) { - scheduleGatewaySigusr1Restart({ reason: "/restart" }); - return { - shouldContinue: false, - reply: { - text: "⚙️ Restarting clawdbot in-process (SIGUSR1); back in a few seconds.", - }, - }; - } - const restartMethod = triggerClawdbotRestart(); - if (!restartMethod.ok) { - const detail = restartMethod.detail - ? ` Details: ${restartMethod.detail}` - : ""; - return { - shouldContinue: false, - reply: { - text: `⚠️ Restart failed (${restartMethod.method}).${detail}`, - }, - }; - } - return { - shouldContinue: false, - reply: { - text: `⚙️ Restarting clawdbot via ${restartMethod.method}; give me a few seconds to come back online.`, - }, - }; - } - - const helpRequested = command.commandBodyNormalized === "/help"; - if (allowTextCommands && helpRequested) { - if (!command.isAuthorizedSender) { - logVerbose( - `Ignoring /help from unauthorized sender: ${command.senderId || ""}`, - ); - return { shouldContinue: false }; - } - return { shouldContinue: false, reply: { text: buildHelpMessage(cfg) } }; - } - - const commandsRequested = command.commandBodyNormalized === "/commands"; - if (allowTextCommands && commandsRequested) { - if (!command.isAuthorizedSender) { - logVerbose( - `Ignoring /commands from unauthorized sender: ${command.senderId || ""}`, - ); - return { shouldContinue: false }; - } - return { - shouldContinue: false, - reply: { text: buildCommandsMessage(cfg) }, - }; - } - - const statusRequested = - directives.hasStatusDirective || - command.commandBodyNormalized === "/status"; - if (allowTextCommands && statusRequested) { - if (!command.isAuthorizedSender) { - logVerbose( - `Ignoring /status from unauthorized sender: ${command.senderId || ""}`, - ); - return { shouldContinue: false }; - } - const reply = await buildStatusReply({ - cfg, - command, - sessionEntry, - sessionKey, - sessionScope, - provider, - model, - contextTokens, - resolvedThinkLevel, - resolvedVerboseLevel, - resolvedReasoningLevel, - resolvedElevatedLevel, - resolveDefaultThinkingLevel, - isGroup, - defaultGroupActivation, - }); - return { shouldContinue: false, reply }; - } - - const whoamiRequested = command.commandBodyNormalized === "/whoami"; - if (allowTextCommands && whoamiRequested) { - if (!command.isAuthorizedSender) { - logVerbose( - `Ignoring /whoami from unauthorized sender: ${command.senderId || ""}`, - ); - return { shouldContinue: false }; - } - const senderId = ctx.SenderId ?? ""; - const senderUsername = ctx.SenderUsername ?? ""; - const lines = ["🧭 Identity", `Channel: ${command.channel}`]; - if (senderId) lines.push(`User id: ${senderId}`); - if (senderUsername) { - const handle = senderUsername.startsWith("@") - ? senderUsername - : `@${senderUsername}`; - lines.push(`Username: ${handle}`); - } - if (ctx.ChatType === "group" && ctx.From) { - lines.push(`Chat: ${ctx.From}`); - } - if (ctx.MessageThreadId != null) { - lines.push(`Thread: ${ctx.MessageThreadId}`); - } - if (senderId) { - lines.push(`AllowFrom: ${senderId}`); - } - return { shouldContinue: false, reply: { text: lines.join("\n") } }; - } - - const configCommand = allowTextCommands - ? parseConfigCommand(command.commandBodyNormalized) - : null; - if (configCommand) { - if (!command.isAuthorizedSender) { - logVerbose( - `Ignoring /config from unauthorized sender: ${command.senderId || ""}`, - ); - return { shouldContinue: false }; - } - if (cfg.commands?.config !== true) { - return { - shouldContinue: false, - reply: { - text: "⚠️ /config is disabled. Set commands.config=true to enable.", - }, - }; - } - if (configCommand.action === "error") { - return { - shouldContinue: false, - reply: { text: `⚠️ ${configCommand.message}` }, - }; - } - const snapshot = await readConfigFileSnapshot(); - if ( - !snapshot.valid || - !snapshot.parsed || - typeof snapshot.parsed !== "object" - ) { - return { - shouldContinue: false, - reply: { - text: "⚠️ Config file is invalid; fix it before using /config.", - }, - }; - } - const parsedBase = structuredClone( - snapshot.parsed as Record, - ); - - if (configCommand.action === "show") { - const pathRaw = configCommand.path?.trim(); - if (pathRaw) { - const parsedPath = parseConfigPath(pathRaw); - if (!parsedPath.ok || !parsedPath.path) { - return { - shouldContinue: false, - reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` }, - }; - } - const value = getConfigValueAtPath(parsedBase, parsedPath.path); - const rendered = JSON.stringify(value ?? null, null, 2); - return { - shouldContinue: false, - reply: { - text: `⚙️ Config ${pathRaw}:\n\`\`\`json\n${rendered}\n\`\`\``, - }, - }; - } - const json = JSON.stringify(parsedBase, null, 2); - return { - shouldContinue: false, - reply: { text: `⚙️ Config (raw):\n\`\`\`json\n${json}\n\`\`\`` }, - }; - } - - if (configCommand.action === "unset") { - const parsedPath = parseConfigPath(configCommand.path); - if (!parsedPath.ok || !parsedPath.path) { - return { - shouldContinue: false, - reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` }, - }; - } - const removed = unsetConfigValueAtPath(parsedBase, parsedPath.path); - if (!removed) { - return { - shouldContinue: false, - reply: { text: `⚙️ No config value found for ${configCommand.path}.` }, - }; - } - const validated = validateConfigObject(parsedBase); - if (!validated.ok) { - const issue = validated.issues[0]; - return { - shouldContinue: false, - reply: { - text: `⚠️ Config invalid after unset (${issue.path}: ${issue.message}).`, - }, - }; - } - await writeConfigFile(validated.config); - return { - shouldContinue: false, - reply: { text: `⚙️ Config updated: ${configCommand.path} removed.` }, - }; - } - - if (configCommand.action === "set") { - const parsedPath = parseConfigPath(configCommand.path); - if (!parsedPath.ok || !parsedPath.path) { - return { - shouldContinue: false, - reply: { text: `⚠️ ${parsedPath.error ?? "Invalid path."}` }, - }; - } - setConfigValueAtPath(parsedBase, parsedPath.path, configCommand.value); - const validated = validateConfigObject(parsedBase); - if (!validated.ok) { - const issue = validated.issues[0]; - return { - shouldContinue: false, - reply: { - text: `⚠️ Config invalid after set (${issue.path}: ${issue.message}).`, - }, - }; - } - await writeConfigFile(validated.config); - const valueLabel = - typeof configCommand.value === "string" - ? `"${configCommand.value}"` - : JSON.stringify(configCommand.value); - return { - shouldContinue: false, - reply: { - text: `⚙️ Config updated: ${configCommand.path}=${valueLabel ?? "null"}`, - }, - }; - } - } - - const debugCommand = allowTextCommands - ? parseDebugCommand(command.commandBodyNormalized) - : null; - if (debugCommand) { - if (!command.isAuthorizedSender) { - logVerbose( - `Ignoring /debug from unauthorized sender: ${command.senderId || ""}`, - ); - return { shouldContinue: false }; - } - if (cfg.commands?.debug !== true) { - return { - shouldContinue: false, - reply: { - text: "⚠️ /debug is disabled. Set commands.debug=true to enable.", - }, - }; - } - if (debugCommand.action === "error") { - return { - shouldContinue: false, - reply: { text: `⚠️ ${debugCommand.message}` }, - }; - } - if (debugCommand.action === "show") { - const overrides = getConfigOverrides(); - const hasOverrides = Object.keys(overrides).length > 0; - if (!hasOverrides) { - return { - shouldContinue: false, - reply: { text: "⚙️ Debug overrides: (none)" }, - }; - } - const json = JSON.stringify(overrides, null, 2); - return { - shouldContinue: false, - reply: { - text: `⚙️ Debug overrides (memory-only):\n\`\`\`json\n${json}\n\`\`\``, - }, - }; - } - if (debugCommand.action === "reset") { - resetConfigOverrides(); - return { - shouldContinue: false, - reply: { text: "⚙️ Debug overrides cleared; using config on disk." }, - }; - } - if (debugCommand.action === "unset") { - const result = unsetConfigOverride(debugCommand.path); - if (!result.ok) { - return { - shouldContinue: false, - reply: { text: `⚠️ ${result.error ?? "Invalid path."}` }, - }; - } - if (!result.removed) { - return { - shouldContinue: false, - reply: { - text: `⚙️ No debug override found for ${debugCommand.path}.`, - }, - }; - } - return { - shouldContinue: false, - reply: { text: `⚙️ Debug override removed for ${debugCommand.path}.` }, - }; - } - if (debugCommand.action === "set") { - const result = setConfigOverride(debugCommand.path, debugCommand.value); - if (!result.ok) { - return { - shouldContinue: false, - reply: { text: `⚠️ ${result.error ?? "Invalid override."}` }, - }; - } - const valueLabel = - typeof debugCommand.value === "string" - ? `"${debugCommand.value}"` - : JSON.stringify(debugCommand.value); - return { - shouldContinue: false, - reply: { - text: `⚙️ Debug override set: ${debugCommand.path}=${valueLabel ?? "null"}`, - }, - }; - } - } - - const stopRequested = command.commandBodyNormalized === "/stop"; - if (allowTextCommands && stopRequested) { - if (!command.isAuthorizedSender) { - logVerbose( - `Ignoring /stop from unauthorized sender: ${command.senderId || ""}`, - ); - return { shouldContinue: false }; - } - const abortTarget = resolveAbortTarget({ - ctx, - sessionKey, - sessionEntry, - sessionStore, - }); - if (abortTarget.sessionId) { - abortEmbeddedPiRun(abortTarget.sessionId); - } - if (abortTarget.entry && sessionStore && abortTarget.key) { - abortTarget.entry.abortedLastRun = true; - abortTarget.entry.updatedAt = Date.now(); - sessionStore[abortTarget.key] = abortTarget.entry; - if (storePath) { - await saveSessionStore(storePath, sessionStore); - } - } else if (command.abortKey) { - setAbortMemory(command.abortKey, true); - } - return { shouldContinue: false, reply: { text: "⚙️ Agent was aborted." } }; - } - - const compactRequested = - command.commandBodyNormalized === "/compact" || - command.commandBodyNormalized.startsWith("/compact "); - if (compactRequested) { - if (!command.isAuthorizedSender) { - logVerbose( - `Ignoring /compact from unauthorized sender: ${command.senderId || ""}`, - ); - return { shouldContinue: false }; - } - if (!sessionEntry?.sessionId) { - return { - shouldContinue: false, - reply: { text: "⚙️ Compaction unavailable (missing session id)." }, - }; - } - const sessionId = sessionEntry.sessionId; - if (isEmbeddedPiRunActive(sessionId)) { - abortEmbeddedPiRun(sessionId); - await waitForEmbeddedPiRunEnd(sessionId, 15_000); - } - const customInstructions = extractCompactInstructions({ - rawBody: ctx.CommandBody ?? ctx.RawBody ?? ctx.Body, - ctx, - cfg, - agentId: params.agentId, - isGroup, - }); - const result = await compactEmbeddedPiSession({ - sessionId, - sessionKey, - messageChannel: command.channel, - sessionFile: resolveSessionFilePath(sessionId, sessionEntry), - workspaceDir, - config: cfg, - skillsSnapshot: sessionEntry.skillsSnapshot, - provider, - model, - thinkLevel: resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()), - bashElevated: { - enabled: false, - allowed: false, - defaultLevel: "off", - }, - customInstructions, - ownerNumbers: - command.ownerList.length > 0 ? command.ownerList : undefined, - }); - - const totalTokens = - sessionEntry.totalTokens ?? - (sessionEntry.inputTokens ?? 0) + (sessionEntry.outputTokens ?? 0); - const contextSummary = formatContextUsageShort( - totalTokens > 0 ? totalTokens : null, - contextTokens ?? sessionEntry.contextTokens ?? null, - ); - const compactLabel = result.ok - ? result.compacted - ? result.result?.tokensBefore - ? `Compacted (${formatTokenCount(result.result.tokensBefore)} before)` - : "Compacted" - : "Compaction skipped" - : "Compaction failed"; - if (result.ok && result.compacted) { - await incrementCompactionCount({ - sessionEntry, - sessionStore, - sessionKey, - storePath, - }); - } - const reason = result.reason?.trim(); - const line = reason - ? `${compactLabel}: ${reason} • ${contextSummary}` - : `${compactLabel} • ${contextSummary}`; - enqueueSystemEvent(line, { sessionKey }); - return { shouldContinue: false, reply: { text: `⚙️ ${line}` } }; - } - - const abortRequested = isAbortTrigger(command.rawBodyNormalized); - if (allowTextCommands && abortRequested) { - const abortTarget = resolveAbortTarget({ - ctx, - sessionKey, - sessionEntry, - sessionStore, - }); - if (abortTarget.sessionId) { - abortEmbeddedPiRun(abortTarget.sessionId); - } - if (abortTarget.entry && sessionStore && abortTarget.key) { - abortTarget.entry.abortedLastRun = true; - abortTarget.entry.updatedAt = Date.now(); - sessionStore[abortTarget.key] = abortTarget.entry; - if (storePath) { - await saveSessionStore(storePath, sessionStore); - } - } else if (command.abortKey) { - setAbortMemory(command.abortKey, true); - } - return { shouldContinue: false, reply: { text: "⚙️ Agent was aborted." } }; - } - - const sendPolicy = resolveSendPolicy({ - cfg, - entry: sessionEntry, - sessionKey, - channel: sessionEntry?.channel ?? command.channel, - chatType: sessionEntry?.chatType, - }); - if (sendPolicy === "deny") { - logVerbose(`Send blocked by policy for session ${sessionKey ?? "unknown"}`); - return { shouldContinue: false }; - } - - return { shouldContinue: true }; -} +export { buildCommandContext } from "./commands-context.js"; +export { handleCommands } from "./commands-core.js"; +export { buildStatusReply } from "./commands-status.js"; +export type { + CommandContext, + CommandHandlerResult, + HandleCommandsParams, +} from "./commands-types.js"; diff --git a/src/auto-reply/reply/get-reply-directives-apply.ts b/src/auto-reply/reply/get-reply-directives-apply.ts new file mode 100644 index 000000000..a167e0a86 --- /dev/null +++ b/src/auto-reply/reply/get-reply-directives-apply.ts @@ -0,0 +1,309 @@ +import type { ClawdbotConfig } from "../../config/config.js"; +import type { SessionEntry } from "../../config/sessions.js"; +import type { MsgContext } from "../templating.js"; +import type { + ElevatedLevel, + ReasoningLevel, + ThinkLevel, + VerboseLevel, +} from "../thinking.js"; +import type { ReplyPayload } from "../types.js"; +import { buildStatusReply } from "./commands.js"; +import { + applyInlineDirectivesFastLane, + handleDirectiveOnly, + type InlineDirectives, + isDirectiveOnly, + persistInlineDirectives, +} from "./directive-handling.js"; +import type { createModelSelectionState } from "./model-selection.js"; +import type { TypingController } from "./typing.js"; + +type AgentDefaults = NonNullable["defaults"]; + +export type ApplyDirectiveResult = + | { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined } + | { + kind: "continue"; + directives: InlineDirectives; + provider: string; + model: string; + contextTokens: number; + directiveAck?: ReplyPayload; + perMessageQueueMode?: InlineDirectives["queueMode"]; + perMessageQueueOptions?: { + debounceMs?: number; + cap?: number; + dropPolicy?: InlineDirectives["dropPolicy"]; + }; + }; + +export async function applyInlineDirectiveOverrides(params: { + ctx: MsgContext; + cfg: ClawdbotConfig; + agentId: string; + agentDir: string; + agentCfg: AgentDefaults; + sessionEntry?: SessionEntry; + sessionStore?: Record; + sessionKey: string; + storePath?: string; + sessionScope: Parameters[0]["sessionScope"]; + isGroup: boolean; + allowTextCommands: boolean; + command: Parameters[0]["command"]; + directives: InlineDirectives; + messageProviderKey: string; + elevatedEnabled: boolean; + elevatedAllowed: boolean; + elevatedFailures: Array<{ gate: string; key: string }>; + defaultProvider: string; + defaultModel: string; + aliasIndex: Parameters[0]["aliasIndex"]; + provider: string; + model: string; + modelState: Awaited>; + initialModelLabel: string; + formatModelSwitchEvent: (label: string, alias?: string) => string; + resolvedElevatedLevel: ElevatedLevel; + defaultActivation: () => ReturnType< + Parameters[0]["defaultGroupActivation"] + >; + contextTokens: number; + effectiveModelDirective?: string; + typing: TypingController; +}): Promise { + const { + ctx, + cfg, + agentId, + agentDir, + agentCfg, + sessionEntry, + sessionStore, + sessionKey, + storePath, + sessionScope, + isGroup, + allowTextCommands, + command, + messageProviderKey, + elevatedEnabled, + elevatedAllowed, + elevatedFailures, + defaultProvider, + defaultModel, + aliasIndex, + modelState, + initialModelLabel, + formatModelSwitchEvent, + resolvedElevatedLevel, + defaultActivation, + typing, + effectiveModelDirective, + } = params; + let { directives } = params; + let { provider, model } = params; + let { contextTokens } = params; + + let directiveAck: ReplyPayload | undefined; + + if (!command.isAuthorizedSender) { + directives = { + ...directives, + hasThinkDirective: false, + hasVerboseDirective: false, + hasReasoningDirective: false, + hasElevatedDirective: false, + hasStatusDirective: false, + hasModelDirective: false, + hasQueueDirective: false, + queueReset: false, + }; + } + + if ( + isDirectiveOnly({ + directives, + cleanedBody: directives.cleaned, + ctx, + cfg, + agentId, + isGroup, + }) + ) { + if (!command.isAuthorizedSender) { + typing.cleanup(); + return { kind: "reply", reply: undefined }; + } + const resolvedDefaultThinkLevel = + (sessionEntry?.thinkingLevel as ThinkLevel | undefined) ?? + (agentCfg?.thinkingDefault as ThinkLevel | undefined) ?? + (await modelState.resolveDefaultThinkingLevel()); + const currentThinkLevel = resolvedDefaultThinkLevel; + 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, + elevatedFailures, + messageProviderKey, + 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 && + command.isAuthorizedSender + ) { + statusReply = await buildStatusReply({ + cfg, + command, + sessionEntry, + sessionKey, + sessionScope, + provider, + model, + contextTokens, + resolvedThinkLevel: resolvedDefaultThinkLevel, + resolvedVerboseLevel: (currentVerboseLevel ?? "off") as VerboseLevel, + resolvedReasoningLevel: (currentReasoningLevel ?? + "off") as ReasoningLevel, + resolvedElevatedLevel, + resolveDefaultThinkingLevel: async () => resolvedDefaultThinkLevel, + isGroup, + defaultGroupActivation: defaultActivation, + }); + } + typing.cleanup(); + if (statusReply?.text && directiveReply?.text) { + return { + kind: "reply", + reply: { text: `${directiveReply.text}\n${statusReply.text}` }, + }; + } + return { kind: "reply", reply: statusReply ?? directiveReply }; + } + + const hasAnyDirective = + directives.hasThinkDirective || + directives.hasVerboseDirective || + directives.hasReasoningDirective || + directives.hasElevatedDirective || + directives.hasModelDirective || + directives.hasQueueDirective || + directives.hasStatusDirective; + + if (hasAnyDirective && command.isAuthorizedSender) { + const fastLane = await applyInlineDirectivesFastLane({ + directives, + commandAuthorized: command.isAuthorizedSender, + ctx, + cfg, + agentId, + isGroup, + sessionEntry, + sessionStore, + sessionKey, + storePath, + elevatedEnabled, + elevatedAllowed, + elevatedFailures, + messageProviderKey, + defaultProvider, + defaultModel, + aliasIndex, + allowedModelKeys: modelState.allowedModelKeys, + allowedModelCatalog: modelState.allowedModelCatalog, + resetModelOverride: modelState.resetModelOverride, + provider, + model, + initialModelLabel, + formatModelSwitchEvent, + agentCfg, + modelState: { + resolveDefaultThinkingLevel: modelState.resolveDefaultThinkingLevel, + allowedModelKeys: modelState.allowedModelKeys, + allowedModelCatalog: modelState.allowedModelCatalog, + resetModelOverride: modelState.resetModelOverride, + }, + }); + directiveAck = fastLane.directiveAck; + provider = fastLane.provider; + model = fastLane.model; + } + + 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; + + return { + kind: "continue", + directives, + provider, + model, + contextTokens, + directiveAck, + perMessageQueueMode, + perMessageQueueOptions, + }; +} diff --git a/src/auto-reply/reply/get-reply-directives-utils.ts b/src/auto-reply/reply/get-reply-directives-utils.ts new file mode 100644 index 000000000..574c66909 --- /dev/null +++ b/src/auto-reply/reply/get-reply-directives-utils.ts @@ -0,0 +1,33 @@ +import type { InlineDirectives } from "./directive-handling.js"; + +export function clearInlineDirectives(cleaned: string): InlineDirectives { + return { + 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, + }; +} diff --git a/src/auto-reply/reply/get-reply-directives.ts b/src/auto-reply/reply/get-reply-directives.ts new file mode 100644 index 000000000..bee712474 --- /dev/null +++ b/src/auto-reply/reply/get-reply-directives.ts @@ -0,0 +1,473 @@ +import type { ModelAliasIndex } from "../../agents/model-selection.js"; +import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import type { SessionEntry } from "../../config/sessions.js"; +import { + listChatCommands, + shouldHandleTextCommands, +} from "../commands-registry.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 { resolveBlockStreamingChunking } from "./block-streaming.js"; +import { buildCommandContext } from "./commands.js"; +import { + type InlineDirectives, + parseInlineDirectives, +} from "./directive-handling.js"; +import { applyInlineDirectiveOverrides } from "./get-reply-directives-apply.js"; +import { clearInlineDirectives } from "./get-reply-directives-utils.js"; +import { + defaultGroupActivation, + resolveGroupRequireMention, +} from "./groups.js"; +import { + CURRENT_MESSAGE_MARKER, + stripMentions, + stripStructuralPrefixes, +} from "./mentions.js"; +import { + createModelSelectionState, + resolveContextTokens, +} from "./model-selection.js"; +import { + formatElevatedUnavailableMessage, + resolveElevatedPermissions, +} from "./reply-elevated.js"; +import { stripInlineStatus } from "./reply-inline.js"; +import type { TypingController } from "./typing.js"; + +type AgentDefaults = NonNullable["defaults"]; + +export type ReplyDirectiveContinuation = { + commandSource: string; + command: ReturnType; + allowTextCommands: boolean; + directives: InlineDirectives; + cleanedBody: string; + messageProviderKey: string; + elevatedEnabled: boolean; + elevatedAllowed: boolean; + elevatedFailures: Array<{ gate: string; key: string }>; + defaultActivation: ReturnType; + resolvedThinkLevel: ThinkLevel | undefined; + resolvedVerboseLevel: VerboseLevel | undefined; + resolvedReasoningLevel: ReasoningLevel; + resolvedElevatedLevel: ElevatedLevel; + blockStreamingEnabled: boolean; + blockReplyChunking?: { + minChars: number; + maxChars: number; + breakPreference: "paragraph" | "newline" | "sentence"; + }; + resolvedBlockStreamingBreak: "text_end" | "message_end"; + provider: string; + model: string; + modelState: Awaited>; + contextTokens: number; + inlineStatusRequested: boolean; + directiveAck?: ReplyPayload; + perMessageQueueMode?: InlineDirectives["queueMode"]; + perMessageQueueOptions?: { + debounceMs?: number; + cap?: number; + dropPolicy?: InlineDirectives["dropPolicy"]; + }; +}; + +export type ReplyDirectiveResult = + | { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined } + | { kind: "continue"; result: ReplyDirectiveContinuation }; + +export async function resolveReplyDirectives(params: { + ctx: MsgContext; + cfg: ClawdbotConfig; + agentId: string; + agentDir: string; + agentCfg: AgentDefaults; + sessionCtx: TemplateContext; + sessionEntry?: SessionEntry; + sessionStore?: Record; + sessionKey: string; + storePath?: string; + sessionScope: Parameters< + typeof applyInlineDirectiveOverrides + >[0]["sessionScope"]; + groupResolution: Parameters< + typeof resolveGroupRequireMention + >[0]["groupResolution"]; + isGroup: boolean; + triggerBodyNormalized: string; + commandAuthorized: boolean; + defaultProvider: string; + defaultModel: string; + aliasIndex: ModelAliasIndex; + provider: string; + model: string; + typing: TypingController; + opts?: GetReplyOptions; +}): Promise { + const { + ctx, + cfg, + agentId, + agentCfg, + agentDir, + sessionCtx, + sessionEntry, + sessionStore, + sessionKey, + storePath, + sessionScope, + groupResolution, + isGroup, + triggerBodyNormalized, + commandAuthorized, + defaultProvider, + defaultModel, + provider: initialProvider, + model: initialModel, + typing, + opts, + } = params; + let provider = initialProvider; + let model = initialModel; + + // Prefer CommandBody/RawBody (clean message without structural context) for directive parsing. + // Keep `Body`/`BodyStripped` as the best-available prompt text (may include context). + const commandSource = + sessionCtx.CommandBody ?? + sessionCtx.RawBody ?? + sessionCtx.BodyStripped ?? + sessionCtx.Body ?? + ""; + const command = buildCommandContext({ + ctx, + cfg, + agentId, + sessionKey, + isGroup, + triggerBodyNormalized, + commandAuthorized, + }); + const allowTextCommands = shouldHandleTextCommands({ + cfg, + surface: command.surface, + commandSource: ctx.CommandSource, + }); + 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())); + const allowStatusDirective = allowTextCommands && command.isAuthorizedSender; + let parsedDirectives = parseInlineDirectives(commandSource, { + modelAliases: configuredAliases, + allowStatusDirective, + }); + const hasInlineStatus = + parsedDirectives.hasStatusDirective && + parsedDirectives.cleaned.trim().length > 0; + if (hasInlineStatus) { + parsedDirectives = { + ...parsedDirectives, + hasStatusDirective: false, + }; + } + if ( + isGroup && + ctx.WasMentioned !== true && + parsedDirectives.hasElevatedDirective + ) { + if (parsedDirectives.elevatedLevel !== "off") { + parsedDirectives = { + ...parsedDirectives, + hasElevatedDirective: false, + elevatedLevel: undefined, + rawElevatedLevel: undefined, + }; + } + } + const hasInlineDirective = + parsedDirectives.hasThinkDirective || + parsedDirectives.hasVerboseDirective || + parsedDirectives.hasReasoningDirective || + parsedDirectives.hasElevatedDirective || + parsedDirectives.hasModelDirective || + parsedDirectives.hasQueueDirective; + if (hasInlineDirective) { + 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) { + const allowInlineStatus = + parsedDirectives.hasStatusDirective && + allowTextCommands && + command.isAuthorizedSender; + parsedDirectives = allowInlineStatus + ? { + ...clearInlineDirectives(parsedDirectives.cleaned), + hasStatusDirective: true, + } + : clearInlineDirectives(parsedDirectives.cleaned); + } + } + } + let directives = commandAuthorized + ? parsedDirectives + : { + ...parsedDirectives, + hasThinkDirective: false, + hasVerboseDirective: false, + hasReasoningDirective: false, + hasStatusDirective: false, + hasModelDirective: false, + hasQueueDirective: false, + queueReset: false, + }; + const existingBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""; + let cleanedBody = (() => { + if (!existingBody) return parsedDirectives.cleaned; + if (!sessionCtx.CommandBody && !sessionCtx.RawBody) { + return parseInlineDirectives(existingBody, { + modelAliases: configuredAliases, + allowStatusDirective, + }).cleaned; + } + + const markerIndex = existingBody.indexOf(CURRENT_MESSAGE_MARKER); + if (markerIndex < 0) { + return parseInlineDirectives(existingBody, { + modelAliases: configuredAliases, + allowStatusDirective, + }).cleaned; + } + + const head = existingBody.slice( + 0, + markerIndex + CURRENT_MESSAGE_MARKER.length, + ); + const tail = existingBody.slice( + markerIndex + CURRENT_MESSAGE_MARKER.length, + ); + const cleanedTail = parseInlineDirectives(tail, { + modelAliases: configuredAliases, + allowStatusDirective, + }).cleaned; + return `${head}${cleanedTail}`; + })(); + + if (allowStatusDirective) { + cleanedBody = stripInlineStatus(cleanedBody).cleaned; + } + + sessionCtx.Body = cleanedBody; + sessionCtx.BodyStripped = cleanedBody; + + const messageProviderKey = + sessionCtx.Provider?.trim().toLowerCase() ?? + ctx.Provider?.trim().toLowerCase() ?? + ""; + const elevated = resolveElevatedPermissions({ + cfg, + agentId, + ctx, + provider: messageProviderKey, + }); + const elevatedEnabled = elevated.enabled; + const elevatedAllowed = elevated.allowed; + const elevatedFailures = elevated.failures; + if ( + directives.hasElevatedDirective && + (!elevatedEnabled || !elevatedAllowed) + ) { + typing.cleanup(); + const runtimeSandboxed = resolveSandboxRuntimeStatus({ + cfg, + sessionKey: ctx.SessionKey, + }).sandboxed; + return { + kind: "reply", + reply: { + text: formatElevatedUnavailableMessage({ + runtimeSandboxed, + failures: elevatedFailures, + sessionKey: ctx.SessionKey, + }), + }, + }; + } + + const requireMention = resolveGroupRequireMention({ + cfg, + ctx: sessionCtx, + groupResolution, + }); + const defaultActivation = defaultGroupActivation(requireMention); + const 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 = + opts?.disableBlockStreaming === true + ? "off" + : opts?.disableBlockStreaming === false + ? "on" + : agentCfg?.blockStreamingDefault === "on" + ? "on" + : "off"; + 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, + sessionCtx.AccountId, + ) + : 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 inlineStatusRequested = + hasInlineStatus && allowTextCommands && command.isAuthorizedSender; + + const applyResult = await applyInlineDirectiveOverrides({ + ctx, + cfg, + agentId, + agentDir, + agentCfg, + sessionEntry, + sessionStore, + sessionKey, + storePath, + sessionScope, + isGroup, + allowTextCommands, + command, + directives, + messageProviderKey, + elevatedEnabled, + elevatedAllowed, + elevatedFailures, + defaultProvider, + defaultModel, + aliasIndex: params.aliasIndex, + provider, + model, + modelState, + initialModelLabel, + formatModelSwitchEvent, + resolvedElevatedLevel, + defaultActivation: () => defaultActivation, + contextTokens, + effectiveModelDirective, + typing, + }); + if (applyResult.kind === "reply") { + return { kind: "reply", reply: applyResult.reply }; + } + directives = applyResult.directives; + provider = applyResult.provider; + model = applyResult.model; + contextTokens = applyResult.contextTokens; + const { directiveAck, perMessageQueueMode, perMessageQueueOptions } = + applyResult; + + return { + kind: "continue", + result: { + commandSource, + command, + allowTextCommands, + directives, + cleanedBody, + messageProviderKey, + elevatedEnabled, + elevatedAllowed, + elevatedFailures, + defaultActivation, + resolvedThinkLevel, + resolvedVerboseLevel, + resolvedReasoningLevel, + resolvedElevatedLevel, + blockStreamingEnabled, + blockReplyChunking, + resolvedBlockStreamingBreak, + provider, + model, + modelState, + contextTokens, + inlineStatusRequested, + directiveAck, + perMessageQueueMode, + perMessageQueueOptions, + }, + }; +} diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts new file mode 100644 index 000000000..7886ba0f2 --- /dev/null +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -0,0 +1,256 @@ +import { getChannelDock } from "../../channels/dock.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import type { SessionEntry } from "../../config/sessions.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 { getAbortMemory } from "./abort.js"; +import { buildStatusReply, handleCommands } from "./commands.js"; +import type { InlineDirectives } from "./directive-handling.js"; +import { isDirectiveOnly } from "./directive-handling.js"; +import type { createModelSelectionState } from "./model-selection.js"; +import { extractInlineSimpleCommand } from "./reply-inline.js"; +import type { TypingController } from "./typing.js"; + +export type InlineActionResult = + | { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined } + | { + kind: "continue"; + directives: InlineDirectives; + abortedLastRun: boolean; + }; + +export async function handleInlineActions(params: { + ctx: MsgContext; + sessionCtx: TemplateContext; + cfg: ClawdbotConfig; + agentId: string; + sessionEntry?: SessionEntry; + sessionStore?: Record; + sessionKey: string; + storePath?: string; + sessionScope: Parameters[0]["sessionScope"]; + workspaceDir: string; + isGroup: boolean; + opts?: GetReplyOptions; + typing: TypingController; + allowTextCommands: boolean; + inlineStatusRequested: boolean; + command: Parameters[0]["command"]; + directives: InlineDirectives; + cleanedBody: string; + elevatedEnabled: boolean; + elevatedAllowed: boolean; + elevatedFailures: Array<{ gate: string; key: string }>; + defaultActivation: Parameters< + typeof buildStatusReply + >[0]["defaultGroupActivation"]; + resolvedThinkLevel: ThinkLevel | undefined; + resolvedVerboseLevel: VerboseLevel | undefined; + resolvedReasoningLevel: ReasoningLevel; + resolvedElevatedLevel: ElevatedLevel; + resolveDefaultThinkingLevel: Awaited< + ReturnType + >["resolveDefaultThinkingLevel"]; + provider: string; + model: string; + contextTokens: number; + directiveAck?: ReplyPayload; + abortedLastRun: boolean; +}): Promise { + const { + ctx, + sessionCtx, + cfg, + agentId, + sessionEntry, + sessionStore, + sessionKey, + storePath, + sessionScope, + workspaceDir, + isGroup, + opts, + typing, + allowTextCommands, + inlineStatusRequested, + command, + directives: initialDirectives, + cleanedBody: initialCleanedBody, + elevatedEnabled, + elevatedAllowed, + elevatedFailures, + defaultActivation, + resolvedThinkLevel, + resolvedVerboseLevel, + resolvedReasoningLevel, + resolvedElevatedLevel, + resolveDefaultThinkingLevel, + provider, + model, + contextTokens, + directiveAck, + abortedLastRun: initialAbortedLastRun, + } = params; + + let directives = initialDirectives; + let cleanedBody = initialCleanedBody; + + const sendInlineReply = async (reply?: ReplyPayload) => { + if (!reply) return; + if (!opts?.onBlockReply) return; + await opts.onBlockReply(reply); + }; + + const inlineCommand = + allowTextCommands && command.isAuthorizedSender + ? extractInlineSimpleCommand(cleanedBody) + : null; + if (inlineCommand) { + cleanedBody = inlineCommand.cleaned; + sessionCtx.Body = cleanedBody; + sessionCtx.BodyStripped = cleanedBody; + } + + const handleInlineStatus = + !isDirectiveOnly({ + directives, + cleanedBody: directives.cleaned, + ctx, + cfg, + agentId, + isGroup, + }) && inlineStatusRequested; + if (handleInlineStatus) { + const inlineStatusReply = await buildStatusReply({ + cfg, + command, + sessionEntry, + sessionKey, + sessionScope, + provider, + model, + contextTokens, + resolvedThinkLevel, + resolvedVerboseLevel: resolvedVerboseLevel ?? "off", + resolvedReasoningLevel, + resolvedElevatedLevel, + resolveDefaultThinkingLevel, + isGroup, + defaultGroupActivation: defaultActivation, + }); + await sendInlineReply(inlineStatusReply); + directives = { ...directives, hasStatusDirective: false }; + } + + if (inlineCommand) { + const inlineCommandContext = { + ...command, + rawBodyNormalized: inlineCommand.command, + commandBodyNormalized: inlineCommand.command, + }; + const inlineResult = await handleCommands({ + ctx, + cfg, + command: inlineCommandContext, + agentId, + directives, + elevated: { + enabled: elevatedEnabled, + allowed: elevatedAllowed, + failures: elevatedFailures, + }, + sessionEntry, + sessionStore, + sessionKey, + storePath, + sessionScope, + workspaceDir, + defaultGroupActivation: defaultActivation, + resolvedThinkLevel, + resolvedVerboseLevel: resolvedVerboseLevel ?? "off", + resolvedReasoningLevel, + resolvedElevatedLevel, + resolveDefaultThinkingLevel, + provider, + model, + contextTokens, + isGroup, + }); + if (inlineResult.reply) { + if (!inlineCommand.cleaned) { + typing.cleanup(); + return { kind: "reply", reply: inlineResult.reply }; + } + await sendInlineReply(inlineResult.reply); + } + } + + if (directiveAck) { + await sendInlineReply(directiveAck); + } + + const isEmptyConfig = Object.keys(cfg).length === 0; + const skipWhenConfigEmpty = command.channelId + ? Boolean(getChannelDock(command.channelId)?.commands?.skipWhenConfigEmpty) + : false; + if ( + skipWhenConfigEmpty && + isEmptyConfig && + command.from && + command.to && + command.from !== command.to + ) { + typing.cleanup(); + return { kind: "reply", reply: undefined }; + } + + let abortedLastRun = initialAbortedLastRun; + if (!sessionEntry && command.abortKey) { + abortedLastRun = getAbortMemory(command.abortKey) ?? false; + } + + const commandResult = await handleCommands({ + ctx, + cfg, + command, + agentId, + directives, + elevated: { + enabled: elevatedEnabled, + allowed: elevatedAllowed, + failures: elevatedFailures, + }, + sessionEntry, + sessionStore, + sessionKey, + storePath, + sessionScope, + workspaceDir, + defaultGroupActivation: defaultActivation, + resolvedThinkLevel, + resolvedVerboseLevel: resolvedVerboseLevel ?? "off", + resolvedReasoningLevel, + resolvedElevatedLevel, + resolveDefaultThinkingLevel, + provider, + model, + contextTokens, + isGroup, + }); + if (!commandResult.shouldContinue) { + typing.cleanup(); + return { kind: "reply", reply: commandResult.reply }; + } + + return { + kind: "continue", + directives, + abortedLastRun, + }; +} diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts new file mode 100644 index 000000000..93c910f06 --- /dev/null +++ b/src/auto-reply/reply/get-reply-run.ts @@ -0,0 +1,425 @@ +import crypto from "node:crypto"; +import { + abortEmbeddedPiRun, + isEmbeddedPiRunActive, + isEmbeddedPiRunStreaming, + resolveEmbeddedSessionLane, +} from "../../agents/pi-embedded.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { + resolveSessionFilePath, + type SessionEntry, + saveSessionStore, +} from "../../config/sessions.js"; +import { logVerbose } from "../../globals.js"; +import { clearCommandLane, getQueueSize } from "../../process/command-queue.js"; +import { normalizeMainKey } from "../../routing/session-key.js"; +import { isReasoningTagProvider } from "../../utils/provider-utils.js"; +import { hasControlCommand } from "../command-detection.js"; +import { buildInboundMediaNote } from "../media-note.js"; +import type { MsgContext, TemplateContext } from "../templating.js"; +import { + type ElevatedLevel, + formatXHighModelHint, + normalizeThinkLevel, + type ReasoningLevel, + supportsXHighThinking, + type ThinkLevel, + type VerboseLevel, +} from "../thinking.js"; +import { SILENT_REPLY_TOKEN } from "../tokens.js"; +import type { GetReplyOptions, ReplyPayload } from "../types.js"; +import { runReplyAgent } from "./agent-runner.js"; +import { applySessionHints } from "./body.js"; +import type { buildCommandContext } from "./commands.js"; +import type { InlineDirectives } from "./directive-handling.js"; +import { buildGroupIntro } from "./groups.js"; +import type { createModelSelectionState } from "./model-selection.js"; +import { resolveQueueSettings } from "./queue.js"; +import { ensureSkillSnapshot, prependSystemEvents } from "./session-updates.js"; +import type { TypingController } from "./typing.js"; +import { createTypingSignaler, resolveTypingMode } from "./typing-mode.js"; + +type AgentDefaults = NonNullable["defaults"]; + +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."; + +type RunPreparedReplyParams = { + ctx: MsgContext; + sessionCtx: TemplateContext; + cfg: ClawdbotConfig; + agentId: string; + agentDir: string; + agentCfg: AgentDefaults; + sessionCfg: ClawdbotConfig["session"]; + commandAuthorized: boolean; + command: ReturnType; + commandSource: string; + allowTextCommands: boolean; + directives: InlineDirectives; + defaultActivation: Parameters[0]["defaultActivation"]; + resolvedThinkLevel: ThinkLevel | undefined; + resolvedVerboseLevel: VerboseLevel | undefined; + resolvedReasoningLevel: ReasoningLevel; + resolvedElevatedLevel: ElevatedLevel; + elevatedEnabled: boolean; + elevatedAllowed: boolean; + blockStreamingEnabled: boolean; + blockReplyChunking?: { + minChars: number; + maxChars: number; + breakPreference: "paragraph" | "newline" | "sentence"; + }; + resolvedBlockStreamingBreak: "text_end" | "message_end"; + modelState: Awaited>; + provider: string; + model: string; + perMessageQueueMode?: InlineDirectives["queueMode"]; + perMessageQueueOptions?: { + debounceMs?: number; + cap?: number; + dropPolicy?: InlineDirectives["dropPolicy"]; + }; + transcribedText?: string; + typing: TypingController; + opts?: GetReplyOptions; + defaultModel: string; + timeoutMs: number; + isNewSession: boolean; + systemSent: boolean; + sessionEntry?: SessionEntry; + sessionStore?: Record; + sessionKey: string; + sessionId?: string; + storePath?: string; + workspaceDir: string; + abortedLastRun: boolean; +}; + +export async function runPreparedReply( + params: RunPreparedReplyParams, +): Promise { + const { + ctx, + sessionCtx, + cfg, + agentId, + agentDir, + agentCfg, + sessionCfg, + commandAuthorized, + command, + commandSource, + allowTextCommands, + directives, + defaultActivation, + elevatedEnabled, + elevatedAllowed, + blockStreamingEnabled, + blockReplyChunking, + resolvedBlockStreamingBreak, + modelState, + provider, + model, + perMessageQueueMode, + perMessageQueueOptions, + transcribedText, + typing, + opts, + defaultModel, + timeoutMs, + isNewSession, + systemSent, + sessionKey, + sessionId, + storePath, + workspaceDir, + sessionStore, + } = params; + let { + sessionEntry, + resolvedThinkLevel, + resolvedVerboseLevel, + resolvedReasoningLevel, + resolvedElevatedLevel, + abortedLastRun, + } = params; + let currentSystemSent = systemSent; + + const isFirstTurnInSession = isNewSession || !currentSystemSent; + 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({ + cfg, + 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 ?? ""; + // Use CommandBody/RawBody for bare reset detection (clean message without structural context). + const rawBodyTrimmed = ( + ctx.CommandBody ?? + ctx.RawBody ?? + ctx.Body ?? + "" + ).trim(); + const baseBodyTrimmedRaw = baseBody.trim(); + if ( + allowTextCommands && + (!commandAuthorized || !command.isAuthorizedSender) && + !baseBodyTrimmedRaw && + hasControlCommand(commandSource, cfg) + ) { + 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 === normalizeMainKey(sessionCfg?.mainKey); + 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; + currentSystemSent = 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 prefixedCommandBody = mediaNote + ? [mediaNote, mediaReplyHint, prefixedBody ?? ""] + .filter(Boolean) + .join("\n") + .trim() + : prefixedBody; + if (!resolvedThinkLevel && prefixedCommandBody) { + const parts = prefixedCommandBody.split(/\s+/); + const maybeLevel = normalizeThinkLevel(parts[0]); + if ( + maybeLevel && + (maybeLevel !== "xhigh" || supportsXHighThinking(provider, model)) + ) { + resolvedThinkLevel = maybeLevel; + prefixedCommandBody = parts.slice(1).join(" ").trim(); + } + } + if (!resolvedThinkLevel) { + resolvedThinkLevel = await modelState.resolveDefaultThinkingLevel(); + } + if ( + resolvedThinkLevel === "xhigh" && + !supportsXHighThinking(provider, model) + ) { + const explicitThink = + directives.hasThinkDirective && directives.thinkLevel !== undefined; + if (explicitThink) { + typing.cleanup(); + return { + text: `Thinking level "xhigh" is only supported for ${formatXHighModelHint()}. Use /think high or switch to one of those models.`, + }; + } + resolvedThinkLevel = "high"; + if ( + sessionEntry && + sessionStore && + sessionKey && + sessionEntry.thinkingLevel === "xhigh" + ) { + sessionEntry.thinkingLevel = "high"; + sessionEntry.updatedAt = Date.now(); + sessionStore[sessionKey] = sessionEntry; + if (storePath) { + await saveSessionStore(storePath, sessionStore); + } + } + } + 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, + channel: 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, + messageId: sessionCtx.MessageSid, + 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, + ...(isReasoningTagProvider(provider) ? { enforceFinalTag: true } : {}), + }, + }; + + if (typingSignals.shouldStartImmediately) { + await typingSignals.signalRunStart(); + } + + return runReplyAgent({ + commandBody: prefixedCommandBody, + 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, + }); +} diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts new file mode 100644 index 000000000..2f398c239 --- /dev/null +++ b/src/auto-reply/reply/get-reply.ts @@ -0,0 +1,272 @@ +import { + resolveAgentDir, + resolveAgentWorkspaceDir, + resolveSessionAgentId, +} from "../../agents/agent-scope.js"; +import { resolveModelRefFromString } from "../../agents/model-selection.js"; +import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; +import { + DEFAULT_AGENT_WORKSPACE_DIR, + ensureAgentWorkspace, +} from "../../agents/workspace.js"; +import { type ClawdbotConfig, loadConfig } from "../../config/config.js"; +import { logVerbose } from "../../globals.js"; +import { defaultRuntime } from "../../runtime.js"; +import { resolveCommandAuthorization } from "../command-auth.js"; +import type { MsgContext } from "../templating.js"; +import { SILENT_REPLY_TOKEN } from "../tokens.js"; +import { + hasAudioTranscriptionConfig, + isAudio, + transcribeInboundAudio, +} from "../transcription.js"; +import type { GetReplyOptions, ReplyPayload } from "../types.js"; +import { resolveDefaultModel } from "./directive-handling.js"; +import { resolveReplyDirectives } from "./get-reply-directives.js"; +import { handleInlineActions } from "./get-reply-inline-actions.js"; +import { runPreparedReply } from "./get-reply-run.js"; +import { initSessionState } from "./session.js"; +import { stageSandboxMedia } from "./stage-sandbox-media.js"; +import { createTypingController } from "./typing.js"; + +export async function getReplyFromConfig( + ctx: MsgContext, + opts?: GetReplyOptions, + configOverride?: ClawdbotConfig, +): Promise { + const cfg = configOverride ?? loadConfig(); + const agentId = resolveSessionAgentId({ + sessionKey: ctx.SessionKey, + config: cfg, + }); + 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 (hasAudioTranscriptionConfig(cfg) && 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 directiveResult = await resolveReplyDirectives({ + ctx, + cfg, + agentId, + agentDir, + agentCfg, + sessionCtx, + sessionEntry, + sessionStore, + sessionKey, + storePath, + sessionScope, + groupResolution, + isGroup, + triggerBodyNormalized, + commandAuthorized, + defaultProvider, + defaultModel, + aliasIndex, + provider, + model, + typing, + opts, + }); + if (directiveResult.kind === "reply") { + return directiveResult.reply; + } + + let { + commandSource, + command, + allowTextCommands, + directives, + cleanedBody, + elevatedEnabled, + elevatedAllowed, + elevatedFailures, + defaultActivation, + resolvedThinkLevel, + resolvedVerboseLevel, + resolvedReasoningLevel, + resolvedElevatedLevel, + blockStreamingEnabled, + blockReplyChunking, + resolvedBlockStreamingBreak, + provider: resolvedProvider, + model: resolvedModel, + modelState, + contextTokens, + inlineStatusRequested, + directiveAck, + perMessageQueueMode, + perMessageQueueOptions, + } = directiveResult.result; + provider = resolvedProvider; + model = resolvedModel; + + const inlineActionResult = await handleInlineActions({ + ctx, + sessionCtx, + cfg, + agentId, + sessionEntry, + sessionStore, + sessionKey, + storePath, + sessionScope, + workspaceDir, + isGroup, + opts, + typing, + allowTextCommands, + inlineStatusRequested, + command, + directives, + cleanedBody, + elevatedEnabled, + elevatedAllowed, + elevatedFailures, + defaultActivation: () => defaultActivation, + resolvedThinkLevel, + resolvedVerboseLevel, + resolvedReasoningLevel, + resolvedElevatedLevel, + resolveDefaultThinkingLevel: modelState.resolveDefaultThinkingLevel, + provider, + model, + contextTokens, + directiveAck, + abortedLastRun, + }); + if (inlineActionResult.kind === "reply") { + return inlineActionResult.reply; + } + directives = inlineActionResult.directives; + abortedLastRun = inlineActionResult.abortedLastRun ?? abortedLastRun; + + await stageSandboxMedia({ + ctx, + sessionCtx, + cfg, + sessionKey, + workspaceDir, + }); + + return runPreparedReply({ + ctx, + sessionCtx, + cfg, + agentId, + agentDir, + agentCfg, + sessionCfg, + commandAuthorized, + command, + commandSource, + allowTextCommands, + directives, + defaultActivation, + resolvedThinkLevel, + resolvedVerboseLevel, + resolvedReasoningLevel, + resolvedElevatedLevel, + elevatedEnabled, + elevatedAllowed, + blockStreamingEnabled, + blockReplyChunking, + resolvedBlockStreamingBreak, + modelState, + provider, + model, + perMessageQueueMode, + perMessageQueueOptions, + transcribedText, + typing, + opts, + defaultModel, + timeoutMs, + isNewSession, + systemSent, + sessionEntry, + sessionStore, + sessionKey, + sessionId, + storePath, + workspaceDir, + abortedLastRun, + }); +} diff --git a/src/auto-reply/reply/reply-elevated.ts b/src/auto-reply/reply/reply-elevated.ts new file mode 100644 index 000000000..0f28830f4 --- /dev/null +++ b/src/auto-reply/reply/reply-elevated.ts @@ -0,0 +1,206 @@ +import { resolveAgentConfig } from "../../agents/agent-scope.js"; +import { getChannelDock } from "../../channels/dock.js"; +import { + CHAT_CHANNEL_ORDER, + normalizeChannelId, +} from "../../channels/registry.js"; +import type { + AgentElevatedAllowFromConfig, + ClawdbotConfig, +} from "../../config/config.js"; +import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; +import type { MsgContext } from "../templating.js"; + +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, ""); +} + +const SENDER_PREFIXES = [ + ...CHAT_CHANNEL_ORDER, + INTERNAL_MESSAGE_CHANNEL, + "user", + "group", + "channel", +]; +const SENDER_PREFIX_RE = new RegExp(`^(${SENDER_PREFIXES.join("|")}):`, "i"); + +function stripSenderPrefix(value?: string) { + if (!value) return ""; + const trimmed = value.trim(); + return trimmed.replace(SENDER_PREFIX_RE, ""); +} + +function resolveElevatedAllowList( + allowFrom: AgentElevatedAllowFromConfig | undefined, + provider: string, + fallbackAllowFrom?: Array, +): Array | undefined { + if (!allowFrom) return fallbackAllowFrom; + const value = allowFrom[provider]; + return Array.isArray(value) ? value : fallbackAllowFrom; +} + +function isApprovedElevatedSender(params: { + provider: string; + ctx: MsgContext; + allowFrom?: AgentElevatedAllowFromConfig; + fallbackAllowFrom?: Array; +}): boolean { + const rawAllow = resolveElevatedAllowList( + params.allowFrom, + params.provider, + params.fallbackAllowFrom, + ); + 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 function resolveElevatedPermissions(params: { + cfg: ClawdbotConfig; + agentId: string; + ctx: MsgContext; + provider: string; +}): { + enabled: boolean; + allowed: boolean; + failures: Array<{ gate: string; key: string }>; +} { + const globalConfig = params.cfg.tools?.elevated; + const agentConfig = resolveAgentConfig(params.cfg, params.agentId)?.tools + ?.elevated; + const globalEnabled = globalConfig?.enabled !== false; + const agentEnabled = agentConfig?.enabled !== false; + const enabled = globalEnabled && agentEnabled; + const failures: Array<{ gate: string; key: string }> = []; + if (!globalEnabled) + failures.push({ gate: "enabled", key: "tools.elevated.enabled" }); + if (!agentEnabled) + failures.push({ + gate: "enabled", + key: "agents.list[].tools.elevated.enabled", + }); + if (!enabled) return { enabled, allowed: false, failures }; + if (!params.provider) { + failures.push({ gate: "provider", key: "ctx.Provider" }); + return { enabled, allowed: false, failures }; + } + + const normalizedProvider = normalizeChannelId(params.provider); + const dockFallbackAllowFrom = normalizedProvider + ? getChannelDock(normalizedProvider)?.elevated?.allowFromFallback?.({ + cfg: params.cfg, + accountId: params.ctx.AccountId, + }) + : undefined; + const fallbackAllowFrom = dockFallbackAllowFrom; + const globalAllowed = isApprovedElevatedSender({ + provider: params.provider, + ctx: params.ctx, + allowFrom: globalConfig?.allowFrom, + fallbackAllowFrom, + }); + if (!globalAllowed) { + failures.push({ + gate: "allowFrom", + key: `tools.elevated.allowFrom.${params.provider}`, + }); + return { enabled, allowed: false, failures }; + } + + const agentAllowed = agentConfig?.allowFrom + ? isApprovedElevatedSender({ + provider: params.provider, + ctx: params.ctx, + allowFrom: agentConfig.allowFrom, + fallbackAllowFrom, + }) + : true; + if (!agentAllowed) { + failures.push({ + gate: "allowFrom", + key: `agents.list[].tools.elevated.allowFrom.${params.provider}`, + }); + } + return { enabled, allowed: globalAllowed && agentAllowed, failures }; +} + +export function formatElevatedUnavailableMessage(params: { + runtimeSandboxed: boolean; + failures: Array<{ gate: string; key: string }>; + sessionKey?: string; +}): string { + const lines: string[] = []; + lines.push( + `elevated is not available right now (runtime=${params.runtimeSandboxed ? "sandboxed" : "direct"}).`, + ); + if (params.failures.length > 0) { + lines.push( + `Failing gates: ${params.failures + .map((f) => `${f.gate} (${f.key})`) + .join(", ")}`, + ); + } else { + lines.push( + "Failing gates: enabled (tools.elevated.enabled / agents.list[].tools.elevated.enabled), allowFrom (tools.elevated.allowFrom.).", + ); + } + lines.push("Fix-it keys:"); + lines.push("- tools.elevated.enabled"); + lines.push("- tools.elevated.allowFrom."); + lines.push("- agents.list[].tools.elevated.enabled"); + lines.push("- agents.list[].tools.elevated.allowFrom."); + if (params.sessionKey) { + lines.push(`See: clawdbot sandbox explain --session ${params.sessionKey}`); + } + return lines.join("\n"); +} diff --git a/src/auto-reply/reply/reply-inline.ts b/src/auto-reply/reply/reply-inline.ts new file mode 100644 index 000000000..e35ef2202 --- /dev/null +++ b/src/auto-reply/reply/reply-inline.ts @@ -0,0 +1,37 @@ +const INLINE_SIMPLE_COMMAND_ALIASES = new Map([ + ["/help", "/help"], + ["/commands", "/commands"], + ["/whoami", "/whoami"], + ["/id", "/whoami"], +]); +const INLINE_SIMPLE_COMMAND_RE = + /(?:^|\s)\/(help|commands|whoami|id)(?=$|\s|:)/i; + +const INLINE_STATUS_RE = /(?:^|\s)\/(?:status|usage)(?=$|\s|:)(?:\s*:\s*)?/gi; + +export function extractInlineSimpleCommand(body?: string): { + command: string; + cleaned: string; +} | null { + if (!body) return null; + const match = body.match(INLINE_SIMPLE_COMMAND_RE); + if (!match || match.index === undefined) return null; + const alias = `/${match[1].toLowerCase()}`; + const command = INLINE_SIMPLE_COMMAND_ALIASES.get(alias); + if (!command) return null; + const cleaned = body.replace(match[0], " ").replace(/\s+/g, " ").trim(); + return { command, cleaned }; +} + +export function stripInlineStatus(body: string): { + cleaned: string; + didStrip: boolean; +} { + const trimmed = body.trim(); + if (!trimmed) return { cleaned: "", didStrip: false }; + const cleaned = trimmed + .replace(INLINE_STATUS_RE, " ") + .replace(/\s+/g, " ") + .trim(); + return { cleaned, didStrip: cleaned !== trimmed }; +} diff --git a/src/auto-reply/reply/stage-sandbox-media.ts b/src/auto-reply/reply/stage-sandbox-media.ts new file mode 100644 index 000000000..967b6671d --- /dev/null +++ b/src/auto-reply/reply/stage-sandbox-media.ts @@ -0,0 +1,118 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { logVerbose } from "../../globals.js"; +import type { MsgContext, TemplateContext } from "../templating.js"; + +export 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)}`); + } +}