From da6f07b7c14aca8030398a6496b4329c1257c36d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 14 Jan 2026 05:39:41 +0000 Subject: [PATCH] refactor(auto-reply): split directive handling --- ...mini-sessions-deletes-transcripts.test.ts} | 0 ...tion-failure-by-resetting-session.test.ts} | 0 ...beat.signals-typing-block-replies.test.ts} | 0 ...rtbeat.signals-typing-normal-runs.test.ts} | 0 ...plies-even-if-session-reset-fails.test.ts} | 0 ...-count-flush-compaction-completes.test.ts} | 0 ...ush-turn-updates-session-metadata.test.ts} | 0 ....skips-memory-flush-cli-providers.test.ts} | 0 ...y-flush-sandbox-workspace-is-read.test.ts} | 0 ...figured-prompts-memory-flush-runs.test.ts} | 0 .../reply/directive-handling.auth.ts | 237 +++ .../reply/directive-handling.fast-lane.ts | 145 ++ .../reply/directive-handling.impl.ts | 426 +++++ .../reply/directive-handling.model-picker.ts | 118 ++ .../reply/directive-handling.model.ts | 298 +++ .../reply/directive-handling.parse.ts | 174 ++ .../reply/directive-handling.persist.ts | 273 +++ .../directive-handling.queue-validation.ts | 89 + .../reply/directive-handling.shared.ts | 52 + src/auto-reply/reply/directive-handling.ts | 1676 +---------------- 20 files changed, 1824 insertions(+), 1664 deletions(-) rename src/auto-reply/reply/{agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.part-3.test.ts => agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.resets-corrupted-gemini-sessions-deletes-transcripts.test.ts} (100%) rename src/auto-reply/reply/{agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.part-4.test.ts => agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.retries-after-compaction-failure-by-resetting-session.test.ts} (100%) rename src/auto-reply/reply/{agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.part-2.test.ts => agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.signals-typing-block-replies.test.ts} (100%) rename src/auto-reply/reply/{agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.part-1.test.ts => agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.signals-typing-normal-runs.test.ts} (100%) rename src/auto-reply/reply/{agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.part-5.test.ts => agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.still-replies-even-if-session-reset-fails.test.ts} (100%) rename src/auto-reply/reply/{agent-runner.memory-flush.runreplyagent-memory-flush.part-5.test.ts => agent-runner.memory-flush.runreplyagent-memory-flush.increments-compaction-count-flush-compaction-completes.test.ts} (100%) rename src/auto-reply/reply/{agent-runner.memory-flush.runreplyagent-memory-flush.part-1.test.ts => agent-runner.memory-flush.runreplyagent-memory-flush.runs-memory-flush-turn-updates-session-metadata.test.ts} (100%) rename src/auto-reply/reply/{agent-runner.memory-flush.runreplyagent-memory-flush.part-2.test.ts => agent-runner.memory-flush.runreplyagent-memory-flush.skips-memory-flush-cli-providers.test.ts} (100%) rename src/auto-reply/reply/{agent-runner.memory-flush.runreplyagent-memory-flush.part-4.test.ts => agent-runner.memory-flush.runreplyagent-memory-flush.skips-memory-flush-sandbox-workspace-is-read.test.ts} (100%) rename src/auto-reply/reply/{agent-runner.memory-flush.runreplyagent-memory-flush.part-3.test.ts => agent-runner.memory-flush.runreplyagent-memory-flush.uses-configured-prompts-memory-flush-runs.test.ts} (100%) create mode 100644 src/auto-reply/reply/directive-handling.auth.ts create mode 100644 src/auto-reply/reply/directive-handling.fast-lane.ts create mode 100644 src/auto-reply/reply/directive-handling.impl.ts create mode 100644 src/auto-reply/reply/directive-handling.model-picker.ts create mode 100644 src/auto-reply/reply/directive-handling.model.ts create mode 100644 src/auto-reply/reply/directive-handling.parse.ts create mode 100644 src/auto-reply/reply/directive-handling.persist.ts create mode 100644 src/auto-reply/reply/directive-handling.queue-validation.ts create mode 100644 src/auto-reply/reply/directive-handling.shared.ts diff --git a/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.part-3.test.ts b/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.resets-corrupted-gemini-sessions-deletes-transcripts.test.ts similarity index 100% rename from src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.part-3.test.ts rename to src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.resets-corrupted-gemini-sessions-deletes-transcripts.test.ts diff --git a/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.part-4.test.ts b/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.retries-after-compaction-failure-by-resetting-session.test.ts similarity index 100% rename from src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.part-4.test.ts rename to src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.retries-after-compaction-failure-by-resetting-session.test.ts diff --git a/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.part-2.test.ts b/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.signals-typing-block-replies.test.ts similarity index 100% rename from src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.part-2.test.ts rename to src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.signals-typing-block-replies.test.ts diff --git a/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.part-1.test.ts b/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.signals-typing-normal-runs.test.ts similarity index 100% rename from src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.part-1.test.ts rename to src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.signals-typing-normal-runs.test.ts diff --git a/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.part-5.test.ts b/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.still-replies-even-if-session-reset-fails.test.ts similarity index 100% rename from src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.part-5.test.ts rename to src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.still-replies-even-if-session-reset-fails.test.ts diff --git a/src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.part-5.test.ts b/src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.increments-compaction-count-flush-compaction-completes.test.ts similarity index 100% rename from src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.part-5.test.ts rename to src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.increments-compaction-count-flush-compaction-completes.test.ts diff --git a/src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.part-1.test.ts b/src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.runs-memory-flush-turn-updates-session-metadata.test.ts similarity index 100% rename from src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.part-1.test.ts rename to src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.runs-memory-flush-turn-updates-session-metadata.test.ts diff --git a/src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.part-2.test.ts b/src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.skips-memory-flush-cli-providers.test.ts similarity index 100% rename from src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.part-2.test.ts rename to src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.skips-memory-flush-cli-providers.test.ts diff --git a/src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.part-4.test.ts b/src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.skips-memory-flush-sandbox-workspace-is-read.test.ts similarity index 100% rename from src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.part-4.test.ts rename to src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.skips-memory-flush-sandbox-workspace-is-read.test.ts diff --git a/src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.part-3.test.ts b/src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.uses-configured-prompts-memory-flush-runs.test.ts similarity index 100% rename from src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.part-3.test.ts rename to src/auto-reply/reply/agent-runner.memory-flush.runreplyagent-memory-flush.uses-configured-prompts-memory-flush-runs.test.ts diff --git a/src/auto-reply/reply/directive-handling.auth.ts b/src/auto-reply/reply/directive-handling.auth.ts new file mode 100644 index 000000000..453c32cfe --- /dev/null +++ b/src/auto-reply/reply/directive-handling.auth.ts @@ -0,0 +1,237 @@ +import { + isProfileInCooldown, + resolveAuthProfileDisplayLabel, + resolveAuthStorePathForDisplay, +} from "../../agents/auth-profiles.js"; +import { + ensureAuthProfileStore, + getCustomProviderApiKey, + resolveAuthProfileOrder, + resolveEnvApiKey, +} from "../../agents/model-auth.js"; +import { normalizeProviderId } from "../../agents/model-selection.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { shortenHomePath } from "../../utils.js"; + +export type ModelAuthDetailMode = "compact" | "verbose"; + +const maskApiKey = (value: string): string => { + const trimmed = value.trim(); + if (!trimmed) return "missing"; + if (trimmed.length <= 16) return trimmed; + return `${trimmed.slice(0, 8)}...${trimmed.slice(-8)}`; +}; + +export const resolveAuthLabel = async ( + provider: string, + cfg: ClawdbotConfig, + modelsPath: string, + agentDir?: string, + mode: ModelAuthDetailMode = "compact", +): Promise<{ label: string; source: string }> => { + const formatPath = (value: string) => shortenHomePath(value); + const store = ensureAuthProfileStore(agentDir, { + allowKeychainPrompt: false, + }); + const order = resolveAuthProfileOrder({ cfg, store, provider }); + const providerKey = normalizeProviderId(provider); + const lastGood = (() => { + const map = store.lastGood; + if (!map) return undefined; + for (const [key, value] of Object.entries(map)) { + if (normalizeProviderId(key) === providerKey) return value; + } + return undefined; + })(); + const nextProfileId = order[0]; + const now = Date.now(); + + const formatUntil = (timestampMs: number) => { + const remainingMs = Math.max(0, timestampMs - now); + const minutes = Math.round(remainingMs / 60_000); + if (minutes < 1) return "soon"; + if (minutes < 60) return `${minutes}m`; + const hours = Math.round(minutes / 60); + if (hours < 48) return `${hours}h`; + const days = Math.round(hours / 24); + return `${days}d`; + }; + + if (order.length > 0) { + if (mode === "compact") { + const profileId = nextProfileId; + if (!profileId) return { label: "missing", source: "missing" }; + const profile = store.profiles[profileId]; + const configProfile = cfg.auth?.profiles?.[profileId]; + const missing = + !profile || + (configProfile?.provider && + configProfile.provider !== profile.provider) || + (configProfile?.mode && + configProfile.mode !== profile.type && + !(configProfile.mode === "oauth" && profile.type === "token")); + + const more = order.length > 1 ? ` (+${order.length - 1})` : ""; + if (missing) return { label: `${profileId} missing${more}`, source: "" }; + + if (profile.type === "api_key") { + return { + label: `${profileId} api-key ${maskApiKey(profile.key)}${more}`, + source: "", + }; + } + if (profile.type === "token") { + const exp = + typeof profile.expires === "number" && + Number.isFinite(profile.expires) && + profile.expires > 0 + ? profile.expires <= now + ? " expired" + : ` exp ${formatUntil(profile.expires)}` + : ""; + return { + label: `${profileId} token ${maskApiKey(profile.token)}${exp}${more}`, + source: "", + }; + } + const display = resolveAuthProfileDisplayLabel({ cfg, store, profileId }); + const label = display === profileId ? profileId : display; + const exp = + typeof profile.expires === "number" && + Number.isFinite(profile.expires) && + profile.expires > 0 + ? profile.expires <= now + ? " expired" + : ` exp ${formatUntil(profile.expires)}` + : ""; + return { label: `${label} oauth${exp}${more}`, source: "" }; + } + + const labels = order.map((profileId) => { + const profile = store.profiles[profileId]; + const configProfile = cfg.auth?.profiles?.[profileId]; + const flags: string[] = []; + if (profileId === nextProfileId) flags.push("next"); + if (lastGood && profileId === lastGood) flags.push("lastGood"); + if (isProfileInCooldown(store, profileId)) { + const until = store.usageStats?.[profileId]?.cooldownUntil; + if ( + typeof until === "number" && + Number.isFinite(until) && + until > now + ) { + flags.push(`cooldown ${formatUntil(until)}`); + } else { + flags.push("cooldown"); + } + } + if ( + !profile || + (configProfile?.provider && + configProfile.provider !== profile.provider) || + (configProfile?.mode && + configProfile.mode !== profile.type && + !(configProfile.mode === "oauth" && profile.type === "token")) + ) { + const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : ""; + return `${profileId}=missing${suffix}`; + } + if (profile.type === "api_key") { + const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : ""; + return `${profileId}=${maskApiKey(profile.key)}${suffix}`; + } + if (profile.type === "token") { + if ( + typeof profile.expires === "number" && + Number.isFinite(profile.expires) && + profile.expires > 0 + ) { + flags.push( + profile.expires <= now + ? "expired" + : `exp ${formatUntil(profile.expires)}`, + ); + } + const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : ""; + return `${profileId}=token:${maskApiKey(profile.token)}${suffix}`; + } + const display = resolveAuthProfileDisplayLabel({ + cfg, + store, + profileId, + }); + const suffix = + display === profileId + ? "" + : display.startsWith(profileId) + ? display.slice(profileId.length).trim() + : `(${display})`; + if ( + typeof profile.expires === "number" && + Number.isFinite(profile.expires) && + profile.expires > 0 + ) { + flags.push( + profile.expires <= now + ? "expired" + : `exp ${formatUntil(profile.expires)}`, + ); + } + const suffixLabel = suffix ? ` ${suffix}` : ""; + const suffixFlags = flags.length > 0 ? ` (${flags.join(", ")})` : ""; + return `${profileId}=OAuth${suffixLabel}${suffixFlags}`; + }); + return { + label: labels.join(", "), + source: `auth-profiles.json: ${formatPath(resolveAuthStorePathForDisplay(agentDir))}`, + }; + } + + const envKey = resolveEnvApiKey(provider); + if (envKey) { + const isOAuthEnv = + envKey.source.includes("ANTHROPIC_OAUTH_TOKEN") || + envKey.source.toLowerCase().includes("oauth"); + const label = isOAuthEnv ? "OAuth (env)" : maskApiKey(envKey.apiKey); + return { label, source: mode === "verbose" ? envKey.source : "" }; + } + const customKey = getCustomProviderApiKey(cfg, provider); + if (customKey) { + return { + label: maskApiKey(customKey), + source: + mode === "verbose" ? `models.json: ${formatPath(modelsPath)}` : "", + }; + } + return { label: "missing", source: "missing" }; +}; + +export const formatAuthLabel = (auth: { label: string; source: string }) => { + if (!auth.source || auth.source === auth.label || auth.source === "missing") { + return auth.label; + } + return `${auth.label} (${auth.source})`; +}; + +export const resolveProfileOverride = (params: { + rawProfile?: string; + provider: string; + cfg: ClawdbotConfig; + agentDir?: string; +}): { profileId?: string; error?: string } => { + const raw = params.rawProfile?.trim(); + if (!raw) return {}; + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const profile = store.profiles[raw]; + if (!profile) { + return { error: `Auth profile "${raw}" not found.` }; + } + if (profile.provider !== params.provider) { + return { + error: `Auth profile "${raw}" is for ${profile.provider}, not ${params.provider}.`, + }; + } + return { profileId: raw }; +}; diff --git a/src/auto-reply/reply/directive-handling.fast-lane.ts b/src/auto-reply/reply/directive-handling.fast-lane.ts new file mode 100644 index 000000000..d63f18ef4 --- /dev/null +++ b/src/auto-reply/reply/directive-handling.fast-lane.ts @@ -0,0 +1,145 @@ +import type { ModelAliasIndex } from "../../agents/model-selection.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import type { SessionEntry } from "../../config/sessions.js"; +import type { MsgContext } from "../templating.js"; +import type { ReplyPayload } from "../types.js"; +import { handleDirectiveOnly } from "./directive-handling.impl.js"; +import type { InlineDirectives } from "./directive-handling.parse.js"; +import { isDirectiveOnly } from "./directive-handling.parse.js"; +import type { + ElevatedLevel, + ReasoningLevel, + ThinkLevel, + VerboseLevel, +} from "./directives.js"; + +export async function applyInlineDirectivesFastLane(params: { + directives: InlineDirectives; + commandAuthorized: boolean; + ctx: MsgContext; + cfg: ClawdbotConfig; + agentId?: string; + isGroup: boolean; + sessionEntry?: SessionEntry; + sessionStore?: Record; + sessionKey: string; + storePath?: string; + elevatedEnabled: boolean; + elevatedAllowed: boolean; + elevatedFailures?: Array<{ gate: string; key: string }>; + messageProviderKey?: string; + defaultProvider: string; + defaultModel: string; + aliasIndex: ModelAliasIndex; + allowedModelKeys: Set; + allowedModelCatalog: Awaited< + ReturnType + >; + resetModelOverride: boolean; + provider: string; + model: string; + initialModelLabel: string; + formatModelSwitchEvent: (label: string, alias?: string) => string; + agentCfg?: NonNullable["defaults"]; + modelState: { + resolveDefaultThinkingLevel: () => Promise; + allowedModelKeys: Set; + allowedModelCatalog: Awaited< + ReturnType< + typeof import("../../agents/model-catalog.js").loadModelCatalog + > + >; + resetModelOverride: boolean; + }; +}): Promise<{ directiveAck?: ReplyPayload; provider: string; model: string }> { + const { + directives, + commandAuthorized, + ctx, + cfg, + agentId, + isGroup, + sessionEntry, + sessionStore, + sessionKey, + storePath, + elevatedEnabled, + elevatedAllowed, + elevatedFailures, + messageProviderKey, + defaultProvider, + defaultModel, + aliasIndex, + allowedModelKeys, + allowedModelCatalog, + resetModelOverride, + formatModelSwitchEvent, + modelState, + } = params; + + let { provider, model } = params; + if ( + !commandAuthorized || + isDirectiveOnly({ + directives, + cleanedBody: directives.cleaned, + ctx, + cfg, + agentId, + isGroup, + }) + ) { + return { directiveAck: undefined, provider, model }; + } + + const agentCfg = params.agentCfg; + 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 directiveAck = await handleDirectiveOnly({ + cfg, + directives, + sessionEntry, + sessionStore, + sessionKey, + storePath, + elevatedEnabled, + elevatedAllowed, + elevatedFailures, + messageProviderKey, + defaultProvider, + defaultModel, + aliasIndex, + allowedModelKeys, + allowedModelCatalog, + resetModelOverride, + provider, + model, + initialModelLabel: params.initialModelLabel, + formatModelSwitchEvent, + currentThinkLevel, + currentVerboseLevel, + currentReasoningLevel, + currentElevatedLevel, + }); + + if (sessionEntry?.providerOverride) { + provider = sessionEntry.providerOverride; + } + if (sessionEntry?.modelOverride) { + model = sessionEntry.modelOverride; + } + + return { directiveAck, provider, model }; +} diff --git a/src/auto-reply/reply/directive-handling.impl.ts b/src/auto-reply/reply/directive-handling.impl.ts new file mode 100644 index 000000000..7e7854662 --- /dev/null +++ b/src/auto-reply/reply/directive-handling.impl.ts @@ -0,0 +1,426 @@ +import { + resolveAgentDir, + resolveSessionAgentId, +} from "../../agents/agent-scope.js"; +import type { ModelAliasIndex } from "../../agents/model-selection.js"; +import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { type SessionEntry, saveSessionStore } from "../../config/sessions.js"; +import { enqueueSystemEvent } from "../../infra/system-events.js"; +import { applyVerboseOverride } from "../../sessions/level-overrides.js"; +import { + formatThinkingLevels, + formatXHighModelHint, + supportsXHighThinking, +} from "../thinking.js"; +import type { ReplyPayload } from "../types.js"; +import { + maybeHandleModelDirectiveInfo, + resolveModelSelectionFromDirective, +} from "./directive-handling.model.js"; +import type { InlineDirectives } from "./directive-handling.parse.js"; +import { maybeHandleQueueDirective } from "./directive-handling.queue-validation.js"; +import { + formatDirectiveAck, + formatElevatedEvent, + formatElevatedRuntimeHint, + formatElevatedUnavailableText, + formatReasoningEvent, + withOptions, +} from "./directive-handling.shared.js"; +import type { + ElevatedLevel, + ReasoningLevel, + ThinkLevel, + VerboseLevel, +} from "./directives.js"; + +export async function handleDirectiveOnly(params: { + cfg: ClawdbotConfig; + directives: InlineDirectives; + sessionEntry?: SessionEntry; + sessionStore?: Record; + sessionKey: string; + storePath?: string; + elevatedEnabled: boolean; + elevatedAllowed: boolean; + elevatedFailures?: Array<{ gate: string; key: string }>; + messageProviderKey?: string; + defaultProvider: string; + defaultModel: string; + aliasIndex: ModelAliasIndex; + allowedModelKeys: Set; + allowedModelCatalog: Awaited< + ReturnType + >; + resetModelOverride: boolean; + provider: string; + model: string; + initialModelLabel: string; + formatModelSwitchEvent: (label: string, alias?: string) => string; + currentThinkLevel?: ThinkLevel; + currentVerboseLevel?: VerboseLevel; + currentReasoningLevel?: ReasoningLevel; + currentElevatedLevel?: ElevatedLevel; +}): Promise { + const { + directives, + sessionEntry, + sessionStore, + sessionKey, + storePath, + elevatedEnabled, + elevatedAllowed, + defaultProvider, + defaultModel, + aliasIndex, + allowedModelKeys, + allowedModelCatalog, + resetModelOverride, + provider, + model, + initialModelLabel, + formatModelSwitchEvent, + currentThinkLevel, + currentVerboseLevel, + currentReasoningLevel, + currentElevatedLevel, + } = params; + const activeAgentId = resolveSessionAgentId({ + sessionKey: params.sessionKey, + config: params.cfg, + }); + const agentDir = resolveAgentDir(params.cfg, activeAgentId); + const runtimeIsSandboxed = resolveSandboxRuntimeStatus({ + cfg: params.cfg, + sessionKey: params.sessionKey, + }).sandboxed; + const shouldHintDirectRuntime = + directives.hasElevatedDirective && !runtimeIsSandboxed; + + const modelInfo = await maybeHandleModelDirectiveInfo({ + directives, + cfg: params.cfg, + agentDir, + activeAgentId, + provider, + model, + defaultProvider, + defaultModel, + aliasIndex, + allowedModelCatalog, + resetModelOverride, + }); + if (modelInfo) return modelInfo; + + const modelResolution = resolveModelSelectionFromDirective({ + directives, + cfg: params.cfg, + agentDir, + defaultProvider, + defaultModel, + aliasIndex, + allowedModelKeys, + allowedModelCatalog, + provider, + }); + if (modelResolution.errorText) return { text: modelResolution.errorText }; + const modelSelection = modelResolution.modelSelection; + const profileOverride = modelResolution.profileOverride; + + const resolvedProvider = modelSelection?.provider ?? provider; + const resolvedModel = modelSelection?.model ?? model; + + if (directives.hasThinkDirective && !directives.thinkLevel) { + // If no argument was provided, show the current level + if (!directives.rawThinkLevel) { + const level = currentThinkLevel ?? "off"; + return { + text: withOptions( + `Current thinking level: ${level}.`, + formatThinkingLevels(resolvedProvider, resolvedModel), + ), + }; + } + return { + text: `Unrecognized thinking level "${directives.rawThinkLevel}". Valid levels: ${formatThinkingLevels(resolvedProvider, resolvedModel)}.`, + }; + } + if (directives.hasVerboseDirective && !directives.verboseLevel) { + if (!directives.rawVerboseLevel) { + const level = currentVerboseLevel ?? "off"; + return { + text: withOptions(`Current verbose level: ${level}.`, "on, off"), + }; + } + return { + text: `Unrecognized verbose level "${directives.rawVerboseLevel}". Valid levels: off, on.`, + }; + } + if (directives.hasReasoningDirective && !directives.reasoningLevel) { + if (!directives.rawReasoningLevel) { + const level = currentReasoningLevel ?? "off"; + return { + text: withOptions( + `Current reasoning level: ${level}.`, + "on, off, stream", + ), + }; + } + return { + text: `Unrecognized reasoning level "${directives.rawReasoningLevel}". Valid levels: on, off, stream.`, + }; + } + if (directives.hasElevatedDirective && !directives.elevatedLevel) { + if (!directives.rawElevatedLevel) { + if (!elevatedEnabled || !elevatedAllowed) { + return { + text: formatElevatedUnavailableText({ + runtimeSandboxed: runtimeIsSandboxed, + failures: params.elevatedFailures, + sessionKey: params.sessionKey, + }), + }; + } + const level = currentElevatedLevel ?? "off"; + return { + text: [ + withOptions(`Current elevated level: ${level}.`, "on, off"), + shouldHintDirectRuntime ? formatElevatedRuntimeHint() : null, + ] + .filter(Boolean) + .join("\n"), + }; + } + return { + text: `Unrecognized elevated level "${directives.rawElevatedLevel}". Valid levels: off, on.`, + }; + } + if ( + directives.hasElevatedDirective && + (!elevatedEnabled || !elevatedAllowed) + ) { + return { + text: formatElevatedUnavailableText({ + runtimeSandboxed: runtimeIsSandboxed, + failures: params.elevatedFailures, + sessionKey: params.sessionKey, + }), + }; + } + + const queueAck = maybeHandleQueueDirective({ + directives, + cfg: params.cfg, + channel: provider, + sessionEntry, + }); + if (queueAck) return queueAck; + + if ( + directives.hasThinkDirective && + directives.thinkLevel === "xhigh" && + !supportsXHighThinking(resolvedProvider, resolvedModel) + ) { + return { + text: `Thinking level "xhigh" is only supported for ${formatXHighModelHint()}.`, + }; + } + + const nextThinkLevel = directives.hasThinkDirective + ? directives.thinkLevel + : ((sessionEntry?.thinkingLevel as ThinkLevel | undefined) ?? + currentThinkLevel); + const shouldDowngradeXHigh = + !directives.hasThinkDirective && + nextThinkLevel === "xhigh" && + !supportsXHighThinking(resolvedProvider, resolvedModel); + + if (sessionEntry && sessionStore && sessionKey) { + const prevElevatedLevel = + currentElevatedLevel ?? + (sessionEntry.elevatedLevel as ElevatedLevel | undefined) ?? + (elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel)); + const prevReasoningLevel = + currentReasoningLevel ?? + (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? + "off"; + let elevatedChanged = + directives.hasElevatedDirective && + directives.elevatedLevel !== undefined && + elevatedEnabled && + elevatedAllowed; + let reasoningChanged = + directives.hasReasoningDirective && + directives.reasoningLevel !== undefined; + if (directives.hasThinkDirective && directives.thinkLevel) { + if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel; + else sessionEntry.thinkingLevel = directives.thinkLevel; + } + if (shouldDowngradeXHigh) { + sessionEntry.thinkingLevel = "high"; + } + if (directives.hasVerboseDirective && directives.verboseLevel) { + applyVerboseOverride(sessionEntry, directives.verboseLevel); + } + if (directives.hasReasoningDirective && directives.reasoningLevel) { + if (directives.reasoningLevel === "off") + delete sessionEntry.reasoningLevel; + else sessionEntry.reasoningLevel = directives.reasoningLevel; + reasoningChanged = + directives.reasoningLevel !== prevReasoningLevel && + directives.reasoningLevel !== undefined; + } + if (directives.hasElevatedDirective && directives.elevatedLevel) { + // Unlike other toggles, elevated defaults can be "on". + // Persist "off" explicitly so `/elevated off` actually overrides defaults. + sessionEntry.elevatedLevel = directives.elevatedLevel; + elevatedChanged = + elevatedChanged || + (directives.elevatedLevel !== prevElevatedLevel && + directives.elevatedLevel !== undefined); + } + if (modelSelection) { + if (modelSelection.isDefault) { + delete sessionEntry.providerOverride; + delete sessionEntry.modelOverride; + } else { + sessionEntry.providerOverride = modelSelection.provider; + sessionEntry.modelOverride = modelSelection.model; + } + if (profileOverride) { + sessionEntry.authProfileOverride = profileOverride; + } else if (directives.hasModelDirective) { + delete sessionEntry.authProfileOverride; + } + } + if (directives.hasQueueDirective && directives.queueReset) { + delete sessionEntry.queueMode; + delete sessionEntry.queueDebounceMs; + delete sessionEntry.queueCap; + delete sessionEntry.queueDrop; + } else if (directives.hasQueueDirective) { + if (directives.queueMode) sessionEntry.queueMode = directives.queueMode; + if (typeof directives.debounceMs === "number") { + sessionEntry.queueDebounceMs = directives.debounceMs; + } + if (typeof directives.cap === "number") { + sessionEntry.queueCap = directives.cap; + } + if (directives.dropPolicy) { + sessionEntry.queueDrop = directives.dropPolicy; + } + } + sessionEntry.updatedAt = Date.now(); + sessionStore[sessionKey] = sessionEntry; + if (storePath) { + await saveSessionStore(storePath, sessionStore); + } + if (modelSelection) { + const nextLabel = `${modelSelection.provider}/${modelSelection.model}`; + if (nextLabel !== initialModelLabel) { + enqueueSystemEvent( + formatModelSwitchEvent(nextLabel, modelSelection.alias), + { + sessionKey, + contextKey: `model:${nextLabel}`, + }, + ); + } + } + if (elevatedChanged) { + const nextElevated = (sessionEntry.elevatedLevel ?? + "off") as ElevatedLevel; + enqueueSystemEvent(formatElevatedEvent(nextElevated), { + sessionKey, + contextKey: "mode:elevated", + }); + } + if (reasoningChanged) { + const nextReasoning = (sessionEntry.reasoningLevel ?? + "off") as ReasoningLevel; + enqueueSystemEvent(formatReasoningEvent(nextReasoning), { + sessionKey, + contextKey: "mode:reasoning", + }); + } + } + + const parts: string[] = []; + if (directives.hasThinkDirective && directives.thinkLevel) { + parts.push( + directives.thinkLevel === "off" + ? "Thinking disabled." + : `Thinking level set to ${directives.thinkLevel}.`, + ); + } + if (directives.hasVerboseDirective && directives.verboseLevel) { + parts.push( + directives.verboseLevel === "off" + ? formatDirectiveAck("Verbose logging disabled.") + : formatDirectiveAck("Verbose logging enabled."), + ); + } + if (directives.hasReasoningDirective && directives.reasoningLevel) { + parts.push( + directives.reasoningLevel === "off" + ? formatDirectiveAck("Reasoning visibility disabled.") + : directives.reasoningLevel === "stream" + ? formatDirectiveAck("Reasoning stream enabled (Telegram only).") + : formatDirectiveAck("Reasoning visibility enabled."), + ); + } + if (directives.hasElevatedDirective && directives.elevatedLevel) { + parts.push( + directives.elevatedLevel === "off" + ? formatDirectiveAck("Elevated mode disabled.") + : formatDirectiveAck("Elevated mode enabled."), + ); + if (shouldHintDirectRuntime) parts.push(formatElevatedRuntimeHint()); + } + if (shouldDowngradeXHigh) { + parts.push( + `Thinking level set to high (xhigh not supported for ${resolvedProvider}/${resolvedModel}).`, + ); + } + if (modelSelection) { + const label = `${modelSelection.provider}/${modelSelection.model}`; + const labelWithAlias = modelSelection.alias + ? `${modelSelection.alias} (${label})` + : label; + parts.push( + modelSelection.isDefault + ? `Model reset to default (${labelWithAlias}).` + : `Model set to ${labelWithAlias}.`, + ); + if (profileOverride) { + parts.push(`Auth profile set to ${profileOverride}.`); + } + } + if (directives.hasQueueDirective && directives.queueMode) { + parts.push( + formatDirectiveAck(`Queue mode set to ${directives.queueMode}.`), + ); + } else if (directives.hasQueueDirective && directives.queueReset) { + parts.push(formatDirectiveAck("Queue mode reset to default.")); + } + if ( + directives.hasQueueDirective && + typeof directives.debounceMs === "number" + ) { + parts.push( + formatDirectiveAck(`Queue debounce set to ${directives.debounceMs}ms.`), + ); + } + if (directives.hasQueueDirective && typeof directives.cap === "number") { + parts.push(formatDirectiveAck(`Queue cap set to ${directives.cap}.`)); + } + if (directives.hasQueueDirective && directives.dropPolicy) { + parts.push( + formatDirectiveAck(`Queue drop set to ${directives.dropPolicy}.`), + ); + } + const ack = parts.join(" ").trim(); + if (!ack && directives.hasStatusDirective) return undefined; + return { text: ack || "OK." }; +} diff --git a/src/auto-reply/reply/directive-handling.model-picker.ts b/src/auto-reply/reply/directive-handling.model-picker.ts new file mode 100644 index 000000000..d09c3bbec --- /dev/null +++ b/src/auto-reply/reply/directive-handling.model-picker.ts @@ -0,0 +1,118 @@ +import { normalizeProviderId } from "../../agents/model-selection.js"; +import type { ClawdbotConfig } from "../../config/config.js"; + +export type ModelPickerCatalogEntry = { + provider: string; + id: string; + name?: string; +}; + +export type ModelPickerItem = { + model: string; + providers: string[]; + providerModels: Record; +}; + +const MODEL_PICK_PROVIDER_PREFERENCE = [ + "anthropic", + "openai", + "openai-codex", + "minimax", + "synthetic", + "google", + "zai", + "openrouter", + "opencode", + "github-copilot", + "groq", + "cerebras", + "mistral", + "xai", + "lmstudio", +] as const; + +function normalizeModelFamilyId(id: string): string { + const trimmed = id.trim(); + if (!trimmed) return trimmed; + const parts = trimmed.split("/").filter(Boolean); + return parts.length > 0 ? (parts[parts.length - 1] ?? trimmed) : trimmed; +} + +function sortProvidersForPicker(providers: string[]): string[] { + const pref = new Map( + MODEL_PICK_PROVIDER_PREFERENCE.map((provider, idx) => [provider, idx]), + ); + return providers.sort((a, b) => { + const pa = pref.get(a); + const pb = pref.get(b); + if (pa !== undefined && pb !== undefined) return pa - pb; + if (pa !== undefined) return -1; + if (pb !== undefined) return 1; + return a.localeCompare(b); + }); +} + +export function buildModelPickerItems( + catalog: ModelPickerCatalogEntry[], +): ModelPickerItem[] { + const byModel = new Map }>(); + for (const entry of catalog) { + const provider = normalizeProviderId(entry.provider); + const model = normalizeModelFamilyId(entry.id); + if (!provider || !model) continue; + const existing = byModel.get(model); + if (existing) { + existing.providerModels[provider] = entry.id; + continue; + } + byModel.set(model, { providerModels: { [provider]: entry.id } }); + } + const out: ModelPickerItem[] = []; + for (const [model, data] of byModel.entries()) { + const providers = sortProvidersForPicker(Object.keys(data.providerModels)); + out.push({ model, providers, providerModels: data.providerModels }); + } + out.sort((a, b) => + a.model.toLowerCase().localeCompare(b.model.toLowerCase()), + ); + return out; +} + +export function pickProviderForModel(params: { + item: ModelPickerItem; + preferredProvider?: string; +}): { provider: string; model: string } | null { + const preferred = params.preferredProvider + ? normalizeProviderId(params.preferredProvider) + : undefined; + if (preferred && params.item.providerModels[preferred]) { + return { + provider: preferred, + model: params.item.providerModels[preferred], + }; + } + const first = params.item.providers[0]; + if (!first) return null; + return { + provider: first, + model: params.item.providerModels[first] ?? params.item.model, + }; +} + +export function resolveProviderEndpointLabel( + provider: string, + cfg: ClawdbotConfig, +): { endpoint?: string; api?: string } { + const normalized = normalizeProviderId(provider); + const providers = (cfg.models?.providers ?? {}) as Record< + string, + { baseUrl?: string; api?: string } | undefined + >; + const entry = providers[normalized]; + const endpoint = entry?.baseUrl?.trim(); + const api = entry?.api?.trim(); + return { + endpoint: endpoint || undefined, + api: api || undefined, + }; +} diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts new file mode 100644 index 000000000..ec311b23a --- /dev/null +++ b/src/auto-reply/reply/directive-handling.model.ts @@ -0,0 +1,298 @@ +import { resolveAuthStorePathForDisplay } from "../../agents/auth-profiles.js"; +import { + type ModelAliasIndex, + modelKey, + normalizeProviderId, + resolveConfiguredModelRef, + resolveModelRefFromString, +} from "../../agents/model-selection.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { shortenHomePath } from "../../utils.js"; +import type { ReplyPayload } from "../types.js"; +import { + formatAuthLabel, + type ModelAuthDetailMode, + resolveAuthLabel, + resolveProfileOverride, +} from "./directive-handling.auth.js"; +import { + buildModelPickerItems, + type ModelPickerCatalogEntry, + pickProviderForModel, + resolveProviderEndpointLabel, +} from "./directive-handling.model-picker.js"; +import type { InlineDirectives } from "./directive-handling.parse.js"; +import { + type ModelDirectiveSelection, + resolveModelDirectiveSelection, +} from "./model-selection.js"; + +function buildModelPickerCatalog(params: { + cfg: ClawdbotConfig; + defaultProvider: string; + defaultModel: string; + aliasIndex: ModelAliasIndex; + allowedModelCatalog: Array<{ provider: string; id?: string; name?: string }>; +}): ModelPickerCatalogEntry[] { + const resolvedDefault = resolveConfiguredModelRef({ + cfg: params.cfg, + defaultProvider: params.defaultProvider, + defaultModel: params.defaultModel, + }); + + const keys = new Set(); + const out: ModelPickerCatalogEntry[] = []; + + const push = (entry: ModelPickerCatalogEntry) => { + const provider = normalizeProviderId(entry.provider); + const id = String(entry.id ?? "").trim(); + if (!provider || !id) return; + const key = modelKey(provider, id); + if (keys.has(key)) return; + keys.add(key); + out.push({ provider, id, name: entry.name }); + }; + + // Prefer catalog entries (when available), but always merge in config-only + // allowlist entries. This keeps custom providers/models visible in /model. + for (const entry of params.allowedModelCatalog) { + push({ + provider: entry.provider, + id: entry.id ?? "", + name: entry.name, + }); + } + + // Merge any configured allowlist keys that the catalog doesn't know about. + for (const raw of Object.keys(params.cfg.agents?.defaults?.models ?? {})) { + const resolved = resolveModelRefFromString({ + raw: String(raw), + defaultProvider: params.defaultProvider, + aliasIndex: params.aliasIndex, + }); + if (!resolved) continue; + push({ + provider: resolved.ref.provider, + id: resolved.ref.model, + name: resolved.ref.model, + }); + } + + // Ensure the configured default is always present (even when no allowlist). + if (resolvedDefault.model) { + push({ + provider: resolvedDefault.provider, + id: resolvedDefault.model, + name: resolvedDefault.model, + }); + } + + return out; +} + +export async function maybeHandleModelDirectiveInfo(params: { + directives: InlineDirectives; + cfg: ClawdbotConfig; + agentDir: string; + activeAgentId: string; + provider: string; + model: string; + defaultProvider: string; + defaultModel: string; + aliasIndex: ModelAliasIndex; + allowedModelCatalog: Array<{ provider: string; id?: string; name?: string }>; + resetModelOverride: boolean; +}): Promise { + if (!params.directives.hasModelDirective) return undefined; + + const rawDirective = params.directives.rawModelDirective?.trim(); + const directive = rawDirective?.toLowerCase(); + const wantsStatus = directive === "status"; + const wantsList = !rawDirective || directive === "list"; + if (!wantsList && !wantsStatus) return undefined; + + if (params.directives.rawModelProfile) { + return { text: "Auth profile override requires a model selection." }; + } + + const pickerCatalog = buildModelPickerCatalog({ + cfg: params.cfg, + defaultProvider: params.defaultProvider, + defaultModel: params.defaultModel, + aliasIndex: params.aliasIndex, + allowedModelCatalog: params.allowedModelCatalog, + }); + + if (wantsList) { + const items = buildModelPickerItems(pickerCatalog); + if (items.length === 0) return { text: "No models available." }; + const current = `${params.provider}/${params.model}`; + const lines: string[] = [ + `Current: ${current}`, + "Pick: /model <#> or /model ", + ]; + for (const [idx, item] of items.entries()) { + lines.push(`${idx + 1}) ${item.model} — ${item.providers.join(", ")}`); + } + lines.push("", "More: /model status"); + return { text: lines.join("\n") }; + } + + const modelsPath = `${params.agentDir}/models.json`; + const formatPath = (value: string) => shortenHomePath(value); + const authMode: ModelAuthDetailMode = "verbose"; + if (pickerCatalog.length === 0) return { text: "No models available." }; + + const authByProvider = new Map(); + for (const entry of pickerCatalog) { + const provider = normalizeProviderId(entry.provider); + if (authByProvider.has(provider)) continue; + const auth = await resolveAuthLabel( + provider, + params.cfg, + modelsPath, + params.agentDir, + authMode, + ); + authByProvider.set(provider, formatAuthLabel(auth)); + } + + const current = `${params.provider}/${params.model}`; + const defaultLabel = `${params.defaultProvider}/${params.defaultModel}`; + const lines = [ + `Current: ${current}`, + `Default: ${defaultLabel}`, + `Agent: ${params.activeAgentId}`, + `Auth file: ${formatPath(resolveAuthStorePathForDisplay(params.agentDir))}`, + ]; + if (params.resetModelOverride) { + lines.push(`(previous selection reset to default)`); + } + + const byProvider = new Map(); + for (const entry of pickerCatalog) { + const provider = normalizeProviderId(entry.provider); + const models = byProvider.get(provider); + if (models) { + models.push(entry); + continue; + } + byProvider.set(provider, [entry]); + } + + for (const provider of byProvider.keys()) { + const models = byProvider.get(provider); + if (!models) continue; + const authLabel = authByProvider.get(provider) ?? "missing"; + const endpoint = resolveProviderEndpointLabel(provider, params.cfg); + const endpointSuffix = endpoint.endpoint + ? ` endpoint: ${endpoint.endpoint}` + : " endpoint: default"; + const apiSuffix = endpoint.api ? ` api: ${endpoint.api}` : ""; + lines.push(""); + lines.push(`[${provider}]${endpointSuffix}${apiSuffix} auth: ${authLabel}`); + for (const entry of models) { + const label = `${provider}/${entry.id}`; + const aliases = params.aliasIndex.byKey.get(label); + const aliasSuffix = + aliases && aliases.length > 0 ? ` (${aliases.join(", ")})` : ""; + lines.push(` • ${label}${aliasSuffix}`); + } + } + return { text: lines.join("\n") }; +} + +export function resolveModelSelectionFromDirective(params: { + directives: InlineDirectives; + cfg: ClawdbotConfig; + agentDir: string; + defaultProvider: string; + defaultModel: string; + aliasIndex: ModelAliasIndex; + allowedModelKeys: Set; + allowedModelCatalog: Array<{ provider: string; id?: string; name?: string }>; + provider: string; +}): { + modelSelection?: ModelDirectiveSelection; + profileOverride?: string; + errorText?: string; +} { + if ( + !params.directives.hasModelDirective || + !params.directives.rawModelDirective + ) { + if (params.directives.rawModelProfile) { + return { errorText: "Auth profile override requires a model selection." }; + } + return {}; + } + + const raw = params.directives.rawModelDirective.trim(); + let modelSelection: ModelDirectiveSelection | undefined; + + if (/^[0-9]+$/.test(raw)) { + const pickerCatalog = buildModelPickerCatalog({ + cfg: params.cfg, + defaultProvider: params.defaultProvider, + defaultModel: params.defaultModel, + aliasIndex: params.aliasIndex, + allowedModelCatalog: params.allowedModelCatalog, + }); + const items = buildModelPickerItems(pickerCatalog); + const index = Number.parseInt(raw, 10) - 1; + const item = Number.isFinite(index) ? items[index] : undefined; + if (!item) { + return { + errorText: `Invalid model selection "${raw}". Use /model to list.`, + }; + } + const picked = pickProviderForModel({ + item, + preferredProvider: params.provider, + }); + if (!picked) { + return { + errorText: `Invalid model selection "${raw}". Use /model to list.`, + }; + } + const key = `${picked.provider}/${picked.model}`; + const aliases = params.aliasIndex.byKey.get(key); + const alias = aliases && aliases.length > 0 ? aliases[0] : undefined; + modelSelection = { + provider: picked.provider, + model: picked.model, + isDefault: + picked.provider === params.defaultProvider && + picked.model === params.defaultModel, + ...(alias ? { alias } : {}), + }; + } else { + const resolved = resolveModelDirectiveSelection({ + raw, + defaultProvider: params.defaultProvider, + defaultModel: params.defaultModel, + aliasIndex: params.aliasIndex, + allowedModelKeys: params.allowedModelKeys, + }); + if (resolved.error) { + return { errorText: resolved.error }; + } + modelSelection = resolved.selection; + } + + let profileOverride: string | undefined; + if (modelSelection && params.directives.rawModelProfile) { + const profileResolved = resolveProfileOverride({ + rawProfile: params.directives.rawModelProfile, + provider: modelSelection.provider, + cfg: params.cfg, + agentDir: params.agentDir, + }); + if (profileResolved.error) { + return { errorText: profileResolved.error }; + } + profileOverride = profileResolved.profileId; + } + + return { modelSelection, profileOverride }; +} diff --git a/src/auto-reply/reply/directive-handling.parse.ts b/src/auto-reply/reply/directive-handling.parse.ts new file mode 100644 index 000000000..23bf4ee21 --- /dev/null +++ b/src/auto-reply/reply/directive-handling.parse.ts @@ -0,0 +1,174 @@ +import type { ClawdbotConfig } from "../../config/config.js"; +import { extractModelDirective } from "../model.js"; +import type { MsgContext } from "../templating.js"; +import type { + ElevatedLevel, + ReasoningLevel, + ThinkLevel, + VerboseLevel, +} from "./directives.js"; +import { + extractElevatedDirective, + extractReasoningDirective, + extractStatusDirective, + extractThinkDirective, + extractVerboseDirective, +} from "./directives.js"; +import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; +import type { QueueDropPolicy, QueueMode } from "./queue.js"; +import { extractQueueDirective } from "./queue.js"; + +export type InlineDirectives = { + cleaned: string; + hasThinkDirective: boolean; + thinkLevel?: ThinkLevel; + rawThinkLevel?: string; + hasVerboseDirective: boolean; + verboseLevel?: VerboseLevel; + rawVerboseLevel?: string; + hasReasoningDirective: boolean; + reasoningLevel?: ReasoningLevel; + rawReasoningLevel?: string; + hasElevatedDirective: boolean; + elevatedLevel?: ElevatedLevel; + rawElevatedLevel?: string; + hasStatusDirective: boolean; + hasModelDirective: boolean; + rawModelDirective?: string; + rawModelProfile?: string; + hasQueueDirective: boolean; + queueMode?: QueueMode; + queueReset: boolean; + rawQueueMode?: string; + debounceMs?: number; + cap?: number; + dropPolicy?: QueueDropPolicy; + rawDebounce?: string; + rawCap?: string; + rawDrop?: string; + hasQueueOptions: boolean; +}; + +export function parseInlineDirectives( + body: string, + options?: { + modelAliases?: string[]; + disableElevated?: boolean; + allowStatusDirective?: boolean; + }, +): InlineDirectives { + const { + cleaned: thinkCleaned, + thinkLevel, + rawLevel: rawThinkLevel, + hasDirective: hasThinkDirective, + } = extractThinkDirective(body); + const { + cleaned: verboseCleaned, + verboseLevel, + rawLevel: rawVerboseLevel, + hasDirective: hasVerboseDirective, + } = extractVerboseDirective(thinkCleaned); + const { + cleaned: reasoningCleaned, + reasoningLevel, + rawLevel: rawReasoningLevel, + hasDirective: hasReasoningDirective, + } = extractReasoningDirective(verboseCleaned); + const { + cleaned: elevatedCleaned, + elevatedLevel, + rawLevel: rawElevatedLevel, + hasDirective: hasElevatedDirective, + } = options?.disableElevated + ? { + cleaned: reasoningCleaned, + elevatedLevel: undefined, + rawLevel: undefined, + hasDirective: false, + } + : extractElevatedDirective(reasoningCleaned); + const allowStatusDirective = options?.allowStatusDirective !== false; + const { cleaned: statusCleaned, hasDirective: hasStatusDirective } = + allowStatusDirective + ? extractStatusDirective(elevatedCleaned) + : { cleaned: elevatedCleaned, hasDirective: false }; + const { + cleaned: modelCleaned, + rawModel, + rawProfile, + hasDirective: hasModelDirective, + } = extractModelDirective(statusCleaned, { + aliases: options?.modelAliases, + }); + const { + cleaned: queueCleaned, + queueMode, + queueReset, + rawMode, + debounceMs, + cap, + dropPolicy, + rawDebounce, + rawCap, + rawDrop, + hasDirective: hasQueueDirective, + hasOptions: hasQueueOptions, + } = extractQueueDirective(modelCleaned); + + return { + cleaned: queueCleaned, + hasThinkDirective, + thinkLevel, + rawThinkLevel, + hasVerboseDirective, + verboseLevel, + rawVerboseLevel, + hasReasoningDirective, + reasoningLevel, + rawReasoningLevel, + hasElevatedDirective, + elevatedLevel, + rawElevatedLevel, + hasStatusDirective, + hasModelDirective, + rawModelDirective: rawModel, + rawModelProfile: rawProfile, + hasQueueDirective, + queueMode, + queueReset, + rawQueueMode: rawMode, + debounceMs, + cap, + dropPolicy, + rawDebounce, + rawCap, + rawDrop, + hasQueueOptions, + }; +} + +export function isDirectiveOnly(params: { + directives: InlineDirectives; + cleanedBody: string; + ctx: MsgContext; + cfg: ClawdbotConfig; + agentId?: string; + isGroup: boolean; +}): boolean { + const { directives, cleanedBody, ctx, cfg, agentId, isGroup } = params; + if ( + !directives.hasThinkDirective && + !directives.hasVerboseDirective && + !directives.hasReasoningDirective && + !directives.hasElevatedDirective && + !directives.hasModelDirective && + !directives.hasQueueDirective + ) + return false; + const stripped = stripStructuralPrefixes(cleanedBody ?? ""); + const noMentions = isGroup + ? stripMentions(stripped, ctx, cfg, agentId) + : stripped; + return noMentions.length === 0; +} diff --git a/src/auto-reply/reply/directive-handling.persist.ts b/src/auto-reply/reply/directive-handling.persist.ts new file mode 100644 index 000000000..8764f0c78 --- /dev/null +++ b/src/auto-reply/reply/directive-handling.persist.ts @@ -0,0 +1,273 @@ +import { + resolveAgentDir, + resolveAgentModelPrimary, + resolveDefaultAgentId, + resolveSessionAgentId, +} from "../../agents/agent-scope.js"; +import { lookupContextTokens } from "../../agents/context.js"; +import { + DEFAULT_CONTEXT_TOKENS, + DEFAULT_MODEL, + DEFAULT_PROVIDER, +} from "../../agents/defaults.js"; +import { + buildModelAliasIndex, + type ModelAliasIndex, + modelKey, + resolveConfiguredModelRef, + resolveModelRefFromString, +} from "../../agents/model-selection.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { type SessionEntry, saveSessionStore } from "../../config/sessions.js"; +import { enqueueSystemEvent } from "../../infra/system-events.js"; +import { applyVerboseOverride } from "../../sessions/level-overrides.js"; +import { resolveProfileOverride } from "./directive-handling.auth.js"; +import type { InlineDirectives } from "./directive-handling.parse.js"; +import { + formatElevatedEvent, + formatReasoningEvent, +} from "./directive-handling.shared.js"; +import type { ElevatedLevel, ReasoningLevel } from "./directives.js"; + +export async function persistInlineDirectives(params: { + directives: InlineDirectives; + effectiveModelDirective?: string; + cfg: ClawdbotConfig; + agentDir?: string; + sessionEntry?: SessionEntry; + sessionStore?: Record; + sessionKey?: string; + storePath?: string; + elevatedEnabled: boolean; + elevatedAllowed: boolean; + defaultProvider: string; + defaultModel: string; + aliasIndex: ModelAliasIndex; + allowedModelKeys: Set; + provider: string; + model: string; + initialModelLabel: string; + formatModelSwitchEvent: (label: string, alias?: string) => string; + agentCfg: NonNullable["defaults"] | undefined; +}): Promise<{ provider: string; model: string; contextTokens: number }> { + const { + directives, + cfg, + sessionEntry, + sessionStore, + sessionKey, + storePath, + elevatedEnabled, + elevatedAllowed, + defaultProvider, + defaultModel, + aliasIndex, + allowedModelKeys, + initialModelLabel, + formatModelSwitchEvent, + agentCfg, + } = params; + let { provider, model } = params; + const activeAgentId = sessionKey + ? resolveSessionAgentId({ sessionKey, config: cfg }) + : resolveDefaultAgentId(cfg); + const agentDir = resolveAgentDir(cfg, activeAgentId); + + if (sessionEntry && sessionStore && sessionKey) { + const prevElevatedLevel = + (sessionEntry.elevatedLevel as ElevatedLevel | undefined) ?? + (agentCfg?.elevatedDefault as ElevatedLevel | undefined) ?? + (elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel)); + const prevReasoningLevel = + (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off"; + let elevatedChanged = + directives.hasElevatedDirective && + directives.elevatedLevel !== undefined && + elevatedEnabled && + elevatedAllowed; + let reasoningChanged = + directives.hasReasoningDirective && + directives.reasoningLevel !== undefined; + let updated = false; + + if (directives.hasThinkDirective && directives.thinkLevel) { + if (directives.thinkLevel === "off") { + delete sessionEntry.thinkingLevel; + } else { + sessionEntry.thinkingLevel = directives.thinkLevel; + } + updated = true; + } + if (directives.hasVerboseDirective && directives.verboseLevel) { + applyVerboseOverride(sessionEntry, directives.verboseLevel); + updated = true; + } + if (directives.hasReasoningDirective && directives.reasoningLevel) { + if (directives.reasoningLevel === "off") { + delete sessionEntry.reasoningLevel; + } else { + sessionEntry.reasoningLevel = directives.reasoningLevel; + } + reasoningChanged = + reasoningChanged || + (directives.reasoningLevel !== prevReasoningLevel && + directives.reasoningLevel !== undefined); + updated = true; + } + if ( + directives.hasElevatedDirective && + directives.elevatedLevel && + elevatedEnabled && + elevatedAllowed + ) { + // Persist "off" explicitly so inline `/elevated off` overrides defaults. + sessionEntry.elevatedLevel = directives.elevatedLevel; + elevatedChanged = + elevatedChanged || + (directives.elevatedLevel !== prevElevatedLevel && + directives.elevatedLevel !== undefined); + updated = true; + } + + const modelDirective = + directives.hasModelDirective && params.effectiveModelDirective + ? params.effectiveModelDirective + : undefined; + if (modelDirective) { + const resolved = resolveModelRefFromString({ + raw: modelDirective, + defaultProvider, + aliasIndex, + }); + if (resolved) { + const key = modelKey(resolved.ref.provider, resolved.ref.model); + if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) { + let profileOverride: string | undefined; + if (directives.rawModelProfile) { + const profileResolved = resolveProfileOverride({ + rawProfile: directives.rawModelProfile, + provider: resolved.ref.provider, + cfg, + agentDir, + }); + if (profileResolved.error) { + throw new Error(profileResolved.error); + } + profileOverride = profileResolved.profileId; + } + const isDefault = + resolved.ref.provider === defaultProvider && + resolved.ref.model === defaultModel; + if (isDefault) { + delete sessionEntry.providerOverride; + delete sessionEntry.modelOverride; + } else { + sessionEntry.providerOverride = resolved.ref.provider; + sessionEntry.modelOverride = resolved.ref.model; + } + if (profileOverride) { + sessionEntry.authProfileOverride = profileOverride; + } else if (directives.hasModelDirective) { + delete sessionEntry.authProfileOverride; + } + provider = resolved.ref.provider; + model = resolved.ref.model; + const nextLabel = `${provider}/${model}`; + if (nextLabel !== initialModelLabel) { + enqueueSystemEvent( + formatModelSwitchEvent(nextLabel, resolved.alias), + { + sessionKey, + contextKey: `model:${nextLabel}`, + }, + ); + } + updated = true; + } + } + } + if (directives.hasQueueDirective && directives.queueReset) { + delete sessionEntry.queueMode; + delete sessionEntry.queueDebounceMs; + delete sessionEntry.queueCap; + delete sessionEntry.queueDrop; + updated = true; + } + + if (updated) { + sessionEntry.updatedAt = Date.now(); + sessionStore[sessionKey] = sessionEntry; + if (storePath) { + await saveSessionStore(storePath, sessionStore); + } + if (elevatedChanged) { + const nextElevated = (sessionEntry.elevatedLevel ?? + "off") as ElevatedLevel; + enqueueSystemEvent(formatElevatedEvent(nextElevated), { + sessionKey, + contextKey: "mode:elevated", + }); + } + if (reasoningChanged) { + const nextReasoning = (sessionEntry.reasoningLevel ?? + "off") as ReasoningLevel; + enqueueSystemEvent(formatReasoningEvent(nextReasoning), { + sessionKey, + contextKey: "mode:reasoning", + }); + } + } + } + + return { + provider, + model, + contextTokens: + agentCfg?.contextTokens ?? + lookupContextTokens(model) ?? + DEFAULT_CONTEXT_TOKENS, + }; +} + +export function resolveDefaultModel(params: { + cfg: ClawdbotConfig; + agentId?: string; +}): { + defaultProvider: string; + defaultModel: string; + aliasIndex: ModelAliasIndex; +} { + const agentModelOverride = params.agentId + ? resolveAgentModelPrimary(params.cfg, params.agentId) + : undefined; + const cfg = + agentModelOverride && agentModelOverride.length > 0 + ? { + ...params.cfg, + agents: { + ...params.cfg.agents, + defaults: { + ...params.cfg.agents?.defaults, + model: { + ...(typeof params.cfg.agents?.defaults?.model === "object" + ? params.cfg.agents.defaults.model + : undefined), + primary: agentModelOverride, + }, + }, + }, + } + : params.cfg; + const mainModel = resolveConfiguredModelRef({ + cfg, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + }); + const defaultProvider = mainModel.provider; + const defaultModel = mainModel.model; + const aliasIndex = buildModelAliasIndex({ + cfg, + defaultProvider, + }); + return { defaultProvider, defaultModel, aliasIndex }; +} diff --git a/src/auto-reply/reply/directive-handling.queue-validation.ts b/src/auto-reply/reply/directive-handling.queue-validation.ts new file mode 100644 index 000000000..f5d3581e7 --- /dev/null +++ b/src/auto-reply/reply/directive-handling.queue-validation.ts @@ -0,0 +1,89 @@ +import type { ClawdbotConfig } from "../../config/config.js"; +import type { SessionEntry } from "../../config/sessions.js"; +import type { ReplyPayload } from "../types.js"; +import type { InlineDirectives } from "./directive-handling.parse.js"; +import { withOptions } from "./directive-handling.shared.js"; +import { resolveQueueSettings } from "./queue.js"; + +export function maybeHandleQueueDirective(params: { + directives: InlineDirectives; + cfg: ClawdbotConfig; + channel: string; + sessionEntry?: SessionEntry; +}): ReplyPayload | undefined { + const { directives } = params; + if (!directives.hasQueueDirective) return undefined; + + const wantsStatus = + !directives.queueMode && + !directives.queueReset && + !directives.hasQueueOptions && + directives.rawQueueMode === undefined && + directives.rawDebounce === undefined && + directives.rawCap === undefined && + directives.rawDrop === undefined; + if (wantsStatus) { + const settings = resolveQueueSettings({ + cfg: params.cfg, + channel: params.channel, + sessionEntry: params.sessionEntry, + }); + const debounceLabel = + typeof settings.debounceMs === "number" + ? `${settings.debounceMs}ms` + : "default"; + const capLabel = + typeof settings.cap === "number" ? String(settings.cap) : "default"; + const dropLabel = settings.dropPolicy ?? "default"; + return { + text: withOptions( + `Current queue settings: mode=${settings.mode}, debounce=${debounceLabel}, cap=${capLabel}, drop=${dropLabel}.`, + "modes steer, followup, collect, steer+backlog, interrupt; debounce:, cap:, drop:old|new|summarize", + ), + }; + } + + const queueModeInvalid = + !directives.queueMode && + !directives.queueReset && + Boolean(directives.rawQueueMode); + const queueDebounceInvalid = + directives.rawDebounce !== undefined && + typeof directives.debounceMs !== "number"; + const queueCapInvalid = + directives.rawCap !== undefined && typeof directives.cap !== "number"; + const queueDropInvalid = + directives.rawDrop !== undefined && !directives.dropPolicy; + + if ( + queueModeInvalid || + queueDebounceInvalid || + queueCapInvalid || + queueDropInvalid + ) { + const errors: string[] = []; + if (queueModeInvalid) { + errors.push( + `Unrecognized queue mode "${directives.rawQueueMode ?? ""}". Valid modes: steer, followup, collect, steer+backlog, interrupt.`, + ); + } + if (queueDebounceInvalid) { + errors.push( + `Invalid debounce "${directives.rawDebounce ?? ""}". Use ms/s/m (e.g. debounce:1500ms, debounce:2s).`, + ); + } + if (queueCapInvalid) { + errors.push( + `Invalid cap "${directives.rawCap ?? ""}". Use a positive integer (e.g. cap:10).`, + ); + } + if (queueDropInvalid) { + errors.push( + `Invalid drop policy "${directives.rawDrop ?? ""}". Use drop:old, drop:new, or drop:summarize.`, + ); + } + return { text: errors.join(" ") }; + } + + return undefined; +} diff --git a/src/auto-reply/reply/directive-handling.shared.ts b/src/auto-reply/reply/directive-handling.shared.ts new file mode 100644 index 000000000..b389ff41b --- /dev/null +++ b/src/auto-reply/reply/directive-handling.shared.ts @@ -0,0 +1,52 @@ +import type { ElevatedLevel, ReasoningLevel } from "./directives.js"; + +export const SYSTEM_MARK = "⚙️"; + +export const formatDirectiveAck = (text: string): string => { + if (!text) return text; + if (text.startsWith(SYSTEM_MARK)) return text; + return `${SYSTEM_MARK} ${text}`; +}; + +export const formatOptionsLine = (options: string) => `Options: ${options}.`; +export const withOptions = (line: string, options: string) => + `${line}\n${formatOptionsLine(options)}`; + +export const formatElevatedRuntimeHint = () => + `${SYSTEM_MARK} Runtime is direct; sandboxing does not apply.`; + +export const formatElevatedEvent = (level: ElevatedLevel) => + level === "on" + ? "Elevated ON — exec runs on host; set elevated:false to stay sandboxed." + : "Elevated OFF — exec stays in sandbox."; + +export const formatReasoningEvent = (level: ReasoningLevel) => { + if (level === "stream") return "Reasoning STREAM — emit live ."; + if (level === "on") return "Reasoning ON — include ."; + return "Reasoning OFF — hide ."; +}; + +export function formatElevatedUnavailableText(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"}).`, + ); + const failures = params.failures ?? []; + if (failures.length > 0) { + lines.push( + `Failing gates: ${failures.map((f) => `${f.gate} (${f.key})`).join(", ")}`, + ); + } else { + lines.push( + "Fix-it keys: tools.elevated.enabled, tools.elevated.allowFrom., agents.list[].tools.elevated.*", + ); + } + if (params.sessionKey) { + lines.push(`See: clawdbot sandbox explain --session ${params.sessionKey}`); + } + return lines.join("\n"); +} diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index c79ab7d43..0e5293578 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -1,1664 +1,12 @@ -import { - resolveAgentDir, - resolveAgentModelPrimary, - resolveDefaultAgentId, - resolveSessionAgentId, -} from "../../agents/agent-scope.js"; -import { - isProfileInCooldown, - resolveAuthProfileDisplayLabel, - resolveAuthStorePathForDisplay, -} from "../../agents/auth-profiles.js"; -import { lookupContextTokens } from "../../agents/context.js"; -import { - DEFAULT_CONTEXT_TOKENS, - DEFAULT_MODEL, - DEFAULT_PROVIDER, -} from "../../agents/defaults.js"; -import { - ensureAuthProfileStore, - getCustomProviderApiKey, - resolveAuthProfileOrder, - resolveEnvApiKey, -} from "../../agents/model-auth.js"; -import { - buildModelAliasIndex, - type ModelAliasIndex, - modelKey, - normalizeProviderId, - resolveConfiguredModelRef, - resolveModelRefFromString, -} from "../../agents/model-selection.js"; -import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js"; -import type { ClawdbotConfig } from "../../config/config.js"; -import { type SessionEntry, saveSessionStore } from "../../config/sessions.js"; -import { enqueueSystemEvent } from "../../infra/system-events.js"; -import { applyVerboseOverride } from "../../sessions/level-overrides.js"; -import { shortenHomePath } from "../../utils.js"; -import { extractModelDirective } from "../model.js"; -import type { MsgContext } from "../templating.js"; -import { - formatThinkingLevels, - formatXHighModelHint, - supportsXHighThinking, -} from "../thinking.js"; -import type { ReplyPayload } from "../types.js"; -import { - type ElevatedLevel, - extractElevatedDirective, - extractReasoningDirective, - extractStatusDirective, - extractThinkDirective, - extractVerboseDirective, - type ReasoningLevel, - type ThinkLevel, - type VerboseLevel, -} from "./directives.js"; -import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; -import { - type ModelDirectiveSelection, - resolveModelDirectiveSelection, -} from "./model-selection.js"; -import { - extractQueueDirective, - type QueueDropPolicy, - type QueueMode, - resolveQueueSettings, -} from "./queue.js"; - -const SYSTEM_MARK = "⚙️"; -export const formatDirectiveAck = (text: string): string => { - if (!text) return text; - if (text.startsWith(SYSTEM_MARK)) return text; - return `${SYSTEM_MARK} ${text}`; -}; - -const formatOptionsLine = (options: string) => `Options: ${options}.`; -const withOptions = (line: string, options: string) => - `${line}\n${formatOptionsLine(options)}`; -const formatElevatedRuntimeHint = () => - `${SYSTEM_MARK} Runtime is direct; sandboxing does not apply.`; - -const formatElevatedEvent = (level: ElevatedLevel) => - level === "on" - ? "Elevated ON — exec runs on host; set elevated:false to stay sandboxed." - : "Elevated OFF — exec stays in sandbox."; - -const formatReasoningEvent = (level: ReasoningLevel) => { - if (level === "stream") return "Reasoning STREAM — emit live ."; - if (level === "on") return "Reasoning ON — include ."; - return "Reasoning OFF — hide ."; -}; - -function formatElevatedUnavailableText(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"}).`, - ); - const failures = params.failures ?? []; - if (failures.length > 0) { - lines.push( - `Failing gates: ${failures.map((f) => `${f.gate} (${f.key})`).join(", ")}`, - ); - } else { - lines.push( - "Fix-it keys: tools.elevated.enabled, tools.elevated.allowFrom., agents.list[].tools.elevated.*", - ); - } - if (params.sessionKey) { - lines.push(`See: clawdbot sandbox explain --session ${params.sessionKey}`); - } - return lines.join("\n"); -} - -const maskApiKey = (value: string): string => { - const trimmed = value.trim(); - if (!trimmed) return "missing"; - if (trimmed.length <= 16) return trimmed; - return `${trimmed.slice(0, 8)}...${trimmed.slice(-8)}`; -}; - -type ModelAuthDetailMode = "compact" | "verbose"; - -const resolveAuthLabel = async ( - provider: string, - cfg: ClawdbotConfig, - modelsPath: string, - agentDir?: string, - mode: ModelAuthDetailMode = "compact", -): Promise<{ label: string; source: string }> => { - const formatPath = (value: string) => shortenHomePath(value); - const store = ensureAuthProfileStore(agentDir, { - allowKeychainPrompt: false, - }); - const order = resolveAuthProfileOrder({ cfg, store, provider }); - const providerKey = normalizeProviderId(provider); - const lastGood = (() => { - const map = store.lastGood; - if (!map) return undefined; - for (const [key, value] of Object.entries(map)) { - if (normalizeProviderId(key) === providerKey) return value; - } - return undefined; - })(); - const nextProfileId = order[0]; - const now = Date.now(); - - const formatUntil = (timestampMs: number) => { - const remainingMs = Math.max(0, timestampMs - now); - const minutes = Math.round(remainingMs / 60_000); - if (minutes < 1) return "soon"; - if (minutes < 60) return `${minutes}m`; - const hours = Math.round(minutes / 60); - if (hours < 48) return `${hours}h`; - const days = Math.round(hours / 24); - return `${days}d`; - }; - - if (order.length > 0) { - if (mode === "compact") { - const profileId = nextProfileId; - if (!profileId) return { label: "missing", source: "missing" }; - const profile = store.profiles[profileId]; - const configProfile = cfg.auth?.profiles?.[profileId]; - const missing = - !profile || - (configProfile?.provider && - configProfile.provider !== profile.provider) || - (configProfile?.mode && - configProfile.mode !== profile.type && - !(configProfile.mode === "oauth" && profile.type === "token")); - - const more = order.length > 1 ? ` (+${order.length - 1})` : ""; - if (missing) return { label: `${profileId} missing${more}`, source: "" }; - - if (profile.type === "api_key") { - return { - label: `${profileId} api-key ${maskApiKey(profile.key)}${more}`, - source: "", - }; - } - if (profile.type === "token") { - const exp = - typeof profile.expires === "number" && - Number.isFinite(profile.expires) && - profile.expires > 0 - ? profile.expires <= now - ? " expired" - : ` exp ${formatUntil(profile.expires)}` - : ""; - return { - label: `${profileId} token ${maskApiKey(profile.token)}${exp}${more}`, - source: "", - }; - } - const display = resolveAuthProfileDisplayLabel({ cfg, store, profileId }); - const label = display === profileId ? profileId : display; - const exp = - typeof profile.expires === "number" && - Number.isFinite(profile.expires) && - profile.expires > 0 - ? profile.expires <= now - ? " expired" - : ` exp ${formatUntil(profile.expires)}` - : ""; - return { label: `${label} oauth${exp}${more}`, source: "" }; - } - - const labels = order.map((profileId) => { - const profile = store.profiles[profileId]; - const configProfile = cfg.auth?.profiles?.[profileId]; - const flags: string[] = []; - if (profileId === nextProfileId) flags.push("next"); - if (lastGood && profileId === lastGood) flags.push("lastGood"); - if (isProfileInCooldown(store, profileId)) { - const until = store.usageStats?.[profileId]?.cooldownUntil; - if ( - typeof until === "number" && - Number.isFinite(until) && - until > now - ) { - flags.push(`cooldown ${formatUntil(until)}`); - } else { - flags.push("cooldown"); - } - } - if ( - !profile || - (configProfile?.provider && - configProfile.provider !== profile.provider) || - (configProfile?.mode && - configProfile.mode !== profile.type && - !(configProfile.mode === "oauth" && profile.type === "token")) - ) { - const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : ""; - return `${profileId}=missing${suffix}`; - } - if (profile.type === "api_key") { - const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : ""; - return `${profileId}=${maskApiKey(profile.key)}${suffix}`; - } - if (profile.type === "token") { - if ( - typeof profile.expires === "number" && - Number.isFinite(profile.expires) && - profile.expires > 0 - ) { - flags.push( - profile.expires <= now - ? "expired" - : `exp ${formatUntil(profile.expires)}`, - ); - } - const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : ""; - return `${profileId}=token:${maskApiKey(profile.token)}${suffix}`; - } - const display = resolveAuthProfileDisplayLabel({ - cfg, - store, - profileId, - }); - const suffix = - display === profileId - ? "" - : display.startsWith(profileId) - ? display.slice(profileId.length).trim() - : `(${display})`; - if ( - typeof profile.expires === "number" && - Number.isFinite(profile.expires) && - profile.expires > 0 - ) { - flags.push( - profile.expires <= now - ? "expired" - : `exp ${formatUntil(profile.expires)}`, - ); - } - const suffixLabel = suffix ? ` ${suffix}` : ""; - const suffixFlags = flags.length > 0 ? ` (${flags.join(", ")})` : ""; - return `${profileId}=OAuth${suffixLabel}${suffixFlags}`; - }); - return { - label: labels.join(", "), - source: `auth-profiles.json: ${formatPath(resolveAuthStorePathForDisplay(agentDir))}`, - }; - } - - const envKey = resolveEnvApiKey(provider); - if (envKey) { - const isOAuthEnv = - envKey.source.includes("ANTHROPIC_OAUTH_TOKEN") || - envKey.source.toLowerCase().includes("oauth"); - const label = isOAuthEnv ? "OAuth (env)" : maskApiKey(envKey.apiKey); - return { label, source: mode === "verbose" ? envKey.source : "" }; - } - const customKey = getCustomProviderApiKey(cfg, provider); - if (customKey) { - return { - label: maskApiKey(customKey), - source: - mode === "verbose" ? `models.json: ${formatPath(modelsPath)}` : "", - }; - } - return { label: "missing", source: "missing" }; -}; - -const formatAuthLabel = (auth: { label: string; source: string }) => { - if (!auth.source || auth.source === auth.label || auth.source === "missing") { - return auth.label; - } - return `${auth.label} (${auth.source})`; -}; - -const resolveProfileOverride = (params: { - rawProfile?: string; - provider: string; - cfg: ClawdbotConfig; - agentDir?: string; -}): { profileId?: string; error?: string } => { - const raw = params.rawProfile?.trim(); - if (!raw) return {}; - const store = ensureAuthProfileStore(params.agentDir, { - allowKeychainPrompt: false, - }); - const profile = store.profiles[raw]; - if (!profile) { - return { error: `Auth profile "${raw}" not found.` }; - } - if (profile.provider !== params.provider) { - return { - error: `Auth profile "${raw}" is for ${profile.provider}, not ${params.provider}.`, - }; - } - return { profileId: raw }; -}; - -type ModelPickerCatalogEntry = { - provider: string; - id: string; - name?: string; -}; - -type ModelPickerItem = { - model: string; - providers: string[]; - providerModels: Record; -}; - -const MODEL_PICK_PROVIDER_PREFERENCE = [ - "anthropic", - "openai", - "openai-codex", - "minimax", - "synthetic", - "google", - "zai", - "openrouter", - "opencode", - "github-copilot", - "groq", - "cerebras", - "mistral", - "xai", - "lmstudio", -] as const; - -function normalizeModelFamilyId(id: string): string { - const trimmed = id.trim(); - if (!trimmed) return trimmed; - const parts = trimmed.split("/").filter(Boolean); - return parts.length > 0 ? (parts[parts.length - 1] ?? trimmed) : trimmed; -} - -function sortProvidersForPicker(providers: string[]): string[] { - const pref = new Map( - MODEL_PICK_PROVIDER_PREFERENCE.map((provider, idx) => [provider, idx]), - ); - return providers.sort((a, b) => { - const pa = pref.get(a); - const pb = pref.get(b); - if (pa !== undefined && pb !== undefined) return pa - pb; - if (pa !== undefined) return -1; - if (pb !== undefined) return 1; - return a.localeCompare(b); - }); -} - -function buildModelPickerItems( - catalog: ModelPickerCatalogEntry[], -): ModelPickerItem[] { - const byModel = new Map }>(); - for (const entry of catalog) { - const provider = normalizeProviderId(entry.provider); - const model = normalizeModelFamilyId(entry.id); - if (!provider || !model) continue; - const existing = byModel.get(model); - if (existing) { - existing.providerModels[provider] = entry.id; - continue; - } - byModel.set(model, { providerModels: { [provider]: entry.id } }); - } - const out: ModelPickerItem[] = []; - for (const [model, data] of byModel.entries()) { - const providers = sortProvidersForPicker(Object.keys(data.providerModels)); - out.push({ model, providers, providerModels: data.providerModels }); - } - out.sort((a, b) => - a.model.toLowerCase().localeCompare(b.model.toLowerCase()), - ); - return out; -} - -function pickProviderForModel(params: { - item: ModelPickerItem; - preferredProvider?: string; -}): { provider: string; model: string } | null { - const preferred = params.preferredProvider - ? normalizeProviderId(params.preferredProvider) - : undefined; - if (preferred && params.item.providerModels[preferred]) { - return { - provider: preferred, - model: params.item.providerModels[preferred], - }; - } - const first = params.item.providers[0]; - if (!first) return null; - return { - provider: first, - model: params.item.providerModels[first] ?? params.item.model, - }; -} - -function resolveProviderEndpointLabel( - provider: string, - cfg: ClawdbotConfig, -): { endpoint?: string; api?: string } { - const normalized = normalizeProviderId(provider); - const providers = (cfg.models?.providers ?? {}) as Record< - string, - { baseUrl?: string; api?: string } | undefined - >; - const entry = providers[normalized]; - const endpoint = entry?.baseUrl?.trim(); - const api = entry?.api?.trim(); - return { - endpoint: endpoint || undefined, - api: api || undefined, - }; -} - -export type InlineDirectives = { - cleaned: string; - hasThinkDirective: boolean; - thinkLevel?: ThinkLevel; - rawThinkLevel?: string; - hasVerboseDirective: boolean; - verboseLevel?: VerboseLevel; - rawVerboseLevel?: string; - hasReasoningDirective: boolean; - reasoningLevel?: ReasoningLevel; - rawReasoningLevel?: string; - hasElevatedDirective: boolean; - elevatedLevel?: ElevatedLevel; - rawElevatedLevel?: string; - hasStatusDirective: boolean; - hasModelDirective: boolean; - rawModelDirective?: string; - rawModelProfile?: string; - hasQueueDirective: boolean; - queueMode?: QueueMode; - queueReset: boolean; - rawQueueMode?: string; - debounceMs?: number; - cap?: number; - dropPolicy?: QueueDropPolicy; - rawDebounce?: string; - rawCap?: string; - rawDrop?: string; - hasQueueOptions: boolean; -}; - -export function parseInlineDirectives( - body: string, - options?: { - modelAliases?: string[]; - disableElevated?: boolean; - allowStatusDirective?: boolean; - }, -): InlineDirectives { - const { - cleaned: thinkCleaned, - thinkLevel, - rawLevel: rawThinkLevel, - hasDirective: hasThinkDirective, - } = extractThinkDirective(body); - const { - cleaned: verboseCleaned, - verboseLevel, - rawLevel: rawVerboseLevel, - hasDirective: hasVerboseDirective, - } = extractVerboseDirective(thinkCleaned); - const { - cleaned: reasoningCleaned, - reasoningLevel, - rawLevel: rawReasoningLevel, - hasDirective: hasReasoningDirective, - } = extractReasoningDirective(verboseCleaned); - const { - cleaned: elevatedCleaned, - elevatedLevel, - rawLevel: rawElevatedLevel, - hasDirective: hasElevatedDirective, - } = options?.disableElevated - ? { - cleaned: reasoningCleaned, - elevatedLevel: undefined, - rawLevel: undefined, - hasDirective: false, - } - : extractElevatedDirective(reasoningCleaned); - const allowStatusDirective = options?.allowStatusDirective !== false; - const { cleaned: statusCleaned, hasDirective: hasStatusDirective } = - allowStatusDirective - ? extractStatusDirective(elevatedCleaned) - : { cleaned: elevatedCleaned, hasDirective: false }; - const { - cleaned: modelCleaned, - rawModel, - rawProfile, - hasDirective: hasModelDirective, - } = extractModelDirective(statusCleaned, { - aliases: options?.modelAliases, - }); - const { - cleaned: queueCleaned, - queueMode, - queueReset, - rawMode, - debounceMs, - cap, - dropPolicy, - rawDebounce, - rawCap, - rawDrop, - hasDirective: hasQueueDirective, - hasOptions: hasQueueOptions, - } = extractQueueDirective(modelCleaned); - - return { - cleaned: queueCleaned, - hasThinkDirective, - thinkLevel, - rawThinkLevel, - hasVerboseDirective, - verboseLevel, - rawVerboseLevel, - hasReasoningDirective, - reasoningLevel, - rawReasoningLevel, - hasElevatedDirective, - elevatedLevel, - rawElevatedLevel, - hasStatusDirective, - hasModelDirective, - rawModelDirective: rawModel, - rawModelProfile: rawProfile, - hasQueueDirective, - queueMode, - queueReset, - rawQueueMode: rawMode, - debounceMs, - cap, - dropPolicy, - rawDebounce, - rawCap, - rawDrop, - hasQueueOptions, - }; -} - -export function isDirectiveOnly(params: { - directives: InlineDirectives; - cleanedBody: string; - ctx: MsgContext; - cfg: ClawdbotConfig; - agentId?: string; - isGroup: boolean; -}): boolean { - const { directives, cleanedBody, ctx, cfg, agentId, isGroup } = params; - if ( - !directives.hasThinkDirective && - !directives.hasVerboseDirective && - !directives.hasReasoningDirective && - !directives.hasElevatedDirective && - !directives.hasModelDirective && - !directives.hasQueueDirective - ) - return false; - const stripped = stripStructuralPrefixes(cleanedBody ?? ""); - const noMentions = isGroup - ? stripMentions(stripped, ctx, cfg, agentId) - : stripped; - return noMentions.length === 0; -} - -export async function applyInlineDirectivesFastLane(params: { - directives: InlineDirectives; - commandAuthorized: boolean; - ctx: MsgContext; - cfg: ClawdbotConfig; - agentId?: string; - isGroup: boolean; - sessionEntry?: SessionEntry; - sessionStore?: Record; - sessionKey: string; - storePath?: string; - elevatedEnabled: boolean; - elevatedAllowed: boolean; - elevatedFailures?: Array<{ gate: string; key: string }>; - messageProviderKey?: string; - defaultProvider: string; - defaultModel: string; - aliasIndex: ModelAliasIndex; - allowedModelKeys: Set; - allowedModelCatalog: Awaited< - ReturnType - >; - resetModelOverride: boolean; - provider: string; - model: string; - initialModelLabel: string; - formatModelSwitchEvent: (label: string, alias?: string) => string; - agentCfg?: NonNullable["defaults"]; - modelState: { - resolveDefaultThinkingLevel: () => Promise; - allowedModelKeys: Set; - allowedModelCatalog: Awaited< - ReturnType< - typeof import("../../agents/model-catalog.js").loadModelCatalog - > - >; - resetModelOverride: boolean; - }; -}): Promise<{ directiveAck?: ReplyPayload; provider: string; model: string }> { - const { - directives, - commandAuthorized, - ctx, - cfg, - agentId, - isGroup, - sessionEntry, - sessionStore, - sessionKey, - storePath, - elevatedEnabled, - elevatedAllowed, - elevatedFailures, - messageProviderKey, - defaultProvider, - defaultModel, - aliasIndex, - allowedModelKeys, - allowedModelCatalog, - resetModelOverride, - formatModelSwitchEvent, - modelState, - } = params; - - let { provider, model } = params; - if ( - !commandAuthorized || - isDirectiveOnly({ - directives, - cleanedBody: directives.cleaned, - ctx, - cfg, - agentId, - isGroup, - }) - ) { - return { directiveAck: undefined, provider, model }; - } - - const agentCfg = params.agentCfg; - 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 directiveAck = await handleDirectiveOnly({ - cfg, - directives, - sessionEntry, - sessionStore, - sessionKey, - storePath, - elevatedEnabled, - elevatedAllowed, - elevatedFailures, - messageProviderKey, - defaultProvider, - defaultModel, - aliasIndex, - allowedModelKeys, - allowedModelCatalog, - resetModelOverride, - provider, - model, - initialModelLabel: params.initialModelLabel, - formatModelSwitchEvent, - currentThinkLevel, - currentVerboseLevel, - currentReasoningLevel, - currentElevatedLevel, - }); - - if (sessionEntry?.providerOverride) { - provider = sessionEntry.providerOverride; - } - if (sessionEntry?.modelOverride) { - model = sessionEntry.modelOverride; - } - - return { directiveAck, provider, model }; -} - -export async function handleDirectiveOnly(params: { - cfg: ClawdbotConfig; - directives: InlineDirectives; - sessionEntry?: SessionEntry; - sessionStore?: Record; - sessionKey: string; - storePath?: string; - elevatedEnabled: boolean; - elevatedAllowed: boolean; - elevatedFailures?: Array<{ gate: string; key: string }>; - messageProviderKey?: string; - defaultProvider: string; - defaultModel: string; - aliasIndex: ModelAliasIndex; - allowedModelKeys: Set; - allowedModelCatalog: Awaited< - ReturnType - >; - resetModelOverride: boolean; - provider: string; - model: string; - initialModelLabel: string; - formatModelSwitchEvent: (label: string, alias?: string) => string; - currentThinkLevel?: ThinkLevel; - currentVerboseLevel?: VerboseLevel; - currentReasoningLevel?: ReasoningLevel; - currentElevatedLevel?: ElevatedLevel; -}): Promise { - const { - directives, - sessionEntry, - sessionStore, - sessionKey, - storePath, - elevatedEnabled, - elevatedAllowed, - defaultProvider, - defaultModel, - aliasIndex, - allowedModelKeys, - allowedModelCatalog, - resetModelOverride, - provider, - model, - initialModelLabel, - formatModelSwitchEvent, - currentThinkLevel, - currentVerboseLevel, - currentReasoningLevel, - currentElevatedLevel, - } = params; - const activeAgentId = resolveSessionAgentId({ - sessionKey: params.sessionKey, - config: params.cfg, - }); - const agentDir = resolveAgentDir(params.cfg, activeAgentId); - const runtimeIsSandboxed = resolveSandboxRuntimeStatus({ - cfg: params.cfg, - sessionKey: params.sessionKey, - }).sandboxed; - const shouldHintDirectRuntime = - directives.hasElevatedDirective && !runtimeIsSandboxed; - - if (directives.hasModelDirective) { - const rawDirective = directives.rawModelDirective?.trim(); - const directive = rawDirective?.toLowerCase(); - const wantsStatus = directive === "status"; - const wantsList = !rawDirective || directive === "list"; - - if ((wantsList || wantsStatus) && directives.rawModelProfile) { - return { text: "Auth profile override requires a model selection." }; - } - - const resolvedDefault = resolveConfiguredModelRef({ - cfg: params.cfg, - defaultProvider, - defaultModel, - }); - const pickerCatalog: ModelPickerCatalogEntry[] = (() => { - const keys = new Set(); - const out: ModelPickerCatalogEntry[] = []; - - const push = (entry: ModelPickerCatalogEntry) => { - const provider = normalizeProviderId(entry.provider); - const id = String(entry.id ?? "").trim(); - if (!provider || !id) return; - const key = modelKey(provider, id); - if (keys.has(key)) return; - keys.add(key); - out.push({ provider, id, name: entry.name }); - }; - - // Prefer catalog entries (when available), but always merge in config-only - // allowlist entries. This keeps custom providers/models visible in /model. - for (const entry of allowedModelCatalog) push(entry); - - // Merge any configured allowlist keys that the catalog doesn't know about. - for (const raw of Object.keys( - params.cfg.agents?.defaults?.models ?? {}, - )) { - const resolved = resolveModelRefFromString({ - raw: String(raw), - defaultProvider, - aliasIndex, - }); - if (!resolved) continue; - push({ - provider: resolved.ref.provider, - id: resolved.ref.model, - name: resolved.ref.model, - }); - } - - // Ensure the configured default is always present (even when no allowlist). - if (resolvedDefault.model) { - push({ - provider: resolvedDefault.provider, - id: resolvedDefault.model, - name: resolvedDefault.model, - }); - } - - return out; - })(); - - if (wantsList) { - const items = buildModelPickerItems(pickerCatalog); - if (items.length === 0) return { text: "No models available." }; - const current = `${params.provider}/${params.model}`; - const lines: string[] = [ - `Current: ${current}`, - "Pick: /model <#> or /model ", - ]; - for (const [idx, item] of items.entries()) { - lines.push(`${idx + 1}) ${item.model} — ${item.providers.join(", ")}`); - } - lines.push("", "More: /model status"); - return { text: lines.join("\n") }; - } - - if (wantsStatus) { - const modelsPath = `${agentDir}/models.json`; - const formatPath = (value: string) => shortenHomePath(value); - const authMode: ModelAuthDetailMode = "verbose"; - const catalog = pickerCatalog; - if (catalog.length === 0) return { text: "No models available." }; - - const authByProvider = new Map(); - for (const entry of catalog) { - const provider = normalizeProviderId(entry.provider); - if (authByProvider.has(provider)) continue; - const auth = await resolveAuthLabel( - provider, - params.cfg, - modelsPath, - agentDir, - authMode, - ); - authByProvider.set(provider, formatAuthLabel(auth)); - } - - const current = `${params.provider}/${params.model}`; - const defaultLabel = `${defaultProvider}/${defaultModel}`; - const lines = [ - `Current: ${current}`, - `Default: ${defaultLabel}`, - `Agent: ${activeAgentId}`, - `Auth file: ${formatPath(resolveAuthStorePathForDisplay(agentDir))}`, - ]; - if (resetModelOverride) { - lines.push(`(previous selection reset to default)`); - } - - const byProvider = new Map(); - for (const entry of catalog) { - const provider = normalizeProviderId(entry.provider); - const models = byProvider.get(provider); - if (models) { - models.push(entry); - continue; - } - byProvider.set(provider, [entry]); - } - - for (const provider of byProvider.keys()) { - const models = byProvider.get(provider); - if (!models) continue; - const authLabel = authByProvider.get(provider) ?? "missing"; - const endpoint = resolveProviderEndpointLabel(provider, params.cfg); - const endpointSuffix = endpoint.endpoint - ? ` endpoint: ${endpoint.endpoint}` - : " endpoint: default"; - const apiSuffix = endpoint.api ? ` api: ${endpoint.api}` : ""; - lines.push(""); - lines.push( - `[${provider}]${endpointSuffix}${apiSuffix} auth: ${authLabel}`, - ); - for (const entry of models) { - const label = `${provider}/${entry.id}`; - const aliases = aliasIndex.byKey.get(label); - const aliasSuffix = - aliases && aliases.length > 0 ? ` (${aliases.join(", ")})` : ""; - lines.push(` • ${label}${aliasSuffix}`); - } - } - return { text: lines.join("\n") }; - } - } - - let modelSelection: ModelDirectiveSelection | undefined; - let profileOverride: string | undefined; - if (directives.hasModelDirective && directives.rawModelDirective) { - const raw = directives.rawModelDirective.trim(); - if (/^[0-9]+$/.test(raw)) { - const resolvedDefault = resolveConfiguredModelRef({ - cfg: params.cfg, - defaultProvider, - defaultModel, - }); - const pickerCatalog: ModelPickerCatalogEntry[] = (() => { - const keys = new Set(); - const out: ModelPickerCatalogEntry[] = []; - - const push = (entry: ModelPickerCatalogEntry) => { - const provider = normalizeProviderId(entry.provider); - const id = String(entry.id ?? "").trim(); - if (!provider || !id) return; - const key = modelKey(provider, id); - if (keys.has(key)) return; - keys.add(key); - out.push({ provider, id, name: entry.name }); - }; - - for (const entry of allowedModelCatalog) push(entry); - - for (const rawKey of Object.keys( - params.cfg.agents?.defaults?.models ?? {}, - )) { - const resolved = resolveModelRefFromString({ - raw: String(rawKey), - defaultProvider, - aliasIndex, - }); - if (!resolved) continue; - push({ - provider: resolved.ref.provider, - id: resolved.ref.model, - name: resolved.ref.model, - }); - } - if (resolvedDefault.model) { - push({ - provider: resolvedDefault.provider, - id: resolvedDefault.model, - name: resolvedDefault.model, - }); - } - return out; - })(); - - const items = buildModelPickerItems(pickerCatalog); - const index = Number.parseInt(raw, 10) - 1; - const item = Number.isFinite(index) ? items[index] : undefined; - if (!item) { - return { - text: `Invalid model selection "${raw}". Use /model to list.`, - }; - } - const picked = pickProviderForModel({ - item, - preferredProvider: params.provider, - }); - if (!picked) { - return { - text: `Invalid model selection "${raw}". Use /model to list.`, - }; - } - const key = `${picked.provider}/${picked.model}`; - const aliases = aliasIndex.byKey.get(key); - const alias = aliases && aliases.length > 0 ? aliases[0] : undefined; - modelSelection = { - provider: picked.provider, - model: picked.model, - isDefault: - picked.provider === defaultProvider && picked.model === defaultModel, - ...(alias ? { alias } : {}), - }; - } else { - const resolved = resolveModelDirectiveSelection({ - raw, - defaultProvider, - defaultModel, - aliasIndex, - allowedModelKeys, - }); - if (resolved.error) { - return { text: resolved.error }; - } - modelSelection = resolved.selection; - } - if (modelSelection && directives.rawModelProfile) { - const profileResolved = resolveProfileOverride({ - rawProfile: directives.rawModelProfile, - provider: modelSelection.provider, - cfg: params.cfg, - agentDir, - }); - if (profileResolved.error) { - return { text: profileResolved.error }; - } - profileOverride = profileResolved.profileId; - } - } - if (directives.rawModelProfile && !modelSelection) { - return { text: "Auth profile override requires a model selection." }; - } - - const resolvedProvider = modelSelection?.provider ?? provider; - const resolvedModel = modelSelection?.model ?? model; - - if (directives.hasThinkDirective && !directives.thinkLevel) { - // If no argument was provided, show the current level - if (!directives.rawThinkLevel) { - const level = currentThinkLevel ?? "off"; - return { - text: withOptions( - `Current thinking level: ${level}.`, - formatThinkingLevels(resolvedProvider, resolvedModel), - ), - }; - } - return { - text: `Unrecognized thinking level "${directives.rawThinkLevel}". Valid levels: ${formatThinkingLevels(resolvedProvider, resolvedModel)}.`, - }; - } - if (directives.hasVerboseDirective && !directives.verboseLevel) { - if (!directives.rawVerboseLevel) { - const level = currentVerboseLevel ?? "off"; - return { - text: withOptions(`Current verbose level: ${level}.`, "on, off"), - }; - } - return { - text: `Unrecognized verbose level "${directives.rawVerboseLevel}". Valid levels: off, on.`, - }; - } - if (directives.hasReasoningDirective && !directives.reasoningLevel) { - if (!directives.rawReasoningLevel) { - const level = currentReasoningLevel ?? "off"; - return { - text: withOptions( - `Current reasoning level: ${level}.`, - "on, off, stream", - ), - }; - } - return { - text: `Unrecognized reasoning level "${directives.rawReasoningLevel}". Valid levels: on, off, stream.`, - }; - } - if (directives.hasElevatedDirective && !directives.elevatedLevel) { - if (!directives.rawElevatedLevel) { - if (!elevatedEnabled || !elevatedAllowed) { - return { - text: formatElevatedUnavailableText({ - runtimeSandboxed: runtimeIsSandboxed, - failures: params.elevatedFailures, - sessionKey: params.sessionKey, - }), - }; - } - const level = currentElevatedLevel ?? "off"; - return { - text: [ - withOptions(`Current elevated level: ${level}.`, "on, off"), - shouldHintDirectRuntime ? formatElevatedRuntimeHint() : null, - ] - .filter(Boolean) - .join("\n"), - }; - } - return { - text: `Unrecognized elevated level "${directives.rawElevatedLevel}". Valid levels: off, on.`, - }; - } - if ( - directives.hasElevatedDirective && - (!elevatedEnabled || !elevatedAllowed) - ) { - return { - text: formatElevatedUnavailableText({ - runtimeSandboxed: runtimeIsSandboxed, - failures: params.elevatedFailures, - sessionKey: params.sessionKey, - }), - }; - } - - if ( - directives.hasQueueDirective && - !directives.queueMode && - !directives.queueReset && - !directives.hasQueueOptions && - directives.rawQueueMode === undefined && - directives.rawDebounce === undefined && - directives.rawCap === undefined && - directives.rawDrop === undefined - ) { - const settings = resolveQueueSettings({ - cfg: params.cfg, - channel: provider, - sessionEntry, - }); - const debounceLabel = - typeof settings.debounceMs === "number" - ? `${settings.debounceMs}ms` - : "default"; - const capLabel = - typeof settings.cap === "number" ? String(settings.cap) : "default"; - const dropLabel = settings.dropPolicy ?? "default"; - return { - text: withOptions( - `Current queue settings: mode=${settings.mode}, debounce=${debounceLabel}, cap=${capLabel}, drop=${dropLabel}.`, - "modes steer, followup, collect, steer+backlog, interrupt; debounce:, cap:, drop:old|new|summarize", - ), - }; - } - - const queueModeInvalid = - directives.hasQueueDirective && - !directives.queueMode && - !directives.queueReset && - Boolean(directives.rawQueueMode); - const queueDebounceInvalid = - directives.hasQueueDirective && - directives.rawDebounce !== undefined && - typeof directives.debounceMs !== "number"; - const queueCapInvalid = - directives.hasQueueDirective && - directives.rawCap !== undefined && - typeof directives.cap !== "number"; - const queueDropInvalid = - directives.hasQueueDirective && - directives.rawDrop !== undefined && - !directives.dropPolicy; - if ( - queueModeInvalid || - queueDebounceInvalid || - queueCapInvalid || - queueDropInvalid - ) { - const errors: string[] = []; - if (queueModeInvalid) { - errors.push( - `Unrecognized queue mode "${directives.rawQueueMode ?? ""}". Valid modes: steer, followup, collect, steer+backlog, interrupt.`, - ); - } - if (queueDebounceInvalid) { - errors.push( - `Invalid debounce "${directives.rawDebounce ?? ""}". Use ms/s/m (e.g. debounce:1500ms, debounce:2s).`, - ); - } - if (queueCapInvalid) { - errors.push( - `Invalid cap "${directives.rawCap ?? ""}". Use a positive integer (e.g. cap:10).`, - ); - } - if (queueDropInvalid) { - errors.push( - `Invalid drop policy "${directives.rawDrop ?? ""}". Use drop:old, drop:new, or drop:summarize.`, - ); - } - return { text: errors.join(" ") }; - } - - if ( - directives.hasThinkDirective && - directives.thinkLevel === "xhigh" && - !supportsXHighThinking(resolvedProvider, resolvedModel) - ) { - return { - text: `Thinking level "xhigh" is only supported for ${formatXHighModelHint()}.`, - }; - } - - const nextThinkLevel = directives.hasThinkDirective - ? directives.thinkLevel - : ((sessionEntry?.thinkingLevel as ThinkLevel | undefined) ?? - currentThinkLevel); - const shouldDowngradeXHigh = - !directives.hasThinkDirective && - nextThinkLevel === "xhigh" && - !supportsXHighThinking(resolvedProvider, resolvedModel); - - if (sessionEntry && sessionStore && sessionKey) { - const prevElevatedLevel = - currentElevatedLevel ?? - (sessionEntry.elevatedLevel as ElevatedLevel | undefined) ?? - (elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel)); - const prevReasoningLevel = - currentReasoningLevel ?? - (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? - "off"; - let elevatedChanged = - directives.hasElevatedDirective && - directives.elevatedLevel !== undefined && - elevatedEnabled && - elevatedAllowed; - let reasoningChanged = - directives.hasReasoningDirective && - directives.reasoningLevel !== undefined; - if (directives.hasThinkDirective && directives.thinkLevel) { - if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel; - else sessionEntry.thinkingLevel = directives.thinkLevel; - } - if (shouldDowngradeXHigh) { - sessionEntry.thinkingLevel = "high"; - } - if (directives.hasVerboseDirective && directives.verboseLevel) { - applyVerboseOverride(sessionEntry, directives.verboseLevel); - } - if (directives.hasReasoningDirective && directives.reasoningLevel) { - if (directives.reasoningLevel === "off") - delete sessionEntry.reasoningLevel; - else sessionEntry.reasoningLevel = directives.reasoningLevel; - reasoningChanged = - directives.reasoningLevel !== prevReasoningLevel && - directives.reasoningLevel !== undefined; - } - if (directives.hasElevatedDirective && directives.elevatedLevel) { - // Unlike other toggles, elevated defaults can be "on". - // Persist "off" explicitly so `/elevated off` actually overrides defaults. - sessionEntry.elevatedLevel = directives.elevatedLevel; - elevatedChanged = - elevatedChanged || - (directives.elevatedLevel !== prevElevatedLevel && - directives.elevatedLevel !== undefined); - } - if (modelSelection) { - if (modelSelection.isDefault) { - delete sessionEntry.providerOverride; - delete sessionEntry.modelOverride; - } else { - sessionEntry.providerOverride = modelSelection.provider; - sessionEntry.modelOverride = modelSelection.model; - } - if (profileOverride) { - sessionEntry.authProfileOverride = profileOverride; - } else if (directives.hasModelDirective) { - delete sessionEntry.authProfileOverride; - } - } - if (directives.hasQueueDirective && directives.queueReset) { - delete sessionEntry.queueMode; - delete sessionEntry.queueDebounceMs; - delete sessionEntry.queueCap; - delete sessionEntry.queueDrop; - } else if (directives.hasQueueDirective) { - if (directives.queueMode) sessionEntry.queueMode = directives.queueMode; - if (typeof directives.debounceMs === "number") { - sessionEntry.queueDebounceMs = directives.debounceMs; - } - if (typeof directives.cap === "number") { - sessionEntry.queueCap = directives.cap; - } - if (directives.dropPolicy) { - sessionEntry.queueDrop = directives.dropPolicy; - } - } - sessionEntry.updatedAt = Date.now(); - sessionStore[sessionKey] = sessionEntry; - if (storePath) { - await saveSessionStore(storePath, sessionStore); - } - if (modelSelection) { - const nextLabel = `${modelSelection.provider}/${modelSelection.model}`; - if (nextLabel !== initialModelLabel) { - enqueueSystemEvent( - formatModelSwitchEvent(nextLabel, modelSelection.alias), - { - sessionKey, - contextKey: `model:${nextLabel}`, - }, - ); - } - } - if (elevatedChanged) { - const nextElevated = (sessionEntry.elevatedLevel ?? - "off") as ElevatedLevel; - enqueueSystemEvent(formatElevatedEvent(nextElevated), { - sessionKey, - contextKey: "mode:elevated", - }); - } - if (reasoningChanged) { - const nextReasoning = (sessionEntry.reasoningLevel ?? - "off") as ReasoningLevel; - enqueueSystemEvent(formatReasoningEvent(nextReasoning), { - sessionKey, - contextKey: "mode:reasoning", - }); - } - } - - const parts: string[] = []; - if (directives.hasThinkDirective && directives.thinkLevel) { - parts.push( - directives.thinkLevel === "off" - ? "Thinking disabled." - : `Thinking level set to ${directives.thinkLevel}.`, - ); - } - if (directives.hasVerboseDirective && directives.verboseLevel) { - parts.push( - directives.verboseLevel === "off" - ? formatDirectiveAck("Verbose logging disabled.") - : formatDirectiveAck("Verbose logging enabled."), - ); - } - if (directives.hasReasoningDirective && directives.reasoningLevel) { - parts.push( - directives.reasoningLevel === "off" - ? formatDirectiveAck("Reasoning visibility disabled.") - : directives.reasoningLevel === "stream" - ? formatDirectiveAck("Reasoning stream enabled (Telegram only).") - : formatDirectiveAck("Reasoning visibility enabled."), - ); - } - if (directives.hasElevatedDirective && directives.elevatedLevel) { - parts.push( - directives.elevatedLevel === "off" - ? formatDirectiveAck("Elevated mode disabled.") - : formatDirectiveAck("Elevated mode enabled."), - ); - if (shouldHintDirectRuntime) parts.push(formatElevatedRuntimeHint()); - } - if (shouldDowngradeXHigh) { - parts.push( - `Thinking level set to high (xhigh not supported for ${resolvedProvider}/${resolvedModel}).`, - ); - } - if (modelSelection) { - const label = `${modelSelection.provider}/${modelSelection.model}`; - const labelWithAlias = modelSelection.alias - ? `${modelSelection.alias} (${label})` - : label; - parts.push( - modelSelection.isDefault - ? `Model reset to default (${labelWithAlias}).` - : `Model set to ${labelWithAlias}.`, - ); - if (profileOverride) { - parts.push(`Auth profile set to ${profileOverride}.`); - } - } - if (directives.hasQueueDirective && directives.queueMode) { - parts.push( - formatDirectiveAck(`Queue mode set to ${directives.queueMode}.`), - ); - } else if (directives.hasQueueDirective && directives.queueReset) { - parts.push(formatDirectiveAck("Queue mode reset to default.")); - } - if ( - directives.hasQueueDirective && - typeof directives.debounceMs === "number" - ) { - parts.push( - formatDirectiveAck(`Queue debounce set to ${directives.debounceMs}ms.`), - ); - } - if (directives.hasQueueDirective && typeof directives.cap === "number") { - parts.push(formatDirectiveAck(`Queue cap set to ${directives.cap}.`)); - } - if (directives.hasQueueDirective && directives.dropPolicy) { - parts.push( - formatDirectiveAck(`Queue drop set to ${directives.dropPolicy}.`), - ); - } - const ack = parts.join(" ").trim(); - if (!ack && directives.hasStatusDirective) return undefined; - return { text: ack || "OK." }; -} - -export async function persistInlineDirectives(params: { - directives: InlineDirectives; - effectiveModelDirective?: string; - cfg: ClawdbotConfig; - agentDir?: string; - sessionEntry?: SessionEntry; - sessionStore?: Record; - sessionKey?: string; - storePath?: string; - elevatedEnabled: boolean; - elevatedAllowed: boolean; - defaultProvider: string; - defaultModel: string; - aliasIndex: ModelAliasIndex; - allowedModelKeys: Set; - provider: string; - model: string; - initialModelLabel: string; - formatModelSwitchEvent: (label: string, alias?: string) => string; - agentCfg: NonNullable["defaults"] | undefined; -}): Promise<{ provider: string; model: string; contextTokens: number }> { - const { - directives, - cfg, - sessionEntry, - sessionStore, - sessionKey, - storePath, - elevatedEnabled, - elevatedAllowed, - defaultProvider, - defaultModel, - aliasIndex, - allowedModelKeys, - initialModelLabel, - formatModelSwitchEvent, - agentCfg, - } = params; - let { provider, model } = params; - const activeAgentId = sessionKey - ? resolveSessionAgentId({ sessionKey, config: cfg }) - : resolveDefaultAgentId(cfg); - const agentDir = resolveAgentDir(cfg, activeAgentId); - - if (sessionEntry && sessionStore && sessionKey) { - const prevElevatedLevel = - (sessionEntry.elevatedLevel as ElevatedLevel | undefined) ?? - (agentCfg?.elevatedDefault as ElevatedLevel | undefined) ?? - (elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel)); - const prevReasoningLevel = - (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off"; - let elevatedChanged = - directives.hasElevatedDirective && - directives.elevatedLevel !== undefined && - elevatedEnabled && - elevatedAllowed; - let reasoningChanged = - directives.hasReasoningDirective && - directives.reasoningLevel !== undefined; - let updated = false; - if (directives.hasThinkDirective && directives.thinkLevel) { - if (directives.thinkLevel === "off") { - delete sessionEntry.thinkingLevel; - } else { - sessionEntry.thinkingLevel = directives.thinkLevel; - } - updated = true; - } - if (directives.hasVerboseDirective && directives.verboseLevel) { - applyVerboseOverride(sessionEntry, directives.verboseLevel); - updated = true; - } - if (directives.hasReasoningDirective && directives.reasoningLevel) { - if (directives.reasoningLevel === "off") { - delete sessionEntry.reasoningLevel; - } else { - sessionEntry.reasoningLevel = directives.reasoningLevel; - } - reasoningChanged = - reasoningChanged || - (directives.reasoningLevel !== prevReasoningLevel && - directives.reasoningLevel !== undefined); - updated = true; - } - if ( - directives.hasElevatedDirective && - directives.elevatedLevel && - elevatedEnabled && - elevatedAllowed - ) { - // Persist "off" explicitly so inline `/elevated off` overrides defaults. - sessionEntry.elevatedLevel = directives.elevatedLevel; - elevatedChanged = - elevatedChanged || - (directives.elevatedLevel !== prevElevatedLevel && - directives.elevatedLevel !== undefined); - updated = true; - } - const modelDirective = - directives.hasModelDirective && params.effectiveModelDirective - ? params.effectiveModelDirective - : undefined; - if (modelDirective) { - const resolved = resolveModelRefFromString({ - raw: modelDirective, - defaultProvider, - aliasIndex, - }); - if (resolved) { - const key = modelKey(resolved.ref.provider, resolved.ref.model); - if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) { - let profileOverride: string | undefined; - if (directives.rawModelProfile) { - const profileResolved = resolveProfileOverride({ - rawProfile: directives.rawModelProfile, - provider: resolved.ref.provider, - cfg, - agentDir, - }); - if (profileResolved.error) { - throw new Error(profileResolved.error); - } - profileOverride = profileResolved.profileId; - } - const isDefault = - resolved.ref.provider === defaultProvider && - resolved.ref.model === defaultModel; - if (isDefault) { - delete sessionEntry.providerOverride; - delete sessionEntry.modelOverride; - } else { - sessionEntry.providerOverride = resolved.ref.provider; - sessionEntry.modelOverride = resolved.ref.model; - } - if (profileOverride) { - sessionEntry.authProfileOverride = profileOverride; - } else if (directives.hasModelDirective) { - delete sessionEntry.authProfileOverride; - } - provider = resolved.ref.provider; - model = resolved.ref.model; - const nextLabel = `${provider}/${model}`; - if (nextLabel !== initialModelLabel) { - enqueueSystemEvent( - formatModelSwitchEvent(nextLabel, resolved.alias), - { - sessionKey, - contextKey: `model:${nextLabel}`, - }, - ); - } - updated = true; - } - } - } - if (directives.hasQueueDirective && directives.queueReset) { - delete sessionEntry.queueMode; - delete sessionEntry.queueDebounceMs; - delete sessionEntry.queueCap; - delete sessionEntry.queueDrop; - updated = true; - } - if (updated) { - sessionEntry.updatedAt = Date.now(); - sessionStore[sessionKey] = sessionEntry; - if (storePath) { - await saveSessionStore(storePath, sessionStore); - } - if (elevatedChanged) { - const nextElevated = (sessionEntry.elevatedLevel ?? - "off") as ElevatedLevel; - enqueueSystemEvent(formatElevatedEvent(nextElevated), { - sessionKey, - contextKey: "mode:elevated", - }); - } - if (reasoningChanged) { - const nextReasoning = (sessionEntry.reasoningLevel ?? - "off") as ReasoningLevel; - enqueueSystemEvent(formatReasoningEvent(nextReasoning), { - sessionKey, - contextKey: "mode:reasoning", - }); - } - } - } - - return { - provider, - model, - contextTokens: - agentCfg?.contextTokens ?? - lookupContextTokens(model) ?? - DEFAULT_CONTEXT_TOKENS, - }; -} - -export function resolveDefaultModel(params: { - cfg: ClawdbotConfig; - agentId?: string; -}): { - defaultProvider: string; - defaultModel: string; - aliasIndex: ModelAliasIndex; -} { - const agentModelOverride = params.agentId - ? resolveAgentModelPrimary(params.cfg, params.agentId) - : undefined; - const cfg = - agentModelOverride && agentModelOverride.length > 0 - ? { - ...params.cfg, - agents: { - ...params.cfg.agents, - defaults: { - ...params.cfg.agents?.defaults, - model: { - ...(typeof params.cfg.agents?.defaults?.model === "object" - ? params.cfg.agents.defaults.model - : undefined), - primary: agentModelOverride, - }, - }, - }, - } - : params.cfg; - const mainModel = resolveConfiguredModelRef({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, - }); - const defaultProvider = mainModel.provider; - const defaultModel = mainModel.model; - const aliasIndex = buildModelAliasIndex({ - cfg, - defaultProvider, - }); - return { defaultProvider, defaultModel, aliasIndex }; -} +export { applyInlineDirectivesFastLane } from "./directive-handling.fast-lane.js"; +export * from "./directive-handling.impl.js"; +export type { InlineDirectives } from "./directive-handling.parse.js"; +export { + isDirectiveOnly, + parseInlineDirectives, +} from "./directive-handling.parse.js"; +export { + persistInlineDirectives, + resolveDefaultModel, +} from "./directive-handling.persist.js"; +export { formatDirectiveAck } from "./directive-handling.shared.js";