refactor(auto-reply): split directive handling
This commit is contained in:
237
src/auto-reply/reply/directive-handling.auth.ts
Normal file
237
src/auto-reply/reply/directive-handling.auth.ts
Normal file
@@ -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 };
|
||||
};
|
||||
145
src/auto-reply/reply/directive-handling.fast-lane.ts
Normal file
145
src/auto-reply/reply/directive-handling.fast-lane.ts
Normal file
@@ -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<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
storePath?: string;
|
||||
elevatedEnabled: boolean;
|
||||
elevatedAllowed: boolean;
|
||||
elevatedFailures?: Array<{ gate: string; key: string }>;
|
||||
messageProviderKey?: string;
|
||||
defaultProvider: string;
|
||||
defaultModel: string;
|
||||
aliasIndex: ModelAliasIndex;
|
||||
allowedModelKeys: Set<string>;
|
||||
allowedModelCatalog: Awaited<
|
||||
ReturnType<typeof import("../../agents/model-catalog.js").loadModelCatalog>
|
||||
>;
|
||||
resetModelOverride: boolean;
|
||||
provider: string;
|
||||
model: string;
|
||||
initialModelLabel: string;
|
||||
formatModelSwitchEvent: (label: string, alias?: string) => string;
|
||||
agentCfg?: NonNullable<ClawdbotConfig["agents"]>["defaults"];
|
||||
modelState: {
|
||||
resolveDefaultThinkingLevel: () => Promise<ThinkLevel | undefined>;
|
||||
allowedModelKeys: Set<string>;
|
||||
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 };
|
||||
}
|
||||
426
src/auto-reply/reply/directive-handling.impl.ts
Normal file
426
src/auto-reply/reply/directive-handling.impl.ts
Normal file
@@ -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<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
storePath?: string;
|
||||
elevatedEnabled: boolean;
|
||||
elevatedAllowed: boolean;
|
||||
elevatedFailures?: Array<{ gate: string; key: string }>;
|
||||
messageProviderKey?: string;
|
||||
defaultProvider: string;
|
||||
defaultModel: string;
|
||||
aliasIndex: ModelAliasIndex;
|
||||
allowedModelKeys: Set<string>;
|
||||
allowedModelCatalog: Awaited<
|
||||
ReturnType<typeof import("../../agents/model-catalog.js").loadModelCatalog>
|
||||
>;
|
||||
resetModelOverride: boolean;
|
||||
provider: string;
|
||||
model: string;
|
||||
initialModelLabel: string;
|
||||
formatModelSwitchEvent: (label: string, alias?: string) => string;
|
||||
currentThinkLevel?: ThinkLevel;
|
||||
currentVerboseLevel?: VerboseLevel;
|
||||
currentReasoningLevel?: ReasoningLevel;
|
||||
currentElevatedLevel?: ElevatedLevel;
|
||||
}): Promise<ReplyPayload | undefined> {
|
||||
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." };
|
||||
}
|
||||
118
src/auto-reply/reply/directive-handling.model-picker.ts
Normal file
118
src/auto-reply/reply/directive-handling.model-picker.ts
Normal file
@@ -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<string, string>;
|
||||
};
|
||||
|
||||
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<string, number>(
|
||||
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<string, { providerModels: Record<string, string> }>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
298
src/auto-reply/reply/directive-handling.model.ts
Normal file
298
src/auto-reply/reply/directive-handling.model.ts
Normal file
@@ -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<string>();
|
||||
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<ReplyPayload | undefined> {
|
||||
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 <provider/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<string, string>();
|
||||
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<string, ModelPickerCatalogEntry[]>();
|
||||
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<string>;
|
||||
allowedModelCatalog: Array<{ provider: string; id?: string; name?: string }>;
|
||||
provider: string;
|
||||
}): {
|
||||
modelSelection?: ModelDirectiveSelection;
|
||||
profileOverride?: string;
|
||||
errorText?: string;
|
||||
} {
|
||||
if (
|
||||
!params.directives.hasModelDirective ||
|
||||
!params.directives.rawModelDirective
|
||||
) {
|
||||
if (params.directives.rawModelProfile) {
|
||||
return { errorText: "Auth profile override requires a model selection." };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
const raw = params.directives.rawModelDirective.trim();
|
||||
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 };
|
||||
}
|
||||
174
src/auto-reply/reply/directive-handling.parse.ts
Normal file
174
src/auto-reply/reply/directive-handling.parse.ts
Normal file
@@ -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;
|
||||
}
|
||||
273
src/auto-reply/reply/directive-handling.persist.ts
Normal file
273
src/auto-reply/reply/directive-handling.persist.ts
Normal file
@@ -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<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
elevatedEnabled: boolean;
|
||||
elevatedAllowed: boolean;
|
||||
defaultProvider: string;
|
||||
defaultModel: string;
|
||||
aliasIndex: ModelAliasIndex;
|
||||
allowedModelKeys: Set<string>;
|
||||
provider: string;
|
||||
model: string;
|
||||
initialModelLabel: string;
|
||||
formatModelSwitchEvent: (label: string, alias?: string) => string;
|
||||
agentCfg: NonNullable<ClawdbotConfig["agents"]>["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 };
|
||||
}
|
||||
89
src/auto-reply/reply/directive-handling.queue-validation.ts
Normal file
89
src/auto-reply/reply/directive-handling.queue-validation.ts
Normal file
@@ -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:<ms|s|m>, cap:<n>, 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;
|
||||
}
|
||||
52
src/auto-reply/reply/directive-handling.shared.ts
Normal file
52
src/auto-reply/reply/directive-handling.shared.ts
Normal file
@@ -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 <think>.";
|
||||
if (level === "on") return "Reasoning ON — include <think>.";
|
||||
return "Reasoning OFF — hide <think>.";
|
||||
};
|
||||
|
||||
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.<provider>, agents.list[].tools.elevated.*",
|
||||
);
|
||||
}
|
||||
if (params.sessionKey) {
|
||||
lines.push(`See: clawdbot sandbox explain --session ${params.sessionKey}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user