openclaw/src/auto-reply/status.ts

747 lines
23 KiB
TypeScript
Raw Normal View History

2025-12-07 16:53:19 +00:00
import fs from "node:fs";
import { lookupContextTokens } from "../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
2026-01-09 02:21:17 +00:00
import { resolveModelAuthMode } from "../agents/model-auth.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import { resolveSandboxRuntimeStatus } from "../agents/sandbox.js";
import type { SkillCommandSpec } from "../agents/skills.js";
import { derivePromptTokens, normalizeUsage, type UsageLike } from "../agents/usage.js";
import type { OpenClawConfig } from "../config/config.js";
2025-12-17 11:29:04 +01:00
import {
2026-01-09 03:14:39 +00:00
resolveMainSessionKey,
resolveSessionFilePath,
resolveSessionFilePathOptions,
2025-12-17 11:29:04 +01:00
type SessionEntry,
type SessionScope,
} from "../config/sessions.js";
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
import { resolveCommitHash } from "../infra/git-commit.js";
import type { MediaUnderstandingDecision } from "../media-understanding/types.js";
import { listPluginCommands } from "../plugins/commands.js";
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
2026-01-24 10:03:19 +00:00
import {
getTtsMaxLength,
getTtsProvider,
isSummarizationEnabled,
resolveTtsAutoMode,
2026-01-24 10:03:19 +00:00
resolveTtsConfig,
resolveTtsPrefsPath,
} from "../tts/tts.js";
2026-01-09 02:21:17 +00:00
import {
estimateUsageCost,
formatTokenCount as formatTokenCountShared,
formatUsd,
resolveModelCostConfig,
} from "../utils/usage-format.js";
2026-01-08 22:51:35 -05:00
import { VERSION } from "../version.js";
import {
listChatCommands,
listChatCommandsForConfig,
type ChatCommandDefinition,
} from "./commands-registry.js";
import type { CommandCategory } from "./commands-registry.types.js";
import { resolveActiveFallbackState } from "./fallback-state.js";
import { formatProviderModelRef, resolveSelectedAndActiveModel } from "./model-runtime.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js";
2025-12-07 16:53:19 +00:00
2026-02-17 11:16:36 +09:00
type AgentDefaults = NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>;
type AgentConfig = Partial<AgentDefaults> & {
model?: AgentDefaults["model"] | string;
};
2025-12-07 16:53:19 +00:00
2026-01-09 02:21:17 +00:00
export const formatTokenCount = formatTokenCountShared;
2026-01-07 07:21:56 +01:00
type QueueStatus = {
mode?: string;
depth?: number;
debounceMs?: number;
cap?: number;
dropPolicy?: string;
showDetails?: boolean;
};
2025-12-07 16:53:19 +00:00
type StatusArgs = {
2026-01-30 03:15:10 +01:00
config?: OpenClawConfig;
2025-12-17 11:29:04 +01:00
agent: AgentConfig;
agentId?: string;
2025-12-07 16:53:19 +00:00
sessionEntry?: SessionEntry;
sessionKey?: string;
sessionScope?: SessionScope;
sessionStorePath?: string;
groupActivation?: "mention" | "always";
2025-12-07 16:53:19 +00:00
resolvedThink?: ThinkLevel;
resolvedVerbose?: VerboseLevel;
resolvedReasoning?: ReasoningLevel;
2026-01-05 07:07:17 +01:00
resolvedElevated?: ElevatedLevel;
2026-01-05 15:50:14 +01:00
modelAuth?: string;
activeModelAuth?: string;
2026-01-07 11:42:41 +01:00
usageLine?: string;
2026-01-24 06:22:54 +00:00
timeLine?: string;
2026-01-07 07:21:56 +01:00
queue?: QueueStatus;
2026-02-17 11:16:36 +09:00
mediaDecisions?: ReadonlyArray<MediaUnderstandingDecision>;
subagentsLine?: string;
2026-01-07 07:21:56 +01:00
includeTranscriptUsage?: boolean;
2026-01-09 03:14:39 +00:00
now?: number;
2025-12-07 16:53:19 +00:00
};
function resolveRuntimeLabel(
args: Pick<StatusArgs, "config" | "agent" | "sessionKey" | "sessionScope">,
): string {
const sessionKey = args.sessionKey?.trim();
if (args.config && sessionKey) {
const runtimeStatus = resolveSandboxRuntimeStatus({
cfg: args.config,
sessionKey,
});
const sandboxMode = runtimeStatus.mode ?? "off";
if (sandboxMode === "off") {
return "direct";
}
const runtime = runtimeStatus.sandboxed ? "docker" : sessionKey ? "direct" : "unknown";
return `${runtime}/${sandboxMode}`;
}
const sandboxMode = args.agent?.sandbox?.mode ?? "off";
if (sandboxMode === "off") {
return "direct";
}
const sandboxed = (() => {
if (!sessionKey) {
return false;
}
if (sandboxMode === "all") {
return true;
}
if (args.config) {
return resolveSandboxRuntimeStatus({
cfg: args.config,
sessionKey,
}).sandboxed;
}
const sessionScope = args.sessionScope ?? "per-sender";
const mainKey = resolveMainSessionKey({
session: { scope: sessionScope },
});
return sessionKey !== mainKey.trim();
})();
const runtime = sandboxed ? "docker" : sessionKey ? "direct" : "unknown";
return `${runtime}/${sandboxMode}`;
}
const formatTokens = (total: number | null | undefined, contextTokens: number | null) => {
2025-12-07 16:53:19 +00:00
const ctx = contextTokens ?? null;
if (total == null) {
2026-01-09 02:21:17 +00:00
const ctxLabel = ctx ? formatTokenCount(ctx) : "?";
return `?/${ctxLabel}`;
}
2025-12-07 16:53:19 +00:00
const pct = ctx ? Math.min(999, Math.round((total / ctx) * 100)) : null;
2026-01-09 02:21:17 +00:00
const totalLabel = formatTokenCount(total);
const ctxLabel = ctx ? formatTokenCount(ctx) : "?";
2025-12-07 16:53:19 +00:00
return `${totalLabel}/${ctxLabel}${pct !== null ? ` (${pct}%)` : ""}`;
};
export const formatContextUsageShort = (
total: number | null | undefined,
contextTokens: number | null | undefined,
) => `Context ${formatTokens(total, contextTokens ?? null)}`;
2026-01-07 07:21:56 +01:00
const formatQueueDetails = (queue?: QueueStatus) => {
if (!queue) {
return "";
}
2026-01-07 07:21:56 +01:00
const depth = typeof queue.depth === "number" ? `depth ${queue.depth}` : null;
if (!queue.showDetails) {
return depth ? ` (${depth})` : "";
}
const detailParts: string[] = [];
if (depth) {
detailParts.push(depth);
}
2026-01-07 07:21:56 +01:00
if (typeof queue.debounceMs === "number") {
const ms = Math.max(0, Math.round(queue.debounceMs));
const label =
ms >= 1000 ? `${ms % 1000 === 0 ? ms / 1000 : (ms / 1000).toFixed(1)}s` : `${ms}ms`;
2026-01-07 07:21:56 +01:00
detailParts.push(`debounce ${label}`);
}
if (typeof queue.cap === "number") {
detailParts.push(`cap ${queue.cap}`);
}
if (queue.dropPolicy) {
detailParts.push(`drop ${queue.dropPolicy}`);
}
2026-01-07 07:21:56 +01:00
return detailParts.length ? ` (${detailParts.join(" · ")})` : "";
};
const readUsageFromSessionLog = (
sessionId?: string,
sessionEntry?: SessionEntry,
agentId?: string,
sessionKey?: string,
storePath?: string,
):
| {
input: number;
output: number;
promptTokens: number;
total: number;
model?: string;
}
| undefined => {
2026-01-30 03:15:10 +01:00
// Transcripts are stored at the session file path (fallback: ~/.openclaw/sessions/<SessionId>.jsonl)
if (!sessionId) {
return undefined;
}
let logPath: string;
try {
const resolvedAgentId =
agentId ?? (sessionKey ? resolveAgentIdFromSessionKey(sessionKey) : undefined);
logPath = resolveSessionFilePath(
sessionId,
sessionEntry,
resolveSessionFilePathOptions({ agentId: resolvedAgentId, storePath }),
);
} catch {
return undefined;
}
if (!fs.existsSync(logPath)) {
return undefined;
}
try {
const lines = fs.readFileSync(logPath, "utf-8").split(/\n+/);
let input = 0;
let output = 0;
let promptTokens = 0;
let model: string | undefined;
let lastUsage: ReturnType<typeof normalizeUsage> | undefined;
for (const line of lines) {
if (!line.trim()) {
continue;
}
try {
const parsed = JSON.parse(line) as {
message?: {
usage?: UsageLike;
model?: string;
};
usage?: UsageLike;
model?: string;
};
const usageRaw = parsed.message?.usage ?? parsed.usage;
const usage = normalizeUsage(usageRaw);
if (usage) {
lastUsage = usage;
}
model = parsed.message?.model ?? parsed.model ?? model;
} catch {
// ignore bad lines
}
}
if (!lastUsage) {
return undefined;
}
input = lastUsage.input ?? 0;
output = lastUsage.output ?? 0;
promptTokens = derivePromptTokens(lastUsage) ?? lastUsage.total ?? input + output;
const total = lastUsage.total ?? promptTokens + output;
if (promptTokens === 0 && total === 0) {
return undefined;
}
return { input, output, promptTokens, total, model };
} catch {
return undefined;
}
};
2026-01-09 02:21:17 +00:00
const formatUsagePair = (input?: number | null, output?: number | null) => {
if (input == null && output == null) {
return null;
}
2026-01-09 02:21:17 +00:00
const inputLabel = typeof input === "number" ? formatTokenCount(input) : "?";
const outputLabel = typeof output === "number" ? formatTokenCount(output) : "?";
2026-01-09 03:14:39 +00:00
return `🧮 Tokens: ${inputLabel} in / ${outputLabel} out`;
2026-01-09 02:21:17 +00:00
};
2026-02-17 11:22:31 +09:00
const formatMediaUnderstandingLine = (decisions?: ReadonlyArray<MediaUnderstandingDecision>) => {
if (!decisions || decisions.length === 0) {
return null;
}
2026-01-17 07:17:09 +00:00
const parts = decisions
.map((decision) => {
const count = decision.attachments.length;
const countLabel = count > 1 ? ` x${count}` : "";
if (decision.outcome === "success") {
const chosen = decision.attachments.find((entry) => entry.chosen)?.chosen;
const provider = chosen?.provider?.trim();
const model = chosen?.model?.trim();
const modelLabel = provider ? (model ? `${provider}/${model}` : provider) : null;
return `${decision.capability}${countLabel} ok${modelLabel ? ` (${modelLabel})` : ""}`;
}
if (decision.outcome === "no-attachment") {
return `${decision.capability} none`;
}
if (decision.outcome === "disabled") {
return `${decision.capability} off`;
}
if (decision.outcome === "scope-deny") {
return `${decision.capability} denied`;
}
if (decision.outcome === "skipped") {
const reason = decision.attachments
.flatMap((entry) => entry.attempts.map((attempt) => attempt.reason).filter(Boolean))
.find(Boolean);
const shortReason = reason ? reason.split(":")[0]?.trim() : undefined;
return `${decision.capability} skipped${shortReason ? ` (${shortReason})` : ""}`;
}
return null;
})
2026-01-21 03:43:42 +00:00
.filter((part): part is string => part != null);
if (parts.length === 0) {
return null;
}
if (parts.every((part) => part.endsWith(" none"))) {
return null;
}
2026-01-17 07:17:09 +00:00
return `📎 Media: ${parts.join(" · ")}`;
};
const formatVoiceModeLine = (
2026-01-30 03:15:10 +01:00
config?: OpenClawConfig,
sessionEntry?: SessionEntry,
): string | null => {
if (!config) {
return null;
}
2026-01-24 10:03:19 +00:00
const ttsConfig = resolveTtsConfig(config);
const prefsPath = resolveTtsPrefsPath(ttsConfig);
const autoMode = resolveTtsAutoMode({
config: ttsConfig,
prefsPath,
sessionAuto: sessionEntry?.ttsAuto,
});
if (autoMode === "off") {
return null;
}
2026-01-24 10:03:19 +00:00
const provider = getTtsProvider(ttsConfig, prefsPath);
const maxLength = getTtsMaxLength(prefsPath);
const summarize = isSummarizationEnabled(prefsPath) ? "on" : "off";
return `🔊 Voice: ${autoMode} · provider=${provider} · limit=${maxLength} · summary=${summarize}`;
2026-01-24 10:03:19 +00:00
};
2025-12-07 16:53:19 +00:00
export function buildStatusMessage(args: StatusArgs): string {
2026-01-09 03:14:39 +00:00
const now = args.now ?? Date.now();
2025-12-07 16:53:19 +00:00
const entry = args.sessionEntry;
const resolved = resolveConfiguredModelRef({
cfg: {
agents: {
defaults: args.agent ?? {},
},
2026-01-30 03:15:10 +01:00
} as OpenClawConfig,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const selectedProvider = entry?.providerOverride ?? resolved.provider ?? DEFAULT_PROVIDER;
const selectedModel = entry?.modelOverride ?? resolved.model ?? DEFAULT_MODEL;
const modelRefs = resolveSelectedAndActiveModel({
selectedProvider,
selectedModel,
sessionEntry: entry,
});
let activeProvider = modelRefs.active.provider;
let activeModel = modelRefs.active.model;
let contextTokens =
2025-12-07 16:53:19 +00:00
entry?.contextTokens ??
2025-12-17 11:29:04 +01:00
args.agent?.contextTokens ??
lookupContextTokens(activeModel) ??
2025-12-07 16:53:19 +00:00
DEFAULT_CONTEXT_TOKENS;
2026-01-09 02:21:17 +00:00
let inputTokens = entry?.inputTokens;
let outputTokens = entry?.outputTokens;
let totalTokens = entry?.totalTokens ?? (entry?.inputTokens ?? 0) + (entry?.outputTokens ?? 0);
// Prefer prompt-size tokens from the session transcript when it looks larger
// (cached prompt tokens are often missing from agent meta/store).
2026-01-07 07:21:56 +01:00
if (args.includeTranscriptUsage) {
const logUsage = readUsageFromSessionLog(
entry?.sessionId,
entry,
args.agentId,
args.sessionKey,
args.sessionStorePath,
);
2026-01-07 07:21:56 +01:00
if (logUsage) {
const candidate = logUsage.promptTokens || logUsage.total;
if (!totalTokens || totalTokens === 0 || candidate > totalTokens) {
totalTokens = candidate;
}
if (!entry?.model && logUsage.model) {
const slashIndex = logUsage.model.indexOf("/");
if (slashIndex > 0) {
const provider = logUsage.model.slice(0, slashIndex).trim();
const model = logUsage.model.slice(slashIndex + 1).trim();
if (provider && model) {
activeProvider = provider;
activeModel = model;
}
} else {
activeModel = logUsage.model;
}
}
2026-01-07 07:21:56 +01:00
if (!contextTokens && logUsage.model) {
contextTokens = lookupContextTokens(logUsage.model) ?? contextTokens;
}
if (!inputTokens || inputTokens === 0) {
inputTokens = logUsage.input;
}
if (!outputTokens || outputTokens === 0) {
outputTokens = logUsage.output;
}
}
}
2025-12-07 16:53:19 +00:00
2025-12-17 11:29:04 +01:00
const thinkLevel = args.resolvedThink ?? args.agent?.thinkingDefault ?? "off";
const verboseLevel = args.resolvedVerbose ?? args.agent?.verboseDefault ?? "off";
const reasoningLevel = args.resolvedReasoning ?? "off";
2026-01-04 05:15:42 +00:00
const elevatedLevel =
2026-01-05 07:07:17 +01:00
args.resolvedElevated ??
2026-01-05 07:12:13 +01:00
args.sessionEntry?.elevatedLevel ??
args.agent?.elevatedDefault ??
"on";
2025-12-07 16:53:19 +00:00
const runtime = { label: resolveRuntimeLabel(args) };
2026-01-09 03:14:39 +00:00
const updatedAt = entry?.updatedAt;
const sessionLine = [
`Session: ${args.sessionKey ?? "unknown"}`,
typeof updatedAt === "number" ? `updated ${formatTimeAgo(now - updatedAt)}` : "no activity",
2026-01-09 03:14:39 +00:00
]
.filter(Boolean)
.join(" • ");
2026-01-02 10:14:58 +01:00
const isGroupSession =
entry?.chatType === "group" ||
2026-01-17 04:04:05 +00:00
entry?.chatType === "channel" ||
2026-01-02 10:14:58 +01:00
Boolean(args.sessionKey?.includes(":group:")) ||
2026-01-17 08:46:19 +00:00
Boolean(args.sessionKey?.includes(":channel:"));
2026-01-07 07:21:56 +01:00
const groupActivationValue = isGroupSession
? (args.groupActivation ?? entry?.groupActivation ?? "mention")
2025-12-22 20:45:22 +00:00
: undefined;
2025-12-22 20:36:29 +01:00
2026-01-09 03:14:39 +00:00
const contextLine = [
`Context: ${formatTokens(totalTokens, contextTokens ?? null)}`,
`🧹 Compactions: ${entry?.compactionCount ?? 0}`,
]
.filter(Boolean)
.join(" · ");
const queueMode = args.queue?.mode ?? "unknown";
const queueDetails = formatQueueDetails(args.queue);
2026-01-17 05:33:27 +00:00
const verboseLabel =
verboseLevel === "full" ? "verbose:full" : verboseLevel === "on" ? "verbose" : null;
2026-01-22 05:32:13 +00:00
const elevatedLabel =
elevatedLevel && elevatedLevel !== "off"
? elevatedLevel === "on"
? "elevated"
: `elevated:${elevatedLevel}`
: null;
2026-01-09 03:14:39 +00:00
const optionParts = [
`Runtime: ${runtime.label}`,
`Think: ${thinkLevel}`,
verboseLabel,
2026-01-09 03:14:39 +00:00
reasoningLevel !== "off" ? `Reasoning: ${reasoningLevel}` : null,
elevatedLabel,
2026-01-09 03:14:39 +00:00
];
const optionsLine = optionParts.filter(Boolean).join(" · ");
const activationParts = [
groupActivationValue ? `👥 Activation: ${groupActivationValue}` : null,
`🪢 Queue: ${queueMode}${queueDetails}`,
];
const activationLine = activationParts.filter(Boolean).join(" · ");
const activeAuthMode = resolveModelAuthMode(activeProvider, args.config);
const selectedAuthLabelValue =
args.modelAuth ??
(() => {
const selectedAuthMode = resolveModelAuthMode(selectedProvider, args.config);
return selectedAuthMode && selectedAuthMode !== "unknown" ? selectedAuthMode : undefined;
})();
const activeAuthLabelValue =
args.activeModelAuth ??
(activeAuthMode && activeAuthMode !== "unknown" ? activeAuthMode : undefined);
const showCost = activeAuthLabelValue === "api-key" || activeAuthLabelValue === "mixed";
2026-01-09 02:21:17 +00:00
const costConfig = showCost
? resolveModelCostConfig({
provider: activeProvider,
model: activeModel,
2026-01-09 02:21:17 +00:00
config: args.config,
})
: undefined;
const hasUsage = typeof inputTokens === "number" || typeof outputTokens === "number";
2026-01-09 02:21:17 +00:00
const cost =
showCost && hasUsage
? estimateUsageCost({
usage: {
input: inputTokens ?? undefined,
output: outputTokens ?? undefined,
},
cost: costConfig,
})
: undefined;
const costLabel = showCost && hasUsage ? formatUsd(cost) : undefined;
const selectedModelLabel = modelRefs.selected.label || "unknown";
const activeModelLabel = formatProviderModelRef(activeProvider, activeModel) || "unknown";
const fallbackState = resolveActiveFallbackState({
selectedModelRef: selectedModelLabel,
activeModelRef: activeModelLabel,
state: entry,
});
const selectedAuthLabel = selectedAuthLabelValue ? ` · 🔑 ${selectedAuthLabelValue}` : "";
const modelLine = `🧠 Model: ${selectedModelLabel}${selectedAuthLabel}`;
const showFallbackAuth = activeAuthLabelValue && activeAuthLabelValue !== selectedAuthLabelValue;
const fallbackLine = fallbackState.active
? `↪️ Fallback: ${activeModelLabel}${
showFallbackAuth ? ` · 🔑 ${activeAuthLabelValue}` : ""
} (${fallbackState.reason ?? "selected model unavailable"})`
: null;
2026-01-09 03:14:39 +00:00
const commit = resolveCommitHash();
2026-01-30 03:15:10 +01:00
const versionLine = `🦞 OpenClaw ${VERSION}${commit ? ` (${commit})` : ""}`;
2026-01-09 02:21:17 +00:00
const usagePair = formatUsagePair(inputTokens, outputTokens);
2026-01-09 03:14:39 +00:00
const costLine = costLabel ? `💵 Cost: ${costLabel}` : null;
const usageCostLine =
usagePair && costLine ? `${usagePair} · ${costLine}` : (usagePair ?? costLine);
2026-01-17 07:17:09 +00:00
const mediaLine = formatMediaUnderstandingLine(args.mediaDecisions);
const voiceLine = formatVoiceModeLine(args.config, args.sessionEntry);
2025-12-07 16:53:19 +00:00
2026-01-09 03:14:39 +00:00
return [
versionLine,
2026-01-24 06:22:54 +00:00
args.timeLine,
2026-01-09 03:14:39 +00:00
modelLine,
fallbackLine,
usageCostLine,
2026-01-09 03:14:39 +00:00
`📚 ${contextLine}`,
2026-01-17 07:17:09 +00:00
mediaLine,
2026-01-09 03:14:39 +00:00
args.usageLine,
`🧵 ${sessionLine}`,
args.subagentsLine,
2026-01-09 03:14:39 +00:00
`⚙️ ${optionsLine}`,
2026-01-24 10:03:19 +00:00
voiceLine,
2026-01-09 03:14:39 +00:00
activationLine,
]
.filter(Boolean)
.join("\n");
2026-01-05 07:07:17 +01:00
}
const CATEGORY_LABELS: Record<CommandCategory, string> = {
session: "Session",
options: "Options",
status: "Status",
management: "Management",
media: "Media",
tools: "Tools",
docks: "Docks",
};
const CATEGORY_ORDER: CommandCategory[] = [
"session",
"options",
"status",
"management",
"media",
"tools",
"docks",
];
function groupCommandsByCategory(
commands: ChatCommandDefinition[],
): Map<CommandCategory, ChatCommandDefinition[]> {
const grouped = new Map<CommandCategory, ChatCommandDefinition[]>();
for (const category of CATEGORY_ORDER) {
grouped.set(category, []);
}
for (const command of commands) {
const category = command.category ?? "tools";
const list = grouped.get(category) ?? [];
list.push(command);
grouped.set(category, list);
}
return grouped;
}
2026-01-30 03:15:10 +01:00
export function buildHelpMessage(cfg?: OpenClawConfig): string {
const lines = [" Help", ""];
lines.push("Session");
lines.push(" /new | /reset | /compact [instructions] | /stop");
lines.push("");
const optionParts = ["/think <level>", "/model <id>", "/verbose on|off"];
if (cfg?.commands?.config === true) {
optionParts.push("/config");
}
if (cfg?.commands?.debug === true) {
optionParts.push("/debug");
}
lines.push("Options");
lines.push(` ${optionParts.join(" | ")}`);
lines.push("");
lines.push("Status");
lines.push(" /status | /whoami | /context");
lines.push("");
lines.push("Skills");
lines.push(" /skill <name> [input]");
lines.push("");
lines.push("More: /commands for full list");
return lines.join("\n");
}
const COMMANDS_PER_PAGE = 8;
export type CommandsMessageOptions = {
page?: number;
surface?: string;
};
export type CommandsMessageResult = {
text: string;
totalPages: number;
currentPage: number;
hasNext: boolean;
hasPrev: boolean;
};
function formatCommandEntry(command: ChatCommandDefinition): string {
const primary = command.nativeName
? `/${command.nativeName}`
: command.textAliases[0]?.trim() || `/${command.key}`;
const seen = new Set<string>();
const aliases = command.textAliases
.map((alias) => alias.trim())
.filter(Boolean)
.filter((alias) => alias.toLowerCase() !== primary.toLowerCase())
.filter((alias) => {
const key = alias.toLowerCase();
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
const aliasLabel = aliases.length ? ` (${aliases.join(", ")})` : "";
const scopeLabel = command.scope === "text" ? " [text]" : "";
return `${primary}${aliasLabel}${scopeLabel} - ${command.description}`;
2025-12-07 16:53:19 +00:00
}
type CommandsListItem = {
label: string;
text: string;
};
function buildCommandItems(
commands: ChatCommandDefinition[],
pluginCommands: ReturnType<typeof listPluginCommands>,
): CommandsListItem[] {
const grouped = groupCommandsByCategory(commands);
const items: CommandsListItem[] = [];
for (const category of CATEGORY_ORDER) {
const categoryCommands = grouped.get(category) ?? [];
if (categoryCommands.length === 0) {
continue;
}
const label = CATEGORY_LABELS[category];
for (const command of categoryCommands) {
items.push({ label, text: formatCommandEntry(command) });
}
}
for (const command of pluginCommands) {
const pluginLabel = command.pluginId ? ` (${command.pluginId})` : "";
items.push({
label: "Plugins",
text: `/${command.name}${pluginLabel} - ${command.description}`,
});
}
return items;
}
function formatCommandList(items: CommandsListItem[]): string {
const lines: string[] = [];
let currentLabel: string | null = null;
for (const item of items) {
if (item.label !== currentLabel) {
if (lines.length > 0) {
lines.push("");
}
lines.push(item.label);
currentLabel = item.label;
}
lines.push(` ${item.text}`);
}
return lines.join("\n");
}
export function buildCommandsMessage(
2026-01-30 03:15:10 +01:00
cfg?: OpenClawConfig,
skillCommands?: SkillCommandSpec[],
options?: CommandsMessageOptions,
): string {
const result = buildCommandsMessagePaginated(cfg, skillCommands, options);
return result.text;
}
export function buildCommandsMessagePaginated(
2026-01-30 03:15:10 +01:00
cfg?: OpenClawConfig,
skillCommands?: SkillCommandSpec[],
options?: CommandsMessageOptions,
): CommandsMessageResult {
const page = Math.max(1, options?.page ?? 1);
const surface = options?.surface?.toLowerCase();
const isTelegram = surface === "telegram";
const commands = cfg
? listChatCommandsForConfig(cfg, { skillCommands })
: listChatCommands({ skillCommands });
const pluginCommands = listPluginCommands();
const items = buildCommandItems(commands, pluginCommands);
if (!isTelegram) {
const lines = [" Slash commands", ""];
lines.push(formatCommandList(items));
return {
text: lines.join("\n").trim(),
totalPages: 1,
currentPage: 1,
hasNext: false,
hasPrev: false,
};
}
const totalCommands = items.length;
const totalPages = Math.max(1, Math.ceil(totalCommands / COMMANDS_PER_PAGE));
const currentPage = Math.min(page, totalPages);
const startIndex = (currentPage - 1) * COMMANDS_PER_PAGE;
const endIndex = startIndex + COMMANDS_PER_PAGE;
const pageItems = items.slice(startIndex, endIndex);
const lines = [` Commands (${currentPage}/${totalPages})`, ""];
lines.push(formatCommandList(pageItems));
return {
text: lines.join("\n").trim(),
totalPages,
currentPage,
hasNext: currentPage < totalPages,
hasPrev: currentPage > 1,
};
}