mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-29 19:01:52 +03:00
chore: Enable "curly" rule to avoid single-statement if confusion/errors.
This commit is contained in:
@@ -15,7 +15,9 @@ function expectFencesBalanced(chunks: string[]) {
|
||||
let open: { markerChar: string; markerLen: number } | null = null;
|
||||
for (const line of chunk.split("\n")) {
|
||||
const match = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/);
|
||||
if (!match) continue;
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const marker = match[2];
|
||||
if (!open) {
|
||||
open = { markerChar: marker[0], markerLen: marker.length };
|
||||
|
||||
+91
-31
@@ -32,7 +32,9 @@ function resolveChunkLimitForProvider(
|
||||
cfgSection: ProviderChunkConfig | undefined,
|
||||
accountId?: string | null,
|
||||
): number | undefined {
|
||||
if (!cfgSection) return undefined;
|
||||
if (!cfgSection) {
|
||||
return undefined;
|
||||
}
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const accounts = cfgSection.accounts;
|
||||
if (accounts && typeof accounts === "object") {
|
||||
@@ -62,7 +64,9 @@ export function resolveTextChunkLimit(
|
||||
? opts.fallbackLimit
|
||||
: DEFAULT_CHUNK_LIMIT;
|
||||
const providerOverride = (() => {
|
||||
if (!provider || provider === INTERNAL_MESSAGE_CHANNEL) return undefined;
|
||||
if (!provider || provider === INTERNAL_MESSAGE_CHANNEL) {
|
||||
return undefined;
|
||||
}
|
||||
const channelsConfig = cfg?.channels as Record<string, unknown> | undefined;
|
||||
const providerConfig = (channelsConfig?.[provider] ??
|
||||
(cfg as Record<string, unknown> | undefined)?.[provider]) as ProviderChunkConfig | undefined;
|
||||
@@ -78,7 +82,9 @@ function resolveChunkModeForProvider(
|
||||
cfgSection: ProviderChunkConfig | undefined,
|
||||
accountId?: string | null,
|
||||
): ChunkMode | undefined {
|
||||
if (!cfgSection) return undefined;
|
||||
if (!cfgSection) {
|
||||
return undefined;
|
||||
}
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const accounts = cfgSection.accounts;
|
||||
if (accounts && typeof accounts === "object") {
|
||||
@@ -102,7 +108,9 @@ export function resolveChunkMode(
|
||||
provider?: TextChunkProvider,
|
||||
accountId?: string | null,
|
||||
): ChunkMode {
|
||||
if (!provider || provider === INTERNAL_MESSAGE_CHANNEL) return DEFAULT_CHUNK_MODE;
|
||||
if (!provider || provider === INTERNAL_MESSAGE_CHANNEL) {
|
||||
return DEFAULT_CHUNK_MODE;
|
||||
}
|
||||
const channelsConfig = cfg?.channels as Record<string, unknown> | undefined;
|
||||
const providerConfig = (channelsConfig?.[provider] ??
|
||||
(cfg as Record<string, unknown> | undefined)?.[provider]) as ProviderChunkConfig | undefined;
|
||||
@@ -124,8 +132,12 @@ export function chunkByNewline(
|
||||
isSafeBreak?: (index: number) => boolean;
|
||||
},
|
||||
): string[] {
|
||||
if (!text) return [];
|
||||
if (maxLineLength <= 0) return text.trim() ? [text] : [];
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
if (maxLineLength <= 0) {
|
||||
return text.trim() ? [text] : [];
|
||||
}
|
||||
const splitLongLines = opts?.splitLongLines !== false;
|
||||
const trimLines = opts?.trimLines !== false;
|
||||
const lines = splitByNewline(text, opts?.isSafeBreak);
|
||||
@@ -180,8 +192,12 @@ export function chunkByParagraph(
|
||||
limit: number,
|
||||
opts?: { splitLongParagraphs?: boolean },
|
||||
): string[] {
|
||||
if (!text) return [];
|
||||
if (limit <= 0) return [text];
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
if (limit <= 0) {
|
||||
return [text];
|
||||
}
|
||||
const splitLongParagraphs = opts?.splitLongParagraphs !== false;
|
||||
|
||||
// Normalize to \n so blank line detection is consistent.
|
||||
@@ -192,8 +208,12 @@ export function chunkByParagraph(
|
||||
// boundaries, not only exceeding a length limit.)
|
||||
const paragraphRe = /\n[\t ]*\n+/;
|
||||
if (!paragraphRe.test(normalized)) {
|
||||
if (normalized.length <= limit) return [normalized];
|
||||
if (!splitLongParagraphs) return [normalized];
|
||||
if (normalized.length <= limit) {
|
||||
return [normalized];
|
||||
}
|
||||
if (!splitLongParagraphs) {
|
||||
return [normalized];
|
||||
}
|
||||
return chunkText(normalized, limit);
|
||||
}
|
||||
|
||||
@@ -218,7 +238,9 @@ export function chunkByParagraph(
|
||||
const chunks: string[] = [];
|
||||
for (const part of parts) {
|
||||
const paragraph = part.replace(/\s+$/g, "");
|
||||
if (!paragraph.trim()) continue;
|
||||
if (!paragraph.trim()) {
|
||||
continue;
|
||||
}
|
||||
if (paragraph.length <= limit) {
|
||||
chunks.push(paragraph);
|
||||
} else if (!splitLongParagraphs) {
|
||||
@@ -249,8 +271,11 @@ export function chunkMarkdownTextWithMode(text: string, limit: number, mode: Chu
|
||||
const out: string[] = [];
|
||||
for (const chunk of paragraphChunks) {
|
||||
const nested = chunkMarkdownText(chunk, limit);
|
||||
if (!nested.length && chunk) out.push(chunk);
|
||||
else out.push(...nested);
|
||||
if (!nested.length && chunk) {
|
||||
out.push(chunk);
|
||||
} else {
|
||||
out.push(...nested);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -274,9 +299,15 @@ function splitByNewline(
|
||||
}
|
||||
|
||||
export function chunkText(text: string, limit: number): string[] {
|
||||
if (!text) return [];
|
||||
if (limit <= 0) return [text];
|
||||
if (text.length <= limit) return [text];
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
if (limit <= 0) {
|
||||
return [text];
|
||||
}
|
||||
if (text.length <= limit) {
|
||||
return [text];
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
@@ -291,7 +322,9 @@ export function chunkText(text: string, limit: number): string[] {
|
||||
let breakIdx = lastNewline > 0 ? lastNewline : lastWhitespace;
|
||||
|
||||
// 3) Fallback: hard break exactly at the limit.
|
||||
if (breakIdx <= 0) breakIdx = limit;
|
||||
if (breakIdx <= 0) {
|
||||
breakIdx = limit;
|
||||
}
|
||||
|
||||
const rawChunk = remaining.slice(0, breakIdx);
|
||||
const chunk = rawChunk.trimEnd();
|
||||
@@ -305,15 +338,23 @@ export function chunkText(text: string, limit: number): string[] {
|
||||
remaining = remaining.slice(nextStart).trimStart();
|
||||
}
|
||||
|
||||
if (remaining.length) chunks.push(remaining);
|
||||
if (remaining.length) {
|
||||
chunks.push(remaining);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export function chunkMarkdownText(text: string, limit: number): string[] {
|
||||
if (!text) return [];
|
||||
if (limit <= 0) return [text];
|
||||
if (text.length <= limit) return [text];
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
if (limit <= 0) {
|
||||
return [text];
|
||||
}
|
||||
if (text.length <= limit) {
|
||||
return [text];
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
@@ -348,7 +389,9 @@ export function chunkMarkdownText(text: string, limit: number): string[] {
|
||||
let lastNewline = remaining.lastIndexOf("\n", Math.max(0, maxIdxIfAlreadyNewline - 1));
|
||||
while (lastNewline !== -1) {
|
||||
const candidateBreak = lastNewline + 1;
|
||||
if (candidateBreak < minProgressIdx) break;
|
||||
if (candidateBreak < minProgressIdx) {
|
||||
break;
|
||||
}
|
||||
const candidateFence = findFenceSpanAt(spans, candidateBreak);
|
||||
if (candidateFence && candidateFence.start === initialFence.start) {
|
||||
breakIdx = Math.max(1, candidateBreak);
|
||||
@@ -374,7 +417,9 @@ export function chunkMarkdownText(text: string, limit: number): string[] {
|
||||
}
|
||||
|
||||
let rawChunk = remaining.slice(0, breakIdx);
|
||||
if (!rawChunk) break;
|
||||
if (!rawChunk) {
|
||||
break;
|
||||
}
|
||||
|
||||
const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
|
||||
const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0));
|
||||
@@ -392,13 +437,17 @@ export function chunkMarkdownText(text: string, limit: number): string[] {
|
||||
remaining = next;
|
||||
}
|
||||
|
||||
if (remaining.length) chunks.push(remaining);
|
||||
if (remaining.length) {
|
||||
chunks.push(remaining);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function stripLeadingNewlines(value: string): string {
|
||||
let i = 0;
|
||||
while (i < value.length && value[i] === "\n") i++;
|
||||
while (i < value.length && value[i] === "\n") {
|
||||
i++;
|
||||
}
|
||||
return i > 0 ? value.slice(i) : value;
|
||||
}
|
||||
|
||||
@@ -407,8 +456,12 @@ function pickSafeBreakIndex(window: string, spans: ReturnType<typeof parseFenceS
|
||||
isSafeFenceBreak(spans, index),
|
||||
);
|
||||
|
||||
if (lastNewline > 0) return lastNewline;
|
||||
if (lastWhitespace > 0) return lastWhitespace;
|
||||
if (lastNewline > 0) {
|
||||
return lastNewline;
|
||||
}
|
||||
if (lastWhitespace > 0) {
|
||||
return lastWhitespace;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -421,7 +474,9 @@ function scanParenAwareBreakpoints(
|
||||
let depth = 0;
|
||||
|
||||
for (let i = 0; i < window.length; i++) {
|
||||
if (!isAllowed(i)) continue;
|
||||
if (!isAllowed(i)) {
|
||||
continue;
|
||||
}
|
||||
const char = window[i];
|
||||
if (char === "(") {
|
||||
depth += 1;
|
||||
@@ -431,9 +486,14 @@ function scanParenAwareBreakpoints(
|
||||
depth -= 1;
|
||||
continue;
|
||||
}
|
||||
if (depth !== 0) continue;
|
||||
if (char === "\n") lastNewline = i;
|
||||
else if (/\s/.test(char)) lastWhitespace = i;
|
||||
if (depth !== 0) {
|
||||
continue;
|
||||
}
|
||||
if (char === "\n") {
|
||||
lastNewline = i;
|
||||
} else if (/\s/.test(char)) {
|
||||
lastWhitespace = i;
|
||||
}
|
||||
}
|
||||
|
||||
return { lastNewline, lastWhitespace };
|
||||
|
||||
@@ -19,26 +19,36 @@ function resolveProviderFromContext(ctx: MsgContext, cfg: OpenClawConfig): Chann
|
||||
normalizeAnyChannelId(ctx.Provider) ??
|
||||
normalizeAnyChannelId(ctx.Surface) ??
|
||||
normalizeAnyChannelId(ctx.OriginatingChannel);
|
||||
if (direct) return direct;
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const candidates = [ctx.From, ctx.To]
|
||||
.filter((value): value is string => Boolean(value?.trim()))
|
||||
.flatMap((value) => value.split(":").map((part) => part.trim()));
|
||||
for (const candidate of candidates) {
|
||||
const normalized = normalizeAnyChannelId(candidate);
|
||||
if (normalized) return normalized;
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
const configured = listChannelDocks()
|
||||
.map((dock) => {
|
||||
if (!dock.config?.resolveAllowFrom) return null;
|
||||
if (!dock.config?.resolveAllowFrom) {
|
||||
return null;
|
||||
}
|
||||
const allowFrom = dock.config.resolveAllowFrom({
|
||||
cfg,
|
||||
accountId: ctx.AccountId,
|
||||
});
|
||||
if (!Array.isArray(allowFrom) || allowFrom.length === 0) return null;
|
||||
if (!Array.isArray(allowFrom) || allowFrom.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return dock.id;
|
||||
})
|
||||
.filter((value): value is ChannelId => Boolean(value));
|
||||
if (configured.length === 1) return configured[0];
|
||||
if (configured.length === 1) {
|
||||
return configured[0];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -49,7 +59,9 @@ function formatAllowFromList(params: {
|
||||
allowFrom: Array<string | number>;
|
||||
}): string[] {
|
||||
const { dock, cfg, accountId, allowFrom } = params;
|
||||
if (!allowFrom || allowFrom.length === 0) return [];
|
||||
if (!allowFrom || allowFrom.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (dock?.config?.formatAllowFrom) {
|
||||
return dock.config.formatAllowFrom({ cfg, accountId, allowFrom });
|
||||
}
|
||||
@@ -84,7 +96,9 @@ function resolveSenderCandidates(params: {
|
||||
const candidates: string[] = [];
|
||||
const pushCandidate = (value?: string | null) => {
|
||||
const trimmed = (value ?? "").trim();
|
||||
if (!trimmed) return;
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
candidates.push(trimmed);
|
||||
};
|
||||
if (params.providerId === "whatsapp") {
|
||||
@@ -100,7 +114,9 @@ function resolveSenderCandidates(params: {
|
||||
for (const sender of candidates) {
|
||||
const entries = normalizeAllowFromEntry({ dock, cfg, accountId, value: sender });
|
||||
for (const entry of entries) {
|
||||
if (!normalized.includes(entry)) normalized.push(entry);
|
||||
if (!normalized.includes(entry)) {
|
||||
normalized.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
@@ -136,7 +152,9 @@ export function resolveCommandAuthorization(params: {
|
||||
accountId: ctx.AccountId,
|
||||
value: to,
|
||||
});
|
||||
if (normalizedTo.length > 0) ownerCandidates.push(...normalizedTo);
|
||||
if (normalizedTo.length > 0) {
|
||||
ownerCandidates.push(...normalizedTo);
|
||||
}
|
||||
}
|
||||
const ownerList = Array.from(new Set(ownerCandidates));
|
||||
|
||||
|
||||
@@ -12,21 +12,33 @@ export function hasControlCommand(
|
||||
cfg?: OpenClawConfig,
|
||||
options?: CommandNormalizeOptions,
|
||||
): boolean {
|
||||
if (!text) return false;
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return false;
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
const normalizedBody = normalizeCommandBody(trimmed, options);
|
||||
if (!normalizedBody) return false;
|
||||
if (!normalizedBody) {
|
||||
return false;
|
||||
}
|
||||
const lowered = normalizedBody.toLowerCase();
|
||||
const commands = cfg ? listChatCommandsForConfig(cfg) : listChatCommands();
|
||||
for (const command of commands) {
|
||||
for (const alias of command.textAliases) {
|
||||
const normalized = alias.trim().toLowerCase();
|
||||
if (!normalized) continue;
|
||||
if (lowered === normalized) return true;
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
if (lowered === normalized) {
|
||||
return true;
|
||||
}
|
||||
if (command.acceptsArgs && lowered.startsWith(normalized)) {
|
||||
const nextChar = normalizedBody.charAt(normalized.length);
|
||||
if (nextChar && /\s/.test(nextChar)) return true;
|
||||
if (nextChar && /\s/.test(nextChar)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,10 +50,16 @@ export function isControlCommandMessage(
|
||||
cfg?: OpenClawConfig,
|
||||
options?: CommandNormalizeOptions,
|
||||
): boolean {
|
||||
if (!text) return false;
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return false;
|
||||
if (hasControlCommand(trimmed, cfg, options)) return true;
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
if (hasControlCommand(trimmed, cfg, options)) {
|
||||
return true;
|
||||
}
|
||||
const normalized = normalizeCommandBody(trimmed, options).trim().toLowerCase();
|
||||
return isAbortTrigger(normalized);
|
||||
}
|
||||
@@ -55,7 +73,9 @@ export function isControlCommandMessage(
|
||||
*/
|
||||
export function hasInlineCommandTokens(text?: string): boolean {
|
||||
const body = text ?? "";
|
||||
if (!body.trim()) return false;
|
||||
if (!body.trim()) {
|
||||
return false;
|
||||
}
|
||||
return /(?:^|\s)[/!][a-z]/i.test(body);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ import type { CommandArgValues } from "./commands-registry.types.js";
|
||||
export type CommandArgsFormatter = (values: CommandArgValues) => string | undefined;
|
||||
|
||||
function normalizeArgValue(value: unknown): string | undefined {
|
||||
if (value == null) return undefined;
|
||||
if (value == null) {
|
||||
return undefined;
|
||||
}
|
||||
let text: string;
|
||||
if (typeof value === "string") {
|
||||
text = value.trim();
|
||||
@@ -24,7 +26,9 @@ const formatConfigArgs: CommandArgsFormatter = (values) => {
|
||||
const action = normalizeArgValue(values.action)?.toLowerCase();
|
||||
const path = normalizeArgValue(values.path);
|
||||
const value = normalizeArgValue(values.value);
|
||||
if (!action) return undefined;
|
||||
if (!action) {
|
||||
return undefined;
|
||||
}
|
||||
if (action === "show" || action === "get") {
|
||||
return path ? `${action} ${path}` : action;
|
||||
}
|
||||
@@ -32,8 +36,12 @@ const formatConfigArgs: CommandArgsFormatter = (values) => {
|
||||
return path ? `${action} ${path}` : action;
|
||||
}
|
||||
if (action === "set") {
|
||||
if (!path) return action;
|
||||
if (!value) return `${action} ${path}`;
|
||||
if (!path) {
|
||||
return action;
|
||||
}
|
||||
if (!value) {
|
||||
return `${action} ${path}`;
|
||||
}
|
||||
return `${action} ${path}=${value}`;
|
||||
}
|
||||
return action;
|
||||
@@ -43,7 +51,9 @@ const formatDebugArgs: CommandArgsFormatter = (values) => {
|
||||
const action = normalizeArgValue(values.action)?.toLowerCase();
|
||||
const path = normalizeArgValue(values.path);
|
||||
const value = normalizeArgValue(values.value);
|
||||
if (!action) return undefined;
|
||||
if (!action) {
|
||||
return undefined;
|
||||
}
|
||||
if (action === "show" || action === "reset") {
|
||||
return action;
|
||||
}
|
||||
@@ -51,8 +61,12 @@ const formatDebugArgs: CommandArgsFormatter = (values) => {
|
||||
return path ? `${action} ${path}` : action;
|
||||
}
|
||||
if (action === "set") {
|
||||
if (!path) return action;
|
||||
if (!value) return `${action} ${path}`;
|
||||
if (!path) {
|
||||
return action;
|
||||
}
|
||||
if (!value) {
|
||||
return `${action} ${path}`;
|
||||
}
|
||||
return `${action} ${path}=${value}`;
|
||||
}
|
||||
return action;
|
||||
@@ -64,10 +78,18 @@ const formatQueueArgs: CommandArgsFormatter = (values) => {
|
||||
const cap = normalizeArgValue(values.cap);
|
||||
const drop = normalizeArgValue(values.drop);
|
||||
const parts: string[] = [];
|
||||
if (mode) parts.push(mode);
|
||||
if (debounce) parts.push(`debounce:${debounce}`);
|
||||
if (cap) parts.push(`cap:${cap}`);
|
||||
if (drop) parts.push(`drop:${drop}`);
|
||||
if (mode) {
|
||||
parts.push(mode);
|
||||
}
|
||||
if (debounce) {
|
||||
parts.push(`debounce:${debounce}`);
|
||||
}
|
||||
if (cap) {
|
||||
parts.push(`cap:${cap}`);
|
||||
}
|
||||
if (drop) {
|
||||
parts.push(`drop:${drop}`);
|
||||
}
|
||||
return parts.length > 0 ? parts.join(" ") : undefined;
|
||||
};
|
||||
|
||||
|
||||
@@ -66,9 +66,13 @@ function registerAlias(commands: ChatCommandDefinition[], key: string, ...aliase
|
||||
const existing = new Set(command.textAliases.map((alias) => alias.trim().toLowerCase()));
|
||||
for (const alias of aliases) {
|
||||
const trimmed = alias.trim();
|
||||
if (!trimmed) continue;
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
const lowered = trimmed.toLowerCase();
|
||||
if (existing.has(lowered)) continue;
|
||||
if (existing.has(lowered)) {
|
||||
continue;
|
||||
}
|
||||
existing.add(lowered);
|
||||
command.textAliases.push(trimmed);
|
||||
}
|
||||
@@ -585,7 +589,9 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
||||
|
||||
export function getChatCommands(): ChatCommandDefinition[] {
|
||||
const registry = getActivePluginRegistry();
|
||||
if (cachedCommands && registry === cachedRegistry) return cachedCommands;
|
||||
if (cachedCommands && registry === cachedRegistry) {
|
||||
return cachedCommands;
|
||||
}
|
||||
const commands = buildChatCommands();
|
||||
cachedCommands = commands;
|
||||
cachedRegistry = registry;
|
||||
|
||||
@@ -43,7 +43,9 @@ let cachedDetectionCommands: ChatCommandDefinition[] | null = null;
|
||||
|
||||
function getTextAliasMap(): Map<string, TextAliasSpec> {
|
||||
const commands = getChatCommands();
|
||||
if (cachedTextAliasMap && cachedTextAliasCommands === commands) return cachedTextAliasMap;
|
||||
if (cachedTextAliasMap && cachedTextAliasCommands === commands) {
|
||||
return cachedTextAliasMap;
|
||||
}
|
||||
const map = new Map<string, TextAliasSpec>();
|
||||
for (const command of commands) {
|
||||
// Canonicalize to the *primary* text alias, not `/${key}`. Some command keys are
|
||||
@@ -53,7 +55,9 @@ function getTextAliasMap(): Map<string, TextAliasSpec> {
|
||||
const acceptsArgs = Boolean(command.acceptsArgs);
|
||||
for (const alias of command.textAliases) {
|
||||
const normalized = alias.trim().toLowerCase();
|
||||
if (!normalized) continue;
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
if (!map.has(normalized)) {
|
||||
map.set(normalized, { key: command.key, canonical, acceptsArgs });
|
||||
}
|
||||
@@ -69,7 +73,9 @@ function escapeRegExp(value: string) {
|
||||
}
|
||||
|
||||
function buildSkillCommandDefinitions(skillCommands?: SkillCommandSpec[]): ChatCommandDefinition[] {
|
||||
if (!skillCommands || skillCommands.length === 0) return [];
|
||||
if (!skillCommands || skillCommands.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return skillCommands.map((spec) => ({
|
||||
key: `skill:${spec.skillName}`,
|
||||
nativeName: spec.name,
|
||||
@@ -85,14 +91,22 @@ export function listChatCommands(params?: {
|
||||
skillCommands?: SkillCommandSpec[];
|
||||
}): ChatCommandDefinition[] {
|
||||
const commands = getChatCommands();
|
||||
if (!params?.skillCommands?.length) return [...commands];
|
||||
if (!params?.skillCommands?.length) {
|
||||
return [...commands];
|
||||
}
|
||||
return [...commands, ...buildSkillCommandDefinitions(params.skillCommands)];
|
||||
}
|
||||
|
||||
export function isCommandEnabled(cfg: OpenClawConfig, commandKey: string): boolean {
|
||||
if (commandKey === "config") return cfg.commands?.config === true;
|
||||
if (commandKey === "debug") return cfg.commands?.debug === true;
|
||||
if (commandKey === "bash") return cfg.commands?.bash === true;
|
||||
if (commandKey === "config") {
|
||||
return cfg.commands?.config === true;
|
||||
}
|
||||
if (commandKey === "debug") {
|
||||
return cfg.commands?.debug === true;
|
||||
}
|
||||
if (commandKey === "bash") {
|
||||
return cfg.commands?.bash === true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -101,7 +115,9 @@ export function listChatCommandsForConfig(
|
||||
params?: { skillCommands?: SkillCommandSpec[] },
|
||||
): ChatCommandDefinition[] {
|
||||
const base = getChatCommands().filter((command) => isCommandEnabled(cfg, command.key));
|
||||
if (!params?.skillCommands?.length) return base;
|
||||
if (!params?.skillCommands?.length) {
|
||||
return base;
|
||||
}
|
||||
return [...base, ...buildSkillCommandDefinitions(params.skillCommands)];
|
||||
}
|
||||
|
||||
@@ -112,10 +128,14 @@ const NATIVE_NAME_OVERRIDES: Record<string, Record<string, string>> = {
|
||||
};
|
||||
|
||||
function resolveNativeName(command: ChatCommandDefinition, provider?: string): string | undefined {
|
||||
if (!command.nativeName) return undefined;
|
||||
if (!command.nativeName) {
|
||||
return undefined;
|
||||
}
|
||||
if (provider) {
|
||||
const override = NATIVE_NAME_OVERRIDES[provider]?.[command.key];
|
||||
if (override) return override;
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
}
|
||||
return command.nativeName;
|
||||
}
|
||||
@@ -168,11 +188,15 @@ export function buildCommandText(commandName: string, args?: string): string {
|
||||
function parsePositionalArgs(definitions: CommandArgDefinition[], raw: string): CommandArgValues {
|
||||
const values: CommandArgValues = {};
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return values;
|
||||
if (!trimmed) {
|
||||
return values;
|
||||
}
|
||||
const tokens = trimmed.split(/\s+/).filter(Boolean);
|
||||
let index = 0;
|
||||
for (const definition of definitions) {
|
||||
if (index >= tokens.length) break;
|
||||
if (index >= tokens.length) {
|
||||
break;
|
||||
}
|
||||
if (definition.captureRemaining) {
|
||||
values[definition.name] = tokens.slice(index).join(" ");
|
||||
index = tokens.length;
|
||||
@@ -191,16 +215,22 @@ function formatPositionalArgs(
|
||||
const parts: string[] = [];
|
||||
for (const definition of definitions) {
|
||||
const value = values[definition.name];
|
||||
if (value == null) continue;
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
let rendered: string;
|
||||
if (typeof value === "string") {
|
||||
rendered = value.trim();
|
||||
} else {
|
||||
rendered = String(value);
|
||||
}
|
||||
if (!rendered) continue;
|
||||
if (!rendered) {
|
||||
continue;
|
||||
}
|
||||
parts.push(rendered);
|
||||
if (definition.captureRemaining) break;
|
||||
if (definition.captureRemaining) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? parts.join(" ") : undefined;
|
||||
}
|
||||
@@ -210,7 +240,9 @@ export function parseCommandArgs(
|
||||
raw?: string,
|
||||
): CommandArgs | undefined {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
if (!command.args || command.argsParsing === "none") {
|
||||
return { raw: trimmed };
|
||||
}
|
||||
@@ -224,11 +256,19 @@ export function serializeCommandArgs(
|
||||
command: ChatCommandDefinition,
|
||||
args?: CommandArgs,
|
||||
): string | undefined {
|
||||
if (!args) return undefined;
|
||||
if (!args) {
|
||||
return undefined;
|
||||
}
|
||||
const raw = args.raw?.trim();
|
||||
if (raw) return raw;
|
||||
if (!args.values || !command.args) return undefined;
|
||||
if (command.formatArgs) return command.formatArgs(args.values);
|
||||
if (raw) {
|
||||
return raw;
|
||||
}
|
||||
if (!args.values || !command.args) {
|
||||
return undefined;
|
||||
}
|
||||
if (command.formatArgs) {
|
||||
return command.formatArgs(args.values);
|
||||
}
|
||||
return formatPositionalArgs(command.args, args.values);
|
||||
}
|
||||
|
||||
@@ -265,7 +305,9 @@ export function resolveCommandArgChoices(params: {
|
||||
model?: string;
|
||||
}): ResolvedCommandArgChoice[] {
|
||||
const { command, arg, cfg } = params;
|
||||
if (!arg.choices) return [];
|
||||
if (!arg.choices) {
|
||||
return [];
|
||||
}
|
||||
const provided = arg.choices;
|
||||
const raw = Array.isArray(provided)
|
||||
? provided
|
||||
@@ -291,27 +333,43 @@ export function resolveCommandArgMenu(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
}): { arg: CommandArgDefinition; choices: ResolvedCommandArgChoice[]; title?: string } | null {
|
||||
const { command, args, cfg } = params;
|
||||
if (!command.args || !command.argsMenu) return null;
|
||||
if (command.argsParsing === "none") return null;
|
||||
if (!command.args || !command.argsMenu) {
|
||||
return null;
|
||||
}
|
||||
if (command.argsParsing === "none") {
|
||||
return null;
|
||||
}
|
||||
const argSpec = command.argsMenu;
|
||||
const argName =
|
||||
argSpec === "auto"
|
||||
? command.args.find((arg) => resolveCommandArgChoices({ command, arg, cfg }).length > 0)?.name
|
||||
: argSpec.arg;
|
||||
if (!argName) return null;
|
||||
if (args?.values && args.values[argName] != null) return null;
|
||||
if (args?.raw && !args.values) return null;
|
||||
if (!argName) {
|
||||
return null;
|
||||
}
|
||||
if (args?.values && args.values[argName] != null) {
|
||||
return null;
|
||||
}
|
||||
if (args?.raw && !args.values) {
|
||||
return null;
|
||||
}
|
||||
const arg = command.args.find((entry) => entry.name === argName);
|
||||
if (!arg) return null;
|
||||
if (!arg) {
|
||||
return null;
|
||||
}
|
||||
const choices = resolveCommandArgChoices({ command, arg, cfg });
|
||||
if (choices.length === 0) return null;
|
||||
if (choices.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const title = argSpec !== "auto" ? argSpec.title : undefined;
|
||||
return { arg, choices, title };
|
||||
}
|
||||
|
||||
export function normalizeCommandBody(raw: string, options?: CommandNormalizeOptions): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.startsWith("/")) return trimmed;
|
||||
if (!trimmed.startsWith("/")) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const newline = trimmed.indexOf("\n");
|
||||
const singleLine = newline === -1 ? trimmed : trimmed.slice(0, newline).trim();
|
||||
@@ -337,15 +395,23 @@ export function normalizeCommandBody(raw: string, options?: CommandNormalizeOpti
|
||||
const lowered = commandBody.toLowerCase();
|
||||
const textAliasMap = getTextAliasMap();
|
||||
const exact = textAliasMap.get(lowered);
|
||||
if (exact) return exact.canonical;
|
||||
if (exact) {
|
||||
return exact.canonical;
|
||||
}
|
||||
|
||||
const tokenMatch = commandBody.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/);
|
||||
if (!tokenMatch) return commandBody;
|
||||
if (!tokenMatch) {
|
||||
return commandBody;
|
||||
}
|
||||
const [, token, rest] = tokenMatch;
|
||||
const tokenKey = `/${token.toLowerCase()}`;
|
||||
const tokenSpec = textAliasMap.get(tokenKey);
|
||||
if (!tokenSpec) return commandBody;
|
||||
if (rest && !tokenSpec.acceptsArgs) return commandBody;
|
||||
if (!tokenSpec) {
|
||||
return commandBody;
|
||||
}
|
||||
if (rest && !tokenSpec.acceptsArgs) {
|
||||
return commandBody;
|
||||
}
|
||||
const normalizedRest = rest?.trimStart();
|
||||
return normalizedRest ? `${tokenSpec.canonical} ${normalizedRest}` : tokenSpec.canonical;
|
||||
}
|
||||
@@ -357,16 +423,22 @@ export function isCommandMessage(raw: string): boolean {
|
||||
|
||||
export function getCommandDetection(_cfg?: OpenClawConfig): CommandDetection {
|
||||
const commands = getChatCommands();
|
||||
if (cachedDetection && cachedDetectionCommands === commands) return cachedDetection;
|
||||
if (cachedDetection && cachedDetectionCommands === commands) {
|
||||
return cachedDetection;
|
||||
}
|
||||
const exact = new Set<string>();
|
||||
const patterns: string[] = [];
|
||||
for (const cmd of commands) {
|
||||
for (const alias of cmd.textAliases) {
|
||||
const normalized = alias.trim().toLowerCase();
|
||||
if (!normalized) continue;
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
exact.add(normalized);
|
||||
const escaped = escapeRegExp(normalized);
|
||||
if (!escaped) continue;
|
||||
if (!escaped) {
|
||||
continue;
|
||||
}
|
||||
if (cmd.acceptsArgs) {
|
||||
patterns.push(`${escaped}(?:\\s+.+|\\s*:\\s*.*)?`);
|
||||
} else {
|
||||
@@ -384,13 +456,21 @@ export function getCommandDetection(_cfg?: OpenClawConfig): CommandDetection {
|
||||
|
||||
export function maybeResolveTextAlias(raw: string, cfg?: OpenClawConfig) {
|
||||
const trimmed = normalizeCommandBody(raw).trim();
|
||||
if (!trimmed.startsWith("/")) return null;
|
||||
if (!trimmed.startsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
const detection = getCommandDetection(cfg);
|
||||
const normalized = trimmed.toLowerCase();
|
||||
if (detection.exact.has(normalized)) return normalized;
|
||||
if (!detection.regex.test(normalized)) return null;
|
||||
if (detection.exact.has(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
if (!detection.regex.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
const tokenMatch = normalized.match(/^\/([^\s:]+)(?:\s|$)/);
|
||||
if (!tokenMatch) return null;
|
||||
if (!tokenMatch) {
|
||||
return null;
|
||||
}
|
||||
const tokenKey = `/${tokenMatch[1]}`;
|
||||
return getTextAliasMap().has(tokenKey) ? tokenKey : null;
|
||||
}
|
||||
@@ -404,23 +484,37 @@ export function resolveTextCommand(
|
||||
} | null {
|
||||
const trimmed = normalizeCommandBody(raw).trim();
|
||||
const alias = maybeResolveTextAlias(trimmed, cfg);
|
||||
if (!alias) return null;
|
||||
if (!alias) {
|
||||
return null;
|
||||
}
|
||||
const spec = getTextAliasMap().get(alias);
|
||||
if (!spec) return null;
|
||||
if (!spec) {
|
||||
return null;
|
||||
}
|
||||
const command = getChatCommands().find((entry) => entry.key === spec.key);
|
||||
if (!command) return null;
|
||||
if (!spec.acceptsArgs) return { command };
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
if (!spec.acceptsArgs) {
|
||||
return { command };
|
||||
}
|
||||
const args = trimmed.slice(alias.length).trim();
|
||||
return { command, args: args || undefined };
|
||||
}
|
||||
|
||||
export function isNativeCommandSurface(surface?: string): boolean {
|
||||
if (!surface) return false;
|
||||
if (!surface) {
|
||||
return false;
|
||||
}
|
||||
return getNativeCommandSurfaces().has(surface.toLowerCase());
|
||||
}
|
||||
|
||||
export function shouldHandleTextCommands(params: ShouldHandleTextCommandsParams): boolean {
|
||||
if (params.commandSource === "native") return true;
|
||||
if (params.cfg.commands?.text !== false) return true;
|
||||
if (params.commandSource === "native") {
|
||||
return true;
|
||||
}
|
||||
if (params.cfg.commands?.text !== false) {
|
||||
return true;
|
||||
}
|
||||
return !isNativeCommandSurface(params.surface);
|
||||
}
|
||||
|
||||
+51
-17
@@ -77,10 +77,16 @@ function resolveExplicitTimezone(value: string): string | undefined {
|
||||
|
||||
function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEnvelopeTimezone {
|
||||
const trimmed = options.timezone?.trim();
|
||||
if (!trimmed) return { mode: "local" };
|
||||
if (!trimmed) {
|
||||
return { mode: "local" };
|
||||
}
|
||||
const lowered = trimmed.toLowerCase();
|
||||
if (lowered === "utc" || lowered === "gmt") return { mode: "utc" };
|
||||
if (lowered === "local" || lowered === "host") return { mode: "local" };
|
||||
if (lowered === "utc" || lowered === "gmt") {
|
||||
return { mode: "utc" };
|
||||
}
|
||||
if (lowered === "local" || lowered === "host") {
|
||||
return { mode: "local" };
|
||||
}
|
||||
if (lowered === "user") {
|
||||
return { mode: "iana", timeZone: resolveUserTimezone(options.userTimezone) };
|
||||
}
|
||||
@@ -118,7 +124,9 @@ function formatZonedTimestamp(date: Date, timeZone?: string): string | undefined
|
||||
.toReversed()
|
||||
.find((part) => part.type === "timeZoneName")
|
||||
?.value?.trim();
|
||||
if (!yyyy || !mm || !dd || !hh || !min) return undefined;
|
||||
if (!yyyy || !mm || !dd || !hh || !min) {
|
||||
return undefined;
|
||||
}
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${min}${tz ? ` ${tz}` : ""}`;
|
||||
}
|
||||
|
||||
@@ -126,29 +134,47 @@ function formatTimestamp(
|
||||
ts: number | Date | undefined,
|
||||
options?: EnvelopeFormatOptions,
|
||||
): string | undefined {
|
||||
if (!ts) return undefined;
|
||||
if (!ts) {
|
||||
return undefined;
|
||||
}
|
||||
const resolved = normalizeEnvelopeOptions(options);
|
||||
if (!resolved.includeTimestamp) return undefined;
|
||||
if (!resolved.includeTimestamp) {
|
||||
return undefined;
|
||||
}
|
||||
const date = ts instanceof Date ? ts : new Date(ts);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return undefined;
|
||||
}
|
||||
const zone = resolveEnvelopeTimezone(resolved);
|
||||
if (zone.mode === "utc") return formatUtcTimestamp(date);
|
||||
if (zone.mode === "local") return formatZonedTimestamp(date);
|
||||
if (zone.mode === "utc") {
|
||||
return formatUtcTimestamp(date);
|
||||
}
|
||||
if (zone.mode === "local") {
|
||||
return formatZonedTimestamp(date);
|
||||
}
|
||||
return formatZonedTimestamp(date, zone.timeZone);
|
||||
}
|
||||
|
||||
function formatElapsedTime(currentMs: number, previousMs: number): string | undefined {
|
||||
const elapsedMs = currentMs - previousMs;
|
||||
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) return undefined;
|
||||
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const seconds = Math.floor(elapsedMs / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
if (hours < 24) {
|
||||
return `${hours}h`;
|
||||
}
|
||||
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d`;
|
||||
@@ -173,10 +199,16 @@ export function formatAgentEnvelope(params: AgentEnvelopeParams): string {
|
||||
} else if (elapsed) {
|
||||
parts.push(`+${elapsed}`);
|
||||
}
|
||||
if (params.host?.trim()) parts.push(params.host.trim());
|
||||
if (params.ip?.trim()) parts.push(params.ip.trim());
|
||||
if (params.host?.trim()) {
|
||||
parts.push(params.host.trim());
|
||||
}
|
||||
if (params.ip?.trim()) {
|
||||
parts.push(params.ip.trim());
|
||||
}
|
||||
const ts = formatTimestamp(params.timestamp, resolved);
|
||||
if (ts) parts.push(ts);
|
||||
if (ts) {
|
||||
parts.push(ts);
|
||||
}
|
||||
const header = `[${parts.join(" ")}]`;
|
||||
return `${header} ${params.body}`;
|
||||
}
|
||||
@@ -223,7 +255,9 @@ export function formatInboundFromLabel(params: {
|
||||
|
||||
const directLabel = params.directLabel.trim();
|
||||
const directId = params.directId?.trim();
|
||||
if (!directId || directId === directLabel) return directLabel;
|
||||
if (!directId || directId === directLabel) {
|
||||
return directLabel;
|
||||
}
|
||||
return `${directLabel} id:${directId}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,12 @@ export type GroupActivationMode = "mention" | "always";
|
||||
|
||||
export function normalizeGroupActivation(raw?: string | null): GroupActivationMode | undefined {
|
||||
const value = raw?.trim().toLowerCase();
|
||||
if (value === "mention") return "mention";
|
||||
if (value === "always") return "always";
|
||||
if (value === "mention") {
|
||||
return "mention";
|
||||
}
|
||||
if (value === "always") {
|
||||
return "always";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -13,12 +17,18 @@ export function parseActivationCommand(raw?: string): {
|
||||
hasCommand: boolean;
|
||||
mode?: GroupActivationMode;
|
||||
} {
|
||||
if (!raw) return { hasCommand: false };
|
||||
if (!raw) {
|
||||
return { hasCommand: false };
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { hasCommand: false };
|
||||
if (!trimmed) {
|
||||
return { hasCommand: false };
|
||||
}
|
||||
const normalized = normalizeCommandBody(trimmed);
|
||||
const match = normalized.match(/^\/activation(?:\s+([a-zA-Z]+))?\s*$/i);
|
||||
if (!match) return { hasCommand: false };
|
||||
if (!match) {
|
||||
return { hasCommand: false };
|
||||
}
|
||||
const mode = normalizeGroupActivation(match[1]);
|
||||
return { hasCommand: true, mode };
|
||||
}
|
||||
|
||||
@@ -20,20 +20,30 @@ export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300;
|
||||
* decide what to do. This function is only for when the file exists but has no content.
|
||||
*/
|
||||
export function isHeartbeatContentEffectivelyEmpty(content: string | undefined | null): boolean {
|
||||
if (content === undefined || content === null) return false;
|
||||
if (typeof content !== "string") return false;
|
||||
if (content === undefined || content === null) {
|
||||
return false;
|
||||
}
|
||||
if (typeof content !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lines = content.split("\n");
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
// Skip empty lines
|
||||
if (!trimmed) continue;
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
// Skip markdown header lines (# followed by space or EOL, ## etc)
|
||||
// This intentionally does NOT skip lines like "#TODO" or "#hashtag" which might be content
|
||||
// (Those aren't valid markdown headers - ATX headers require space after #)
|
||||
if (/^#+(\s|$)/.test(trimmed)) continue;
|
||||
if (/^#+(\s|$)/.test(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
// Skip empty markdown list items like "- [ ]" or "* [ ]" or just "- "
|
||||
if (/^[-*+]\s*(\[[\sXx]?\]\s*)?$/.test(trimmed)) continue;
|
||||
if (/^[-*+]\s*(\[[\sXx]?\]\s*)?$/.test(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
// Found a non-empty, non-comment line - there's actionable content
|
||||
return false;
|
||||
}
|
||||
@@ -50,10 +60,14 @@ export type StripHeartbeatMode = "heartbeat" | "message";
|
||||
|
||||
function stripTokenAtEdges(raw: string): { text: string; didStrip: boolean } {
|
||||
let text = raw.trim();
|
||||
if (!text) return { text: "", didStrip: false };
|
||||
if (!text) {
|
||||
return { text: "", didStrip: false };
|
||||
}
|
||||
|
||||
const token = HEARTBEAT_TOKEN;
|
||||
if (!text.includes(token)) return { text, didStrip: false };
|
||||
if (!text.includes(token)) {
|
||||
return { text, didStrip: false };
|
||||
}
|
||||
|
||||
let didStrip = false;
|
||||
let changed = true;
|
||||
@@ -83,9 +97,13 @@ export function stripHeartbeatToken(
|
||||
raw?: string,
|
||||
opts: { mode?: StripHeartbeatMode; maxAckChars?: number } = {},
|
||||
) {
|
||||
if (!raw) return { shouldSkip: true, text: "", didStrip: false };
|
||||
if (!raw) {
|
||||
return { shouldSkip: true, text: "", didStrip: false };
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { shouldSkip: true, text: "", didStrip: false };
|
||||
if (!trimmed) {
|
||||
return { shouldSkip: true, text: "", didStrip: false };
|
||||
}
|
||||
|
||||
const mode: StripHeartbeatMode = opts.mode ?? "message";
|
||||
const maxAckCharsRaw = opts.maxAckChars;
|
||||
|
||||
@@ -2,7 +2,9 @@ import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { InboundDebounceByProvider } from "../config/types.messages.js";
|
||||
|
||||
const resolveMs = (value: unknown): number | undefined => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.max(0, Math.trunc(value));
|
||||
};
|
||||
|
||||
@@ -10,7 +12,9 @@ const resolveChannelOverride = (params: {
|
||||
byChannel?: InboundDebounceByProvider;
|
||||
channel: string;
|
||||
}): number | undefined => {
|
||||
if (!params.byChannel) return undefined;
|
||||
if (!params.byChannel) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveMs(params.byChannel[params.channel]);
|
||||
};
|
||||
|
||||
@@ -50,7 +54,9 @@ export function createInboundDebouncer<T>(params: {
|
||||
clearTimeout(buffer.timeout);
|
||||
buffer.timeout = null;
|
||||
}
|
||||
if (buffer.items.length === 0) return;
|
||||
if (buffer.items.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await params.onFlush(buffer.items);
|
||||
} catch (err) {
|
||||
@@ -60,12 +66,16 @@ export function createInboundDebouncer<T>(params: {
|
||||
|
||||
const flushKey = async (key: string) => {
|
||||
const buffer = buffers.get(key);
|
||||
if (!buffer) return;
|
||||
if (!buffer) {
|
||||
return;
|
||||
}
|
||||
await flushBuffer(key, buffer);
|
||||
};
|
||||
|
||||
const scheduleFlush = (key: string, buffer: DebounceBuffer<T>) => {
|
||||
if (buffer.timeout) clearTimeout(buffer.timeout);
|
||||
if (buffer.timeout) {
|
||||
clearTimeout(buffer.timeout);
|
||||
}
|
||||
buffer.timeout = setTimeout(() => {
|
||||
void flushBuffer(key, buffer);
|
||||
}, debounceMs);
|
||||
|
||||
@@ -27,7 +27,9 @@ export function buildInboundMediaNote(ctx: MsgContext): string | undefined {
|
||||
}
|
||||
if (Array.isArray(ctx.MediaUnderstandingDecisions)) {
|
||||
for (const decision of ctx.MediaUnderstandingDecisions) {
|
||||
if (decision.outcome !== "success") continue;
|
||||
if (decision.outcome !== "success") {
|
||||
continue;
|
||||
}
|
||||
for (const attachment of decision.attachments) {
|
||||
if (attachment.chosen?.outcome === "success") {
|
||||
suppressed.add(attachment.attachmentIndex);
|
||||
@@ -42,7 +44,9 @@ export function buildInboundMediaNote(ctx: MsgContext): string | undefined {
|
||||
: ctx.MediaPath?.trim()
|
||||
? [ctx.MediaPath.trim()]
|
||||
: [];
|
||||
if (paths.length === 0) return undefined;
|
||||
if (paths.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const urls =
|
||||
Array.isArray(ctx.MediaUrls) && ctx.MediaUrls.length === paths.length
|
||||
@@ -61,7 +65,9 @@ export function buildInboundMediaNote(ctx: MsgContext): string | undefined {
|
||||
index,
|
||||
}))
|
||||
.filter((entry) => !suppressed.has(entry.index));
|
||||
if (entries.length === 0) return undefined;
|
||||
if (entries.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (entries.length === 1) {
|
||||
return formatMediaAttachedLine({
|
||||
path: entries[0]?.path ?? "",
|
||||
|
||||
@@ -11,7 +11,9 @@ export function extractModelDirective(
|
||||
rawProfile?: string;
|
||||
hasDirective: boolean;
|
||||
} {
|
||||
if (!body) return { cleaned: "", hasDirective: false };
|
||||
if (!body) {
|
||||
return { cleaned: "", hasDirective: false };
|
||||
}
|
||||
|
||||
const modelMatch = body.match(
|
||||
/(?:^|\s)\/model(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)*)?/i,
|
||||
|
||||
+9
-3
@@ -82,7 +82,9 @@ describe("directive behavior", () => {
|
||||
},
|
||||
{
|
||||
onBlockReply: (payload) => {
|
||||
if (payload.text) blockReplies.push(payload.text);
|
||||
if (payload.text) {
|
||||
blockReplies.push(payload.text);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -125,7 +127,9 @@ describe("directive behavior", () => {
|
||||
},
|
||||
{
|
||||
onBlockReply: (payload) => {
|
||||
if (payload.text) blockReplies.push(payload.text);
|
||||
if (payload.text) {
|
||||
blockReplies.push(payload.text);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -149,7 +153,9 @@ describe("directive behavior", () => {
|
||||
},
|
||||
{
|
||||
onBlockReply: (payload) => {
|
||||
if (payload.text) blockReplies.push(payload.text);
|
||||
if (payload.text) {
|
||||
blockReplies.push(payload.text);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -24,7 +24,9 @@ const ABORT_TRIGGERS = new Set(["stop", "esc", "abort", "wait", "exit", "interru
|
||||
const ABORT_MEMORY = new Map<string, boolean>();
|
||||
|
||||
export function isAbortTrigger(text?: string): boolean {
|
||||
if (!text) return false;
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
const normalized = text.trim().toLowerCase();
|
||||
return ABORT_TRIGGERS.has(normalized);
|
||||
}
|
||||
@@ -49,15 +51,21 @@ function resolveSessionEntryForKey(
|
||||
store: Record<string, SessionEntry> | undefined,
|
||||
sessionKey: string | undefined,
|
||||
) {
|
||||
if (!store || !sessionKey) return {};
|
||||
if (!store || !sessionKey) {
|
||||
return {};
|
||||
}
|
||||
const direct = store[sessionKey];
|
||||
if (direct) return { entry: direct, key: sessionKey };
|
||||
if (direct) {
|
||||
return { entry: direct, key: sessionKey };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function resolveAbortTargetKey(ctx: MsgContext): string | undefined {
|
||||
const target = ctx.CommandTargetSessionKey?.trim();
|
||||
if (target) return target;
|
||||
if (target) {
|
||||
return target;
|
||||
}
|
||||
const sessionKey = ctx.SessionKey?.trim();
|
||||
return sessionKey || undefined;
|
||||
}
|
||||
@@ -67,7 +75,9 @@ function normalizeRequesterSessionKey(
|
||||
key: string | undefined,
|
||||
): string | undefined {
|
||||
const cleaned = key?.trim();
|
||||
if (!cleaned) return undefined;
|
||||
if (!cleaned) {
|
||||
return undefined;
|
||||
}
|
||||
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
||||
return resolveInternalSessionKey({ key: cleaned, alias, mainKey });
|
||||
}
|
||||
@@ -77,18 +87,26 @@ export function stopSubagentsForRequester(params: {
|
||||
requesterSessionKey?: string;
|
||||
}): { stopped: number } {
|
||||
const requesterKey = normalizeRequesterSessionKey(params.cfg, params.requesterSessionKey);
|
||||
if (!requesterKey) return { stopped: 0 };
|
||||
if (!requesterKey) {
|
||||
return { stopped: 0 };
|
||||
}
|
||||
const runs = listSubagentRunsForRequester(requesterKey);
|
||||
if (runs.length === 0) return { stopped: 0 };
|
||||
if (runs.length === 0) {
|
||||
return { stopped: 0 };
|
||||
}
|
||||
|
||||
const storeCache = new Map<string, Record<string, SessionEntry>>();
|
||||
const seenChildKeys = new Set<string>();
|
||||
let stopped = 0;
|
||||
|
||||
for (const run of runs) {
|
||||
if (run.endedAt) continue;
|
||||
if (run.endedAt) {
|
||||
continue;
|
||||
}
|
||||
const childKey = run.childSessionKey?.trim();
|
||||
if (!childKey || seenChildKeys.has(childKey)) continue;
|
||||
if (!childKey || seenChildKeys.has(childKey)) {
|
||||
continue;
|
||||
}
|
||||
seenChildKeys.add(childKey);
|
||||
|
||||
const cleared = clearSessionQueues([childKey]);
|
||||
@@ -130,7 +148,9 @@ export async function tryFastAbortFromMessage(params: {
|
||||
const stripped = isGroup ? stripMentions(raw, ctx, cfg, agentId) : raw;
|
||||
const normalized = normalizeCommandBody(stripped);
|
||||
const abortRequested = normalized === "/stop" || isAbortTrigger(stripped);
|
||||
if (!abortRequested) return { handled: false, aborted: false };
|
||||
if (!abortRequested) {
|
||||
return { handled: false, aborted: false };
|
||||
}
|
||||
|
||||
const commandAuthorized = ctx.CommandAuthorized;
|
||||
const auth = resolveCommandAuthorization({
|
||||
@@ -138,7 +158,9 @@ export async function tryFastAbortFromMessage(params: {
|
||||
cfg,
|
||||
commandAuthorized,
|
||||
});
|
||||
if (!auth.isAuthorizedSender) return { handled: false, aborted: false };
|
||||
if (!auth.isAuthorizedSender) {
|
||||
return { handled: false, aborted: false };
|
||||
}
|
||||
|
||||
const abortKey = targetKey ?? auth.from ?? auth.to;
|
||||
const requesterSessionKey = targetKey ?? ctx.SessionKey ?? abortKey;
|
||||
@@ -161,7 +183,9 @@ export async function tryFastAbortFromMessage(params: {
|
||||
store[key] = entry;
|
||||
await updateSessionStore(storePath, (nextStore) => {
|
||||
const nextEntry = nextStore[key] ?? entry;
|
||||
if (!nextEntry) return;
|
||||
if (!nextEntry) {
|
||||
return;
|
||||
}
|
||||
nextEntry.abortedLastRun = true;
|
||||
nextEntry.updatedAt = Date.now();
|
||||
nextStore[key] = nextEntry;
|
||||
|
||||
@@ -103,7 +103,9 @@ export async function runAgentTurnWithFallback(params: {
|
||||
params.followupRun.run.reasoningLevel === "stream" && params.opts?.onReasoningStream
|
||||
);
|
||||
const normalizeStreamingText = (payload: ReplyPayload): { text?: string; skip: boolean } => {
|
||||
if (!allowPartialStream) return { skip: true };
|
||||
if (!allowPartialStream) {
|
||||
return { skip: true };
|
||||
}
|
||||
let text = payload.text;
|
||||
if (!params.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
|
||||
const stripped = stripHeartbeatToken(text, {
|
||||
@@ -121,14 +123,20 @@ export async function runAgentTurnWithFallback(params: {
|
||||
if (isSilentReplyText(text, SILENT_REPLY_TOKEN)) {
|
||||
return { skip: true };
|
||||
}
|
||||
if (!text) return { skip: true };
|
||||
if (!text) {
|
||||
return { skip: true };
|
||||
}
|
||||
const sanitized = sanitizeUserFacingText(text);
|
||||
if (!sanitized.trim()) return { skip: true };
|
||||
if (!sanitized.trim()) {
|
||||
return { skip: true };
|
||||
}
|
||||
return { text: sanitized, skip: false };
|
||||
};
|
||||
const handlePartialForTyping = async (payload: ReplyPayload): Promise<string | undefined> => {
|
||||
const { text, skip } = normalizeStreamingText(payload);
|
||||
if (skip || !text) return undefined;
|
||||
if (skip || !text) {
|
||||
return undefined;
|
||||
}
|
||||
await params.typingSignals.signalTextDelta(text);
|
||||
return text;
|
||||
};
|
||||
@@ -266,7 +274,9 @@ export async function runAgentTurnWithFallback(params: {
|
||||
params.sessionCtx.Surface,
|
||||
params.sessionCtx.Provider,
|
||||
);
|
||||
if (!channel) return "markdown";
|
||||
if (!channel) {
|
||||
return "markdown";
|
||||
}
|
||||
return isMarkdownCapableMessageChannel(channel) ? "markdown" : "plain";
|
||||
})(),
|
||||
bashElevated: params.followupRun.run.bashElevated,
|
||||
@@ -279,7 +289,9 @@ export async function runAgentTurnWithFallback(params: {
|
||||
onPartialReply: allowPartialStream
|
||||
? async (payload) => {
|
||||
const textForTyping = await handlePartialForTyping(payload);
|
||||
if (!params.opts?.onPartialReply || textForTyping === undefined) return;
|
||||
if (!params.opts?.onPartialReply || textForTyping === undefined) {
|
||||
return;
|
||||
}
|
||||
await params.opts.onPartialReply({
|
||||
text: textForTyping,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
@@ -324,7 +336,9 @@ export async function runAgentTurnWithFallback(params: {
|
||||
? async (payload) => {
|
||||
const { text, skip } = normalizeStreamingText(payload);
|
||||
const hasPayloadMedia = (payload.mediaUrls?.length ?? 0) > 0;
|
||||
if (skip && !hasPayloadMedia) return;
|
||||
if (skip && !hasPayloadMedia) {
|
||||
return;
|
||||
}
|
||||
const currentMessageId =
|
||||
params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid;
|
||||
const taggedPayload = applyReplyTagsToPayload(
|
||||
@@ -339,7 +353,9 @@ export async function runAgentTurnWithFallback(params: {
|
||||
currentMessageId,
|
||||
);
|
||||
// Let through payloads with audioAsVoice flag even if empty (need to track it)
|
||||
if (!isRenderablePayload(taggedPayload) && !payload.audioAsVoice) return;
|
||||
if (!isRenderablePayload(taggedPayload) && !payload.audioAsVoice) {
|
||||
return;
|
||||
}
|
||||
const parsed = parseReplyDirectives(taggedPayload.text ?? "", {
|
||||
currentMessageId,
|
||||
silentToken: SILENT_REPLY_TOKEN,
|
||||
@@ -353,9 +369,12 @@ export async function runAgentTurnWithFallback(params: {
|
||||
!hasRenderableMedia &&
|
||||
!payload.audioAsVoice &&
|
||||
!parsed.audioAsVoice
|
||||
)
|
||||
) {
|
||||
return;
|
||||
if (parsed.isSilent && !hasRenderableMedia) return;
|
||||
}
|
||||
if (parsed.isSilent && !hasRenderableMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blockPayload: ReplyPayload = params.applyReplyToMode({
|
||||
...taggedPayload,
|
||||
@@ -399,7 +418,9 @@ export async function runAgentTurnWithFallback(params: {
|
||||
// a typing loop that never sees a matching markRunComplete(). Track and drain.
|
||||
const task = (async () => {
|
||||
const { text, skip } = normalizeStreamingText(payload);
|
||||
if (skip) return;
|
||||
if (skip) {
|
||||
return;
|
||||
}
|
||||
await params.typingSignals.signalTextDelta(text);
|
||||
await onToolResult({
|
||||
text,
|
||||
|
||||
@@ -26,7 +26,9 @@ export const createShouldEmitToolResult = (params: {
|
||||
const store = loadSessionStore(params.storePath);
|
||||
const entry = store[params.sessionKey];
|
||||
const current = normalizeVerboseLevel(String(entry?.verboseLevel ?? ""));
|
||||
if (current) return current !== "off";
|
||||
if (current) {
|
||||
return current !== "off";
|
||||
}
|
||||
} catch {
|
||||
// ignore store read failures
|
||||
}
|
||||
@@ -49,7 +51,9 @@ export const createShouldEmitToolOutput = (params: {
|
||||
const store = loadSessionStore(params.storePath);
|
||||
const entry = store[params.sessionKey];
|
||||
const current = normalizeVerboseLevel(String(entry?.verboseLevel ?? ""));
|
||||
if (current) return current === "full";
|
||||
if (current) {
|
||||
return current === "full";
|
||||
}
|
||||
} catch {
|
||||
// ignore store read failures
|
||||
}
|
||||
@@ -72,9 +76,15 @@ export const signalTypingIfNeeded = async (
|
||||
): Promise<void> => {
|
||||
const shouldSignalTyping = payloads.some((payload) => {
|
||||
const trimmed = payload.text?.trim();
|
||||
if (trimmed) return true;
|
||||
if (payload.mediaUrl) return true;
|
||||
if (payload.mediaUrls && payload.mediaUrls.length > 0) return true;
|
||||
if (trimmed) {
|
||||
return true;
|
||||
}
|
||||
if (payload.mediaUrl) {
|
||||
return true;
|
||||
}
|
||||
if (payload.mediaUrls && payload.mediaUrls.length > 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (shouldSignalTyping) {
|
||||
|
||||
@@ -39,15 +39,21 @@ export async function runMemoryFlushIfNeeded(params: {
|
||||
isHeartbeat: boolean;
|
||||
}): Promise<SessionEntry | undefined> {
|
||||
const memoryFlushSettings = resolveMemoryFlushSettings(params.cfg);
|
||||
if (!memoryFlushSettings) return params.sessionEntry;
|
||||
if (!memoryFlushSettings) {
|
||||
return params.sessionEntry;
|
||||
}
|
||||
|
||||
const memoryFlushWritable = (() => {
|
||||
if (!params.sessionKey) return true;
|
||||
if (!params.sessionKey) {
|
||||
return true;
|
||||
}
|
||||
const runtime = resolveSandboxRuntimeStatus({
|
||||
cfg: params.cfg,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
if (!runtime.sandboxed) return true;
|
||||
if (!runtime.sandboxed) {
|
||||
return true;
|
||||
}
|
||||
const sandboxCfg = resolveSandboxConfigForAgent(params.cfg, runtime.agentId);
|
||||
return sandboxCfg.workspaceAccess === "rw";
|
||||
})();
|
||||
@@ -69,7 +75,9 @@ export async function runMemoryFlushIfNeeded(params: {
|
||||
softThresholdTokens: memoryFlushSettings.softThresholdTokens,
|
||||
});
|
||||
|
||||
if (!shouldFlushMemory) return params.sessionEntry;
|
||||
if (!shouldFlushMemory) {
|
||||
return params.sessionEntry;
|
||||
}
|
||||
|
||||
let activeSessionEntry = params.sessionEntry;
|
||||
const activeSessionStore = params.sessionStore;
|
||||
|
||||
@@ -52,7 +52,9 @@ export function buildReplyPayloads(params: {
|
||||
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
|
||||
}
|
||||
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||
if (stripped.shouldSkip && !hasMedia) return [];
|
||||
if (stripped.shouldSkip && !hasMedia) {
|
||||
return [];
|
||||
}
|
||||
return [{ ...payload, text: stripped.text }];
|
||||
});
|
||||
|
||||
|
||||
@@ -20,9 +20,13 @@ export function buildThreadingToolContext(params: {
|
||||
hasRepliedRef: { value: boolean } | undefined;
|
||||
}): ChannelThreadingToolContext {
|
||||
const { sessionCtx, config, hasRepliedRef } = params;
|
||||
if (!config) return {};
|
||||
if (!config) {
|
||||
return {};
|
||||
}
|
||||
const rawProvider = sessionCtx.Provider?.trim().toLowerCase();
|
||||
if (!rawProvider) return {};
|
||||
if (!rawProvider) {
|
||||
return {};
|
||||
}
|
||||
const provider = normalizeChannelId(rawProvider) ?? normalizeAnyChannelId(rawProvider);
|
||||
// Fallback for unrecognized/plugin channels (e.g., BlueBubbles before plugin registry init)
|
||||
const dock = provider ? getChannelDock(provider) : undefined;
|
||||
@@ -78,10 +82,14 @@ export const formatResponseUsageLine = (params: {
|
||||
};
|
||||
}): string | null => {
|
||||
const usage = params.usage;
|
||||
if (!usage) return null;
|
||||
if (!usage) {
|
||||
return null;
|
||||
}
|
||||
const input = usage.input;
|
||||
const output = usage.output;
|
||||
if (typeof input !== "number" && typeof output !== "number") return null;
|
||||
if (typeof input !== "number" && typeof output !== "number") {
|
||||
return null;
|
||||
}
|
||||
const inputLabel = typeof input === "number" ? formatTokenCount(input) : "?";
|
||||
const outputLabel = typeof output === "number" ? formatTokenCount(output) : "?";
|
||||
const cost =
|
||||
@@ -109,7 +117,9 @@ export const appendUsageLine = (payloads: ReplyPayload[], line: string): ReplyPa
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (index === -1) return [...payloads, { text: line }];
|
||||
if (index === -1) {
|
||||
return [...payloads, { text: line }];
|
||||
}
|
||||
const existing = payloads[index];
|
||||
const existingText = existing.text ?? "";
|
||||
const separator = existingText.endsWith("\n") ? "" : "\n";
|
||||
|
||||
@@ -106,10 +106,16 @@ describe("runReplyAgent claude-cli routing", () => {
|
||||
const randomSpy = vi.spyOn(crypto, "randomUUID").mockReturnValue("run-1");
|
||||
const lifecyclePhases: string[] = [];
|
||||
const unsubscribe = onAgentEvent((evt) => {
|
||||
if (evt.runId !== "run-1") return;
|
||||
if (evt.stream !== "lifecycle") return;
|
||||
if (evt.runId !== "run-1") {
|
||||
return;
|
||||
}
|
||||
if (evt.stream !== "lifecycle") {
|
||||
return;
|
||||
}
|
||||
const phase = evt.data?.phase;
|
||||
if (typeof phase === "string") lifecyclePhases.push(phase);
|
||||
if (typeof phase === "string") {
|
||||
lifecyclePhases.push(phase);
|
||||
}
|
||||
});
|
||||
runCliAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "ok" }],
|
||||
|
||||
@@ -236,9 +236,13 @@ export async function runReplyAgent(params: {
|
||||
buildLogMessage,
|
||||
cleanupTranscripts,
|
||||
}: SessionResetOptions): Promise<boolean> => {
|
||||
if (!sessionKey || !activeSessionStore || !storePath) return false;
|
||||
if (!sessionKey || !activeSessionStore || !storePath) {
|
||||
return false;
|
||||
}
|
||||
const prevEntry = activeSessionStore[sessionKey] ?? activeSessionEntry;
|
||||
if (!prevEntry) return false;
|
||||
if (!prevEntry) {
|
||||
return false;
|
||||
}
|
||||
const prevSessionId = cleanupTranscripts ? prevEntry.sessionId : undefined;
|
||||
const nextSessionId = crypto.randomUUID();
|
||||
const nextEntry: SessionEntry = {
|
||||
@@ -273,7 +277,9 @@ export async function runReplyAgent(params: {
|
||||
if (cleanupTranscripts && prevSessionId) {
|
||||
const transcriptCandidates = new Set<string>();
|
||||
const resolved = resolveSessionFilePath(prevSessionId, prevEntry, { agentId });
|
||||
if (resolved) transcriptCandidates.add(resolved);
|
||||
if (resolved) {
|
||||
transcriptCandidates.add(resolved);
|
||||
}
|
||||
transcriptCandidates.add(resolveSessionTranscriptPath(prevSessionId, agentId));
|
||||
for (const candidate of transcriptCandidates) {
|
||||
try {
|
||||
@@ -391,8 +397,9 @@ export async function runReplyAgent(params: {
|
||||
// Drain any late tool/block deliveries before deciding there's "nothing to send".
|
||||
// Otherwise, a late typing trigger (e.g. from a tool callback) can outlive the run and
|
||||
// keep the typing indicator stuck.
|
||||
if (payloadArray.length === 0)
|
||||
if (payloadArray.length === 0) {
|
||||
return finalizeWithFollowup(undefined, queueKey, runFollowupTurn);
|
||||
}
|
||||
|
||||
const payloadResult = buildReplyPayloads({
|
||||
payloads: payloadArray,
|
||||
@@ -413,8 +420,9 @@ export async function runReplyAgent(params: {
|
||||
const { replyPayloads } = payloadResult;
|
||||
didLogHeartbeatStrip = payloadResult.didLogHeartbeatStrip;
|
||||
|
||||
if (replyPayloads.length === 0)
|
||||
if (replyPayloads.length === 0) {
|
||||
return finalizeWithFollowup(undefined, queueKey, runFollowupTurn);
|
||||
}
|
||||
|
||||
await signalTypingIfNeeded(replyPayloads, typingSignals);
|
||||
|
||||
@@ -477,7 +485,9 @@ export async function runReplyAgent(params: {
|
||||
if (formatted && responseUsageMode === "full" && sessionKey) {
|
||||
formatted = `${formatted} · session ${sessionKey}`;
|
||||
}
|
||||
if (formatted) responseUsageLine = formatted;
|
||||
if (formatted) {
|
||||
responseUsageLine = formatted;
|
||||
}
|
||||
}
|
||||
|
||||
// If verbose is enabled and this is a new session, prepend a session hint.
|
||||
|
||||
@@ -35,19 +35,25 @@ let activeJob: ActiveBashJob | null = null;
|
||||
|
||||
function resolveForegroundMs(cfg: OpenClawConfig): number {
|
||||
const raw = cfg.commands?.bashForegroundMs;
|
||||
if (typeof raw !== "number" || Number.isNaN(raw)) return DEFAULT_FOREGROUND_MS;
|
||||
if (typeof raw !== "number" || Number.isNaN(raw)) {
|
||||
return DEFAULT_FOREGROUND_MS;
|
||||
}
|
||||
return clampInt(raw, 0, MAX_FOREGROUND_MS);
|
||||
}
|
||||
|
||||
function formatSessionSnippet(sessionId: string) {
|
||||
const trimmed = sessionId.trim();
|
||||
if (trimmed.length <= 12) return trimmed;
|
||||
if (trimmed.length <= 12) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed.slice(0, 8)}…`;
|
||||
}
|
||||
|
||||
function formatOutputBlock(text: string) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return "(no output)";
|
||||
if (!trimmed) {
|
||||
return "(no output)";
|
||||
}
|
||||
return `\`\`\`txt\n${trimmed}\n\`\`\``;
|
||||
}
|
||||
|
||||
@@ -56,7 +62,9 @@ function parseBashRequest(raw: string): BashRequest | null {
|
||||
let restSource = "";
|
||||
if (trimmed.toLowerCase().startsWith("/bash")) {
|
||||
const match = trimmed.match(/^\/bash(?:\s*:\s*|\s+|$)([\s\S]*)$/i);
|
||||
if (!match) return null;
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
restSource = match[1] ?? "";
|
||||
} else if (trimmed.startsWith("!")) {
|
||||
restSource = trimmed.slice(1);
|
||||
@@ -68,7 +76,9 @@ function parseBashRequest(raw: string): BashRequest | null {
|
||||
}
|
||||
|
||||
const rest = restSource.trimStart();
|
||||
if (!rest) return { action: "help" };
|
||||
if (!rest) {
|
||||
return { action: "help" };
|
||||
}
|
||||
const tokenMatch = rest.match(/^(\S+)(?:\s+([\s\S]+))?$/);
|
||||
const token = tokenMatch?.[1]?.trim() ?? "";
|
||||
const remainder = tokenMatch?.[2]?.trim() ?? "";
|
||||
@@ -100,17 +110,27 @@ function resolveRawCommandBody(params: {
|
||||
|
||||
function getScopedSession(sessionId: string) {
|
||||
const running = getSession(sessionId);
|
||||
if (running && running.scopeKey === CHAT_BASH_SCOPE_KEY) return { running };
|
||||
if (running && running.scopeKey === CHAT_BASH_SCOPE_KEY) {
|
||||
return { running };
|
||||
}
|
||||
const finished = getFinishedSession(sessionId);
|
||||
if (finished && finished.scopeKey === CHAT_BASH_SCOPE_KEY) return { finished };
|
||||
if (finished && finished.scopeKey === CHAT_BASH_SCOPE_KEY) {
|
||||
return { finished };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function ensureActiveJobState() {
|
||||
if (!activeJob) return null;
|
||||
if (activeJob.state === "starting") return activeJob;
|
||||
if (!activeJob) {
|
||||
return null;
|
||||
}
|
||||
if (activeJob.state === "starting") {
|
||||
return activeJob;
|
||||
}
|
||||
const { running, finished } = getScopedSession(activeJob.sessionId);
|
||||
if (running) return activeJob;
|
||||
if (running) {
|
||||
return activeJob;
|
||||
}
|
||||
if (finished) {
|
||||
activeJob = null;
|
||||
return null;
|
||||
@@ -120,12 +140,20 @@ function ensureActiveJobState() {
|
||||
}
|
||||
|
||||
function attachActiveWatcher(sessionId: string) {
|
||||
if (!activeJob || activeJob.state !== "running") return;
|
||||
if (activeJob.sessionId !== sessionId) return;
|
||||
if (activeJob.watcherAttached) return;
|
||||
if (!activeJob || activeJob.state !== "running") {
|
||||
return;
|
||||
}
|
||||
if (activeJob.sessionId !== sessionId) {
|
||||
return;
|
||||
}
|
||||
if (activeJob.watcherAttached) {
|
||||
return;
|
||||
}
|
||||
const { running } = getScopedSession(sessionId);
|
||||
const child = running?.child;
|
||||
if (!child) return;
|
||||
if (!child) {
|
||||
return;
|
||||
}
|
||||
activeJob.watcherAttached = true;
|
||||
child.once("close", () => {
|
||||
if (activeJob?.state === "running" && activeJob.sessionId === sessionId) {
|
||||
@@ -317,7 +345,9 @@ export async function handleBashChatCommand(params: {
|
||||
}
|
||||
|
||||
const commandText = request.command.trim();
|
||||
if (!commandText) return buildUsageReply();
|
||||
if (!commandText) {
|
||||
return buildUsageReply();
|
||||
}
|
||||
|
||||
activeJob = {
|
||||
state: "starting",
|
||||
|
||||
@@ -25,7 +25,9 @@ export function createBlockReplyCoalescer(params: {
|
||||
let idleTimer: NodeJS.Timeout | undefined;
|
||||
|
||||
const clearIdleTimer = () => {
|
||||
if (!idleTimer) return;
|
||||
if (!idleTimer) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = undefined;
|
||||
};
|
||||
@@ -37,7 +39,9 @@ export function createBlockReplyCoalescer(params: {
|
||||
};
|
||||
|
||||
const scheduleIdleFlush = () => {
|
||||
if (idleMs <= 0) return;
|
||||
if (idleMs <= 0) {
|
||||
return;
|
||||
}
|
||||
clearIdleTimer();
|
||||
idleTimer = setTimeout(() => {
|
||||
void flush({ force: false });
|
||||
@@ -50,7 +54,9 @@ export function createBlockReplyCoalescer(params: {
|
||||
resetBuffer();
|
||||
return;
|
||||
}
|
||||
if (!bufferText) return;
|
||||
if (!bufferText) {
|
||||
return;
|
||||
}
|
||||
if (!options?.force && bufferText.length < minChars) {
|
||||
scheduleIdleFlush();
|
||||
return;
|
||||
@@ -65,7 +71,9 @@ export function createBlockReplyCoalescer(params: {
|
||||
};
|
||||
|
||||
const enqueue = (payload: ReplyPayload) => {
|
||||
if (shouldAbort()) return;
|
||||
if (shouldAbort()) {
|
||||
return;
|
||||
}
|
||||
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||
const text = payload.text ?? "";
|
||||
const hasText = text.trim().length > 0;
|
||||
@@ -74,7 +82,9 @@ export function createBlockReplyCoalescer(params: {
|
||||
void onFlush(payload);
|
||||
return;
|
||||
}
|
||||
if (!hasText) return;
|
||||
if (!hasText) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
bufferText &&
|
||||
|
||||
@@ -53,7 +53,9 @@ const withTimeout = async <T>(
|
||||
timeoutMs: number,
|
||||
timeoutError: Error,
|
||||
): Promise<T> => {
|
||||
if (!timeoutMs || timeoutMs <= 0) return promise;
|
||||
if (!timeoutMs || timeoutMs <= 0) {
|
||||
return promise;
|
||||
}
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(timeoutError), timeoutMs);
|
||||
@@ -61,7 +63,9 @@ const withTimeout = async <T>(
|
||||
try {
|
||||
return await Promise.race([promise, timeoutPromise]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -87,20 +91,28 @@ export function createBlockReplyPipeline(params: {
|
||||
let didLogTimeout = false;
|
||||
|
||||
const sendPayload = (payload: ReplyPayload, skipSeen?: boolean) => {
|
||||
if (aborted) return;
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
const payloadKey = createBlockReplyPayloadKey(payload);
|
||||
if (!skipSeen) {
|
||||
if (seenKeys.has(payloadKey)) return;
|
||||
if (seenKeys.has(payloadKey)) {
|
||||
return;
|
||||
}
|
||||
seenKeys.add(payloadKey);
|
||||
}
|
||||
if (sentKeys.has(payloadKey) || pendingKeys.has(payloadKey)) return;
|
||||
if (sentKeys.has(payloadKey) || pendingKeys.has(payloadKey)) {
|
||||
return;
|
||||
}
|
||||
pendingKeys.add(payloadKey);
|
||||
|
||||
const timeoutError = new Error(`block reply delivery timed out after ${timeoutMs}ms`);
|
||||
const abortController = new AbortController();
|
||||
sendChain = sendChain
|
||||
.then(async () => {
|
||||
if (aborted) return false;
|
||||
if (aborted) {
|
||||
return false;
|
||||
}
|
||||
await withTimeout(
|
||||
onBlockReply(payload, {
|
||||
abortSignal: abortController.signal,
|
||||
@@ -112,7 +124,9 @@ export function createBlockReplyPipeline(params: {
|
||||
return true;
|
||||
})
|
||||
.then((didSend) => {
|
||||
if (!didSend) return;
|
||||
if (!didSend) {
|
||||
return;
|
||||
}
|
||||
sentKeys.add(payloadKey);
|
||||
didStream = true;
|
||||
})
|
||||
@@ -148,7 +162,9 @@ export function createBlockReplyPipeline(params: {
|
||||
|
||||
const bufferPayload = (payload: ReplyPayload) => {
|
||||
buffer?.onEnqueue?.(payload);
|
||||
if (!buffer?.shouldBuffer(payload)) return false;
|
||||
if (!buffer?.shouldBuffer(payload)) {
|
||||
return false;
|
||||
}
|
||||
const payloadKey = createBlockReplyPayloadKey(payload);
|
||||
if (
|
||||
seenKeys.has(payloadKey) ||
|
||||
@@ -165,7 +181,9 @@ export function createBlockReplyPipeline(params: {
|
||||
};
|
||||
|
||||
const flushBuffered = () => {
|
||||
if (!bufferedPayloads.length) return;
|
||||
if (!bufferedPayloads.length) {
|
||||
return;
|
||||
}
|
||||
for (const payload of bufferedPayloads) {
|
||||
const finalPayload = buffer?.finalize?.(payload) ?? payload;
|
||||
sendPayload(finalPayload, true);
|
||||
@@ -175,8 +193,12 @@ export function createBlockReplyPipeline(params: {
|
||||
};
|
||||
|
||||
const enqueue = (payload: ReplyPayload) => {
|
||||
if (aborted) return;
|
||||
if (bufferPayload(payload)) return;
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
if (bufferPayload(payload)) {
|
||||
return;
|
||||
}
|
||||
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||
if (hasMedia) {
|
||||
void coalescer?.flush({ force: true });
|
||||
|
||||
@@ -16,7 +16,9 @@ const getBlockChunkProviders = () =>
|
||||
new Set<TextChunkProvider>([...listDeliverableMessageChannels(), INTERNAL_MESSAGE_CHANNEL]);
|
||||
|
||||
function normalizeChunkProvider(provider?: string): TextChunkProvider | undefined {
|
||||
if (!provider) return undefined;
|
||||
if (!provider) {
|
||||
return undefined;
|
||||
}
|
||||
const cleaned = provider.trim().toLowerCase();
|
||||
return getBlockChunkProviders().has(cleaned as TextChunkProvider)
|
||||
? (cleaned as TextChunkProvider)
|
||||
@@ -34,9 +36,13 @@ function resolveProviderBlockStreamingCoalesce(params: {
|
||||
accountId?: string | null;
|
||||
}): BlockStreamingCoalesceConfig | undefined {
|
||||
const { cfg, providerKey, accountId } = params;
|
||||
if (!cfg || !providerKey) return undefined;
|
||||
if (!cfg || !providerKey) {
|
||||
return undefined;
|
||||
}
|
||||
const providerCfg = (cfg as Record<string, unknown>)[providerKey];
|
||||
if (!providerCfg || typeof providerCfg !== "object") return undefined;
|
||||
if (!providerCfg || typeof providerCfg !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const typed = providerCfg as ProviderBlockStreamingConfig;
|
||||
const accountCfg = typed.accounts?.[normalizedAccountId];
|
||||
|
||||
@@ -26,7 +26,9 @@ export async function applySessionHints(params: {
|
||||
const sessionKey = params.sessionKey;
|
||||
await updateSessionStore(params.storePath, (store) => {
|
||||
const entry = store[sessionKey] ?? params.sessionEntry;
|
||||
if (!entry) return;
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
store[sessionKey] = {
|
||||
...entry,
|
||||
abortedLastRun: false,
|
||||
|
||||
@@ -54,9 +54,13 @@ const SCOPES = new Set<AllowlistScope>(["dm", "group", "all"]);
|
||||
|
||||
function parseAllowlistCommand(raw: string): AllowlistCommand | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.toLowerCase().startsWith("/allowlist")) return null;
|
||||
if (!trimmed.toLowerCase().startsWith("/allowlist")) {
|
||||
return null;
|
||||
}
|
||||
const rest = trimmed.slice("/allowlist".length).trim();
|
||||
if (!rest) return { action: "list", scope: "dm" };
|
||||
if (!rest) {
|
||||
return { action: "list", scope: "dm" };
|
||||
}
|
||||
|
||||
const tokens = rest.split(/\s+/);
|
||||
let action: AllowlistAction = "list";
|
||||
@@ -107,11 +111,15 @@ function parseAllowlistCommand(raw: string): AllowlistCommand | null {
|
||||
const key = kv[0]?.trim().toLowerCase();
|
||||
const value = kv[1]?.trim();
|
||||
if (key === "channel") {
|
||||
if (value) channel = value;
|
||||
if (value) {
|
||||
channel = value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (key === "account") {
|
||||
if (value) account = value;
|
||||
if (value) {
|
||||
account = value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (key === "scope" && value && SCOPES.has(value.toLowerCase() as AllowlistScope)) {
|
||||
@@ -151,7 +159,9 @@ function normalizeAllowFrom(params: {
|
||||
}
|
||||
|
||||
function formatEntryList(entries: string[], resolved?: Map<string, string>): string {
|
||||
if (entries.length === 0) return "(none)";
|
||||
if (entries.length === 0) {
|
||||
return "(none)";
|
||||
}
|
||||
return entries
|
||||
.map((entry) => {
|
||||
const name = resolved?.get(entry);
|
||||
@@ -185,7 +195,9 @@ function resolveAccountTarget(
|
||||
function getNestedValue(root: Record<string, unknown>, path: string[]): unknown {
|
||||
let current: unknown = root;
|
||||
for (const key of path) {
|
||||
if (!current || typeof current !== "object") return undefined;
|
||||
if (!current || typeof current !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
return current;
|
||||
@@ -207,7 +219,9 @@ function ensureNestedObject(
|
||||
}
|
||||
|
||||
function setNestedValue(root: Record<string, unknown>, path: string[], value: unknown) {
|
||||
if (path.length === 0) return;
|
||||
if (path.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (path.length === 1) {
|
||||
root[path[0]] = value;
|
||||
return;
|
||||
@@ -217,13 +231,17 @@ function setNestedValue(root: Record<string, unknown>, path: string[], value: un
|
||||
}
|
||||
|
||||
function deleteNestedValue(root: Record<string, unknown>, path: string[]) {
|
||||
if (path.length === 0) return;
|
||||
if (path.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (path.length === 1) {
|
||||
delete root[path[0]];
|
||||
return;
|
||||
}
|
||||
const parent = getNestedValue(root, path.slice(0, -1));
|
||||
if (!parent || typeof parent !== "object") return;
|
||||
if (!parent || typeof parent !== "object") {
|
||||
return;
|
||||
}
|
||||
delete (parent as Record<string, unknown>)[path[path.length - 1]];
|
||||
}
|
||||
|
||||
@@ -231,9 +249,13 @@ function resolveChannelAllowFromPaths(
|
||||
channelId: ChannelId,
|
||||
scope: AllowlistScope,
|
||||
): string[] | null {
|
||||
if (scope === "all") return null;
|
||||
if (scope === "all") {
|
||||
return null;
|
||||
}
|
||||
if (scope === "dm") {
|
||||
if (channelId === "slack" || channelId === "discord") return ["dm", "allowFrom"];
|
||||
if (channelId === "slack" || channelId === "discord") {
|
||||
return ["dm", "allowFrom"];
|
||||
}
|
||||
if (
|
||||
channelId === "telegram" ||
|
||||
channelId === "whatsapp" ||
|
||||
@@ -265,11 +287,15 @@ async function resolveSlackNames(params: {
|
||||
}) {
|
||||
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const token = account.config.userToken?.trim() || account.botToken?.trim();
|
||||
if (!token) return new Map<string, string>();
|
||||
if (!token) {
|
||||
return new Map<string, string>();
|
||||
}
|
||||
const resolved = await resolveSlackUserAllowlist({ token, entries: params.entries });
|
||||
const map = new Map<string, string>();
|
||||
for (const entry of resolved) {
|
||||
if (entry.resolved && entry.name) map.set(entry.input, entry.name);
|
||||
if (entry.resolved && entry.name) {
|
||||
map.set(entry.input, entry.name);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -281,19 +307,27 @@ async function resolveDiscordNames(params: {
|
||||
}) {
|
||||
const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const token = account.token?.trim();
|
||||
if (!token) return new Map<string, string>();
|
||||
if (!token) {
|
||||
return new Map<string, string>();
|
||||
}
|
||||
const resolved = await resolveDiscordUserAllowlist({ token, entries: params.entries });
|
||||
const map = new Map<string, string>();
|
||||
for (const entry of resolved) {
|
||||
if (entry.resolved && entry.name) map.set(entry.input, entry.name);
|
||||
if (entry.resolved && entry.name) {
|
||||
map.set(entry.input, entry.name);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export const handleAllowlistCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseAllowlistCommand(params.command.commandBodyNormalized);
|
||||
if (!parsed) return null;
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
if (parsed.action === "error") {
|
||||
return { shouldContinue: false, reply: { text: `⚠️ ${parsed.message}` } };
|
||||
}
|
||||
@@ -444,8 +478,12 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
|
||||
|
||||
const lines: string[] = ["🧾 Allowlist"];
|
||||
lines.push(`Channel: ${channelId}${accountId ? ` (account ${accountId})` : ""}`);
|
||||
if (dmPolicy) lines.push(`DM policy: ${dmPolicy}`);
|
||||
if (groupPolicy) lines.push(`Group policy: ${groupPolicy}`);
|
||||
if (dmPolicy) {
|
||||
lines.push(`DM policy: ${dmPolicy}`);
|
||||
}
|
||||
if (groupPolicy) {
|
||||
lines.push(`Group policy: ${groupPolicy}`);
|
||||
}
|
||||
|
||||
const showDm = scope === "dm" || scope === "all";
|
||||
const showGroup = scope === "group" || scope === "all";
|
||||
|
||||
@@ -24,7 +24,9 @@ type ParsedApproveCommand =
|
||||
|
||||
function parseApproveCommand(raw: string): ParsedApproveCommand | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.toLowerCase().startsWith(COMMAND)) return null;
|
||||
if (!trimmed.toLowerCase().startsWith(COMMAND)) {
|
||||
return null;
|
||||
}
|
||||
const rest = trimmed.slice(COMMAND.length).trim();
|
||||
if (!rest) {
|
||||
return { ok: false, error: "Usage: /approve <id> allow-once|allow-always|deny" };
|
||||
@@ -61,10 +63,14 @@ function buildResolvedByLabel(params: Parameters<CommandHandler>[0]): string {
|
||||
}
|
||||
|
||||
export const handleApproveCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
}
|
||||
const normalized = params.command.commandBodyNormalized;
|
||||
const parsed = parseApproveCommand(normalized);
|
||||
if (!parsed) return null;
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /approve from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
|
||||
@@ -3,7 +3,9 @@ import { handleBashChatCommand } from "./bash-command.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
|
||||
export const handleBashCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
}
|
||||
const { command } = params;
|
||||
const bashSlashRequested =
|
||||
command.commandBodyNormalized === "/bash" || command.commandBodyNormalized.startsWith("/bash ");
|
||||
|
||||
@@ -25,12 +25,18 @@ function extractCompactInstructions(params: {
|
||||
? stripMentions(raw, params.ctx, params.cfg, params.agentId)
|
||||
: raw;
|
||||
const trimmed = stripped.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const lowered = trimmed.toLowerCase();
|
||||
const prefix = lowered.startsWith("/compact") ? "/compact" : null;
|
||||
if (!prefix) return undefined;
|
||||
if (!prefix) {
|
||||
return undefined;
|
||||
}
|
||||
let rest = trimmed.slice(prefix.length).trimStart();
|
||||
if (rest.startsWith(":")) rest = rest.slice(1).trimStart();
|
||||
if (rest.startsWith(":")) {
|
||||
rest = rest.slice(1).trimStart();
|
||||
}
|
||||
return rest.length ? rest : undefined;
|
||||
}
|
||||
|
||||
@@ -38,7 +44,9 @@ export const handleCompactCommand: CommandHandler = async (params) => {
|
||||
const compactRequested =
|
||||
params.command.commandBodyNormalized === "/compact" ||
|
||||
params.command.commandBodyNormalized.startsWith("/compact ");
|
||||
if (!compactRequested) return null;
|
||||
if (!compactRequested) {
|
||||
return null;
|
||||
}
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /compact from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
|
||||
@@ -23,9 +23,13 @@ import { parseConfigCommand } from "./config-commands.js";
|
||||
import { parseDebugCommand } from "./debug-commands.js";
|
||||
|
||||
export const handleConfigCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
}
|
||||
const configCommand = parseConfigCommand(params.command.commandBodyNormalized);
|
||||
if (!configCommand) return null;
|
||||
if (!configCommand) {
|
||||
return null;
|
||||
}
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /config from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
@@ -173,9 +177,13 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
|
||||
};
|
||||
|
||||
export const handleDebugCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
}
|
||||
const debugCommand = parseDebugCommand(params.command.commandBodyNormalized);
|
||||
if (!debugCommand) return null;
|
||||
if (!debugCommand) {
|
||||
return null;
|
||||
}
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /debug from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
|
||||
@@ -29,8 +29,12 @@ function formatCharsAndTokens(chars: number): string {
|
||||
}
|
||||
|
||||
function parseContextArgs(commandBodyNormalized: string): string {
|
||||
if (commandBodyNormalized === "/context") return "";
|
||||
if (commandBodyNormalized.startsWith("/context ")) return commandBodyNormalized.slice(8).trim();
|
||||
if (commandBodyNormalized === "/context") {
|
||||
return "";
|
||||
}
|
||||
if (commandBodyNormalized.startsWith("/context ")) {
|
||||
return commandBodyNormalized.slice(8).trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -49,7 +53,9 @@ async function resolveContextReport(
|
||||
params: HandleCommandsParams,
|
||||
): Promise<SessionSystemPromptReport> {
|
||||
const existing = params.sessionEntry?.systemPromptReport;
|
||||
if (existing && existing.source === "run") return existing;
|
||||
if (existing && existing.source === "run") {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const workspaceDir = params.workspaceDir;
|
||||
const bootstrapMaxChars = resolveBootstrapMaxChars(params.cfg);
|
||||
|
||||
@@ -110,7 +110,9 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
|
||||
|
||||
for (const handler of HANDLERS) {
|
||||
const result = await handler(params, allowTextCommands);
|
||||
if (result) return result;
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
const sendPolicy = resolveSendPolicy({
|
||||
|
||||
@@ -10,8 +10,12 @@ import { buildContextReply } from "./commands-context-report.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
|
||||
export const handleHelpCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (params.command.commandBodyNormalized !== "/help") return null;
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
}
|
||||
if (params.command.commandBodyNormalized !== "/help") {
|
||||
return null;
|
||||
}
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /help from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
@@ -25,8 +29,12 @@ export const handleHelpCommand: CommandHandler = async (params, allowTextCommand
|
||||
};
|
||||
|
||||
export const handleCommandsListCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (params.command.commandBodyNormalized !== "/commands") return null;
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
}
|
||||
if (params.command.commandBodyNormalized !== "/commands") {
|
||||
return null;
|
||||
}
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /commands from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
@@ -108,10 +116,14 @@ export function buildCommandsPaginationKeyboard(
|
||||
}
|
||||
|
||||
export const handleStatusCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
}
|
||||
const statusRequested =
|
||||
params.directives.hasStatusDirective || params.command.commandBodyNormalized === "/status";
|
||||
if (!statusRequested) return null;
|
||||
if (!statusRequested) {
|
||||
return null;
|
||||
}
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /status from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
@@ -140,9 +152,13 @@ export const handleStatusCommand: CommandHandler = async (params, allowTextComma
|
||||
};
|
||||
|
||||
export const handleContextCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
}
|
||||
const normalized = params.command.commandBodyNormalized;
|
||||
if (normalized !== "/context" && !normalized.startsWith("/context ")) return null;
|
||||
if (normalized !== "/context" && !normalized.startsWith("/context ")) {
|
||||
return null;
|
||||
}
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /context from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
@@ -153,8 +169,12 @@ export const handleContextCommand: CommandHandler = async (params, allowTextComm
|
||||
};
|
||||
|
||||
export const handleWhoamiCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (params.command.commandBodyNormalized !== "/whoami") return null;
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
}
|
||||
if (params.command.commandBodyNormalized !== "/whoami") {
|
||||
return null;
|
||||
}
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /whoami from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
@@ -164,7 +184,9 @@ export const handleWhoamiCommand: CommandHandler = async (params, allowTextComma
|
||||
const senderId = params.ctx.SenderId ?? "";
|
||||
const senderUsername = params.ctx.SenderUsername ?? "";
|
||||
const lines = ["🧭 Identity", `Channel: ${params.command.channel}`];
|
||||
if (senderId) lines.push(`User id: ${senderId}`);
|
||||
if (senderId) {
|
||||
lines.push(`User id: ${senderId}`);
|
||||
}
|
||||
if (senderUsername) {
|
||||
const handle = senderUsername.startsWith("@") ? senderUsername : `@${senderUsername}`;
|
||||
lines.push(`Username: ${handle}`);
|
||||
|
||||
@@ -42,12 +42,16 @@ function parseModelsArgs(raw: string): {
|
||||
}
|
||||
if (lower.startsWith("page=")) {
|
||||
const value = Number.parseInt(lower.slice("page=".length), 10);
|
||||
if (Number.isFinite(value) && value > 0) page = value;
|
||||
if (Number.isFinite(value) && value > 0) {
|
||||
page = value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (/^[0-9]+$/.test(lower)) {
|
||||
const value = Number.parseInt(lower, 10);
|
||||
if (Number.isFinite(value) && value > 0) page = value;
|
||||
if (Number.isFinite(value) && value > 0) {
|
||||
page = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +61,9 @@ function parseModelsArgs(raw: string): {
|
||||
if (lower.startsWith("limit=") || lower.startsWith("size=")) {
|
||||
const rawValue = lower.slice(lower.indexOf("=") + 1);
|
||||
const value = Number.parseInt(rawValue, 10);
|
||||
if (Number.isFinite(value) && value > 0) pageSize = Math.min(PAGE_SIZE_MAX, value);
|
||||
if (Number.isFinite(value) && value > 0) {
|
||||
pageSize = Math.min(PAGE_SIZE_MAX, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +80,9 @@ export async function resolveModelsCommandReply(params: {
|
||||
commandBodyNormalized: string;
|
||||
}): Promise<ReplyPayload | null> {
|
||||
const body = params.commandBodyNormalized.trim();
|
||||
if (!body.startsWith("/models")) return null;
|
||||
if (!body.startsWith("/models")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const argText = body.replace(/^\/models\b/i, "").trim();
|
||||
const { provider, page, pageSize, all } = parseModelsArgs(argText);
|
||||
@@ -108,13 +116,17 @@ export async function resolveModelsCommandReply(params: {
|
||||
|
||||
const addRawModelRef = (raw?: string) => {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) return;
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: trimmed,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
aliasIndex,
|
||||
});
|
||||
if (!resolved) return;
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
add(resolved.ref.provider, resolved.ref.model);
|
||||
};
|
||||
|
||||
@@ -232,12 +244,16 @@ export async function resolveModelsCommandReply(params: {
|
||||
}
|
||||
|
||||
export const handleModelsCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reply = await resolveModelsCommandReply({
|
||||
cfg: params.cfg,
|
||||
commandBodyNormalized: params.command.commandBodyNormalized,
|
||||
});
|
||||
if (!reply) return null;
|
||||
if (!reply) {
|
||||
return null;
|
||||
}
|
||||
return { reply, shouldContinue: false };
|
||||
};
|
||||
|
||||
@@ -19,11 +19,15 @@ export const handlePluginCommand: CommandHandler = async (
|
||||
): Promise<CommandHandlerResult | null> => {
|
||||
const { command, cfg } = params;
|
||||
|
||||
if (!allowTextCommands) return null;
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to match a plugin command
|
||||
const match = matchPluginCommand(command.commandBodyNormalized);
|
||||
if (!match) return null;
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Execute the plugin command (always returns a result)
|
||||
const result = await executePluginCommand({
|
||||
|
||||
@@ -22,9 +22,13 @@ function resolveSessionEntryForKey(
|
||||
store: Record<string, SessionEntry> | undefined,
|
||||
sessionKey: string | undefined,
|
||||
) {
|
||||
if (!store || !sessionKey) return {};
|
||||
if (!store || !sessionKey) {
|
||||
return {};
|
||||
}
|
||||
const direct = store[sessionKey];
|
||||
if (direct) return { entry: direct, key: sessionKey };
|
||||
if (direct) {
|
||||
return { entry: direct, key: sessionKey };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -36,7 +40,9 @@ function resolveAbortTarget(params: {
|
||||
}) {
|
||||
const targetSessionKey = params.ctx.CommandTargetSessionKey?.trim() || params.sessionKey;
|
||||
const { entry, key } = resolveSessionEntryForKey(params.sessionStore, targetSessionKey);
|
||||
if (entry && key) return { entry, key, sessionId: entry.sessionId };
|
||||
if (entry && key) {
|
||||
return { entry, key, sessionId: entry.sessionId };
|
||||
}
|
||||
if (params.sessionEntry && params.sessionKey) {
|
||||
return {
|
||||
entry: params.sessionEntry,
|
||||
@@ -48,9 +54,13 @@ function resolveAbortTarget(params: {
|
||||
}
|
||||
|
||||
export const handleActivationCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
}
|
||||
const activationCommand = parseActivationCommand(params.command.commandBodyNormalized);
|
||||
if (!activationCommand.hasCommand) return null;
|
||||
if (!activationCommand.hasCommand) {
|
||||
return null;
|
||||
}
|
||||
if (!params.isGroup) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
@@ -89,9 +99,13 @@ export const handleActivationCommand: CommandHandler = async (params, allowTextC
|
||||
};
|
||||
|
||||
export const handleSendPolicyCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
}
|
||||
const sendPolicyCommand = parseSendPolicyCommand(params.command.commandBodyNormalized);
|
||||
if (!sendPolicyCommand.hasCommand) return null;
|
||||
if (!sendPolicyCommand.hasCommand) {
|
||||
return null;
|
||||
}
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /send from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
@@ -131,9 +145,13 @@ export const handleSendPolicyCommand: CommandHandler = async (params, allowTextC
|
||||
};
|
||||
|
||||
export const handleUsageCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
}
|
||||
const normalized = params.command.commandBodyNormalized;
|
||||
if (normalized !== "/usage" && !normalized.startsWith("/usage ")) return null;
|
||||
if (normalized !== "/usage" && !normalized.startsWith("/usage ")) {
|
||||
return null;
|
||||
}
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /usage from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
@@ -195,8 +213,11 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman
|
||||
const next = requested ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off");
|
||||
|
||||
if (params.sessionEntry && params.sessionStore && params.sessionKey) {
|
||||
if (next === "off") delete params.sessionEntry.responseUsage;
|
||||
else params.sessionEntry.responseUsage = next;
|
||||
if (next === "off") {
|
||||
delete params.sessionEntry.responseUsage;
|
||||
} else {
|
||||
params.sessionEntry.responseUsage = next;
|
||||
}
|
||||
params.sessionEntry.updatedAt = Date.now();
|
||||
params.sessionStore[params.sessionKey] = params.sessionEntry;
|
||||
if (params.storePath) {
|
||||
@@ -215,8 +236,12 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman
|
||||
};
|
||||
|
||||
export const handleRestartCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (params.command.commandBodyNormalized !== "/restart") return null;
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
}
|
||||
if (params.command.commandBodyNormalized !== "/restart") {
|
||||
return null;
|
||||
}
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /restart from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
@@ -260,8 +285,12 @@ export const handleRestartCommand: CommandHandler = async (params, allowTextComm
|
||||
};
|
||||
|
||||
export const handleStopCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (params.command.commandBodyNormalized !== "/stop") return null;
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
}
|
||||
if (params.command.commandBodyNormalized !== "/stop") {
|
||||
return null;
|
||||
}
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /stop from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
@@ -319,8 +348,12 @@ export const handleStopCommand: CommandHandler = async (params, allowTextCommand
|
||||
};
|
||||
|
||||
export const handleAbortTrigger: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (!isAbortTrigger(params.command.rawBodyNormalized)) return null;
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
}
|
||||
if (!isAbortTrigger(params.command.rawBodyNormalized)) {
|
||||
return null;
|
||||
}
|
||||
const abortTarget = resolveAbortTarget({
|
||||
ctx: params.ctx,
|
||||
sessionKey: params.sessionKey,
|
||||
|
||||
@@ -34,7 +34,9 @@ import { resolveSubagentLabel } from "./subagents-utils.js";
|
||||
|
||||
function formatApiKeySnippet(apiKey: string): string {
|
||||
const compact = apiKey.replace(/\s+/g, "");
|
||||
if (!compact) return "unknown";
|
||||
if (!compact) {
|
||||
return "unknown";
|
||||
}
|
||||
const edge = compact.length >= 12 ? 6 : 4;
|
||||
const head = compact.slice(0, edge);
|
||||
const tail = compact.slice(-edge);
|
||||
@@ -48,7 +50,9 @@ function resolveModelAuthLabel(
|
||||
agentDir?: string,
|
||||
): string | undefined {
|
||||
const resolved = provider?.trim();
|
||||
if (!resolved) return undefined;
|
||||
if (!resolved) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const providerKey = normalizeProviderId(resolved);
|
||||
const store = ensureAuthProfileStore(agentDir, {
|
||||
@@ -161,7 +165,9 @@ export async function buildStatusReply(params: {
|
||||
maxWindows: 2,
|
||||
includeResets: true,
|
||||
});
|
||||
if (summaryLine) usageLine = `📊 Usage: ${summaryLine}`;
|
||||
if (summaryLine) {
|
||||
usageLine = `📊 Usage: ${summaryLine}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
usageLine = null;
|
||||
|
||||
@@ -36,18 +36,24 @@ const COMMAND = "/subagents";
|
||||
const ACTIONS = new Set(["list", "stop", "log", "send", "info", "help"]);
|
||||
|
||||
function formatTimestamp(valueMs?: number) {
|
||||
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) return "n/a";
|
||||
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
|
||||
return "n/a";
|
||||
}
|
||||
return new Date(valueMs).toISOString();
|
||||
}
|
||||
|
||||
function formatTimestampWithAge(valueMs?: number) {
|
||||
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) return "n/a";
|
||||
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
|
||||
return "n/a";
|
||||
}
|
||||
return `${formatTimestamp(valueMs)} (${formatAgeShort(Date.now() - valueMs)})`;
|
||||
}
|
||||
|
||||
function resolveRequesterSessionKey(params: Parameters<CommandHandler>[0]): string | undefined {
|
||||
const raw = params.sessionKey?.trim() || params.ctx.CommandTargetSessionKey?.trim();
|
||||
if (!raw) return undefined;
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const { mainKey, alias } = resolveMainSessionAlias(params.cfg);
|
||||
return resolveInternalSessionKey({ key: raw, alias, mainKey });
|
||||
}
|
||||
@@ -57,7 +63,9 @@ function resolveSubagentTarget(
|
||||
token: string | undefined,
|
||||
): SubagentTargetResolution {
|
||||
const trimmed = token?.trim();
|
||||
if (!trimmed) return { error: "Missing subagent id." };
|
||||
if (!trimmed) {
|
||||
return { error: "Missing subagent id." };
|
||||
}
|
||||
if (trimmed === "last") {
|
||||
const sorted = sortSubagentRuns(runs);
|
||||
return { entry: sorted[0] };
|
||||
@@ -75,7 +83,9 @@ function resolveSubagentTarget(
|
||||
return match ? { entry: match } : { error: `Unknown subagent session: ${trimmed}` };
|
||||
}
|
||||
const byRunId = runs.filter((entry) => entry.runId.startsWith(trimmed));
|
||||
if (byRunId.length === 1) return { entry: byRunId[0] };
|
||||
if (byRunId.length === 1) {
|
||||
return { entry: byRunId[0] };
|
||||
}
|
||||
if (byRunId.length > 1) {
|
||||
return { error: `Ambiguous run id prefix: ${trimmed}` };
|
||||
}
|
||||
@@ -117,11 +127,17 @@ export function extractMessageText(message: ChatMessage): { role: string; text:
|
||||
);
|
||||
return normalized ? { role, text: normalized } : null;
|
||||
}
|
||||
if (!Array.isArray(content)) return null;
|
||||
if (!Array.isArray(content)) {
|
||||
return null;
|
||||
}
|
||||
const chunks: string[] = [];
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") continue;
|
||||
if ((block as { type?: unknown }).type !== "text") continue;
|
||||
if (!block || typeof block !== "object") {
|
||||
continue;
|
||||
}
|
||||
if ((block as { type?: unknown }).type !== "text") {
|
||||
continue;
|
||||
}
|
||||
const text = (block as { text?: unknown }).text;
|
||||
if (typeof text === "string") {
|
||||
const value = shouldSanitize ? sanitizeTextContent(text) : text;
|
||||
@@ -138,7 +154,9 @@ function formatLogLines(messages: ChatMessage[]) {
|
||||
const lines: string[] = [];
|
||||
for (const msg of messages) {
|
||||
const extracted = extractMessageText(msg);
|
||||
if (!extracted) continue;
|
||||
if (!extracted) {
|
||||
continue;
|
||||
}
|
||||
const label = extracted.role === "assistant" ? "Assistant" : "User";
|
||||
lines.push(`${label}: ${extracted.text}`);
|
||||
}
|
||||
@@ -153,9 +171,13 @@ function loadSubagentSessionEntry(params: Parameters<CommandHandler>[0], childKe
|
||||
}
|
||||
|
||||
export const handleSubagentsCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
}
|
||||
const normalized = params.command.commandBodyNormalized;
|
||||
if (!normalized.startsWith(COMMAND)) return null;
|
||||
if (!normalized.startsWith(COMMAND)) {
|
||||
return null;
|
||||
}
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /subagents from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
@@ -361,7 +383,9 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo
|
||||
},
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
if (response?.runId) runId = response.runId;
|
||||
if (response?.runId) {
|
||||
runId = response.runId;
|
||||
}
|
||||
} catch (err) {
|
||||
const messageText =
|
||||
err instanceof Error ? err.message : typeof err === "string" ? err : "error";
|
||||
|
||||
@@ -26,10 +26,16 @@ type ParsedTtsCommand = {
|
||||
|
||||
function parseTtsCommand(normalized: string): ParsedTtsCommand | null {
|
||||
// Accept `/tts` and `/tts <action> [args]` as a single control surface.
|
||||
if (normalized === "/tts") return { action: "status", args: "" };
|
||||
if (!normalized.startsWith("/tts ")) return null;
|
||||
if (normalized === "/tts") {
|
||||
return { action: "status", args: "" };
|
||||
}
|
||||
if (!normalized.startsWith("/tts ")) {
|
||||
return null;
|
||||
}
|
||||
const rest = normalized.slice(5).trim();
|
||||
if (!rest) return { action: "status", args: "" };
|
||||
if (!rest) {
|
||||
return { action: "status", args: "" };
|
||||
}
|
||||
const [action, ...tail] = rest.split(/\s+/);
|
||||
return { action: action.toLowerCase(), args: tail.join(" ").trim() };
|
||||
}
|
||||
@@ -63,9 +69,13 @@ function ttsUsage(): ReplyPayload {
|
||||
}
|
||||
|
||||
export const handleTtsCommands: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseTtsCommand(params.command.commandBodyNormalized);
|
||||
if (!parsed) return null;
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
|
||||
@@ -8,12 +8,18 @@ export type ConfigCommand =
|
||||
|
||||
export function parseConfigCommand(raw: string): ConfigCommand | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.toLowerCase().startsWith("/config")) return null;
|
||||
if (!trimmed.toLowerCase().startsWith("/config")) {
|
||||
return null;
|
||||
}
|
||||
const rest = trimmed.slice("/config".length).trim();
|
||||
if (!rest) return { action: "show" };
|
||||
if (!rest) {
|
||||
return { action: "show" };
|
||||
}
|
||||
|
||||
const match = rest.match(/^(\S+)(?:\s+([\s\S]+))?$/);
|
||||
if (!match) return { action: "error", message: "Invalid /config syntax." };
|
||||
if (!match) {
|
||||
return { action: "error", message: "Invalid /config syntax." };
|
||||
}
|
||||
const action = match[1].toLowerCase();
|
||||
const args = (match[2] ?? "").trim();
|
||||
|
||||
@@ -23,7 +29,9 @@ export function parseConfigCommand(raw: string): ConfigCommand | null {
|
||||
case "get":
|
||||
return { action: "show", path: args || undefined };
|
||||
case "unset": {
|
||||
if (!args) return { action: "error", message: "Usage: /config unset path" };
|
||||
if (!args) {
|
||||
return { action: "error", message: "Usage: /config unset path" };
|
||||
}
|
||||
return { action: "unset", path: args };
|
||||
}
|
||||
case "set": {
|
||||
|
||||
@@ -3,7 +3,9 @@ export function parseConfigValue(raw: string): {
|
||||
error?: string;
|
||||
} {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { error: "Missing value." };
|
||||
if (!trimmed) {
|
||||
return { error: "Missing value." };
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
try {
|
||||
@@ -13,13 +15,21 @@ export function parseConfigValue(raw: string): {
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed === "true") return { value: true };
|
||||
if (trimmed === "false") return { value: false };
|
||||
if (trimmed === "null") return { value: null };
|
||||
if (trimmed === "true") {
|
||||
return { value: true };
|
||||
}
|
||||
if (trimmed === "false") {
|
||||
return { value: false };
|
||||
}
|
||||
if (trimmed === "null") {
|
||||
return { value: null };
|
||||
}
|
||||
|
||||
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
|
||||
const num = Number(trimmed);
|
||||
if (Number.isFinite(num)) return { value: num };
|
||||
if (Number.isFinite(num)) {
|
||||
return { value: num };
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -9,12 +9,18 @@ export type DebugCommand =
|
||||
|
||||
export function parseDebugCommand(raw: string): DebugCommand | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.toLowerCase().startsWith("/debug")) return null;
|
||||
if (!trimmed.toLowerCase().startsWith("/debug")) {
|
||||
return null;
|
||||
}
|
||||
const rest = trimmed.slice("/debug".length).trim();
|
||||
if (!rest) return { action: "show" };
|
||||
if (!rest) {
|
||||
return { action: "show" };
|
||||
}
|
||||
|
||||
const match = rest.match(/^(\S+)(?:\s+([\s\S]+))?$/);
|
||||
if (!match) return { action: "error", message: "Invalid /debug syntax." };
|
||||
if (!match) {
|
||||
return { action: "error", message: "Invalid /debug syntax." };
|
||||
}
|
||||
const action = match[1].toLowerCase();
|
||||
const args = (match[2] ?? "").trim();
|
||||
|
||||
@@ -24,7 +30,9 @@ export function parseDebugCommand(raw: string): DebugCommand | null {
|
||||
case "reset":
|
||||
return { action: "reset" };
|
||||
case "unset": {
|
||||
if (!args) return { action: "error", message: "Usage: /debug unset path" };
|
||||
if (!args) {
|
||||
return { action: "error", message: "Usage: /debug unset path" };
|
||||
}
|
||||
return { action: "unset", path: args };
|
||||
}
|
||||
case "set": {
|
||||
|
||||
@@ -17,8 +17,12 @@ export type ModelAuthDetailMode = "compact" | "verbose";
|
||||
|
||||
const maskApiKey = (value: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "missing";
|
||||
if (trimmed.length <= 16) return trimmed;
|
||||
if (!trimmed) {
|
||||
return "missing";
|
||||
}
|
||||
if (trimmed.length <= 16) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed.slice(0, 8)}...${trimmed.slice(-8)}`;
|
||||
};
|
||||
|
||||
@@ -37,9 +41,13 @@ export const resolveAuthLabel = async (
|
||||
const providerKey = normalizeProviderId(provider);
|
||||
const lastGood = (() => {
|
||||
const map = store.lastGood;
|
||||
if (!map) return undefined;
|
||||
if (!map) {
|
||||
return undefined;
|
||||
}
|
||||
for (const [key, value] of Object.entries(map)) {
|
||||
if (normalizeProviderId(key) === providerKey) return value;
|
||||
if (normalizeProviderId(key) === providerKey) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
@@ -49,10 +57,16 @@ export const resolveAuthLabel = async (
|
||||
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`;
|
||||
if (minutes < 1) {
|
||||
return "soon";
|
||||
}
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 48) return `${hours}h`;
|
||||
if (hours < 48) {
|
||||
return `${hours}h`;
|
||||
}
|
||||
const days = Math.round(hours / 24);
|
||||
return `${days}d`;
|
||||
};
|
||||
@@ -60,7 +74,9 @@ export const resolveAuthLabel = async (
|
||||
if (order.length > 0) {
|
||||
if (mode === "compact") {
|
||||
const profileId = nextProfileId;
|
||||
if (!profileId) return { label: "missing", source: "missing" };
|
||||
if (!profileId) {
|
||||
return { label: "missing", source: "missing" };
|
||||
}
|
||||
const profile = store.profiles[profileId];
|
||||
const configProfile = cfg.auth?.profiles?.[profileId];
|
||||
const missing =
|
||||
@@ -71,7 +87,9 @@ export const resolveAuthLabel = async (
|
||||
!(configProfile.mode === "oauth" && profile.type === "token"));
|
||||
|
||||
const more = order.length > 1 ? ` (+${order.length - 1})` : "";
|
||||
if (missing) return { label: `${profileId} missing${more}`, source: "" };
|
||||
if (missing) {
|
||||
return { label: `${profileId} missing${more}`, source: "" };
|
||||
}
|
||||
|
||||
if (profile.type === "api_key") {
|
||||
return {
|
||||
@@ -110,8 +128,12 @@ export const resolveAuthLabel = async (
|
||||
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 (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) {
|
||||
@@ -205,7 +227,9 @@ export const resolveProfileOverride = (params: {
|
||||
agentDir?: string;
|
||||
}): { profileId?: string; error?: string } => {
|
||||
const raw = params.rawProfile?.trim();
|
||||
if (!raw) return {};
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
const store = ensureAuthProfileStore(params.agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
|
||||
@@ -133,7 +133,9 @@ export async function handleDirectiveOnly(params: {
|
||||
allowedModelCatalog,
|
||||
resetModelOverride,
|
||||
});
|
||||
if (modelInfo) return modelInfo;
|
||||
if (modelInfo) {
|
||||
return modelInfo;
|
||||
}
|
||||
|
||||
const modelResolution = resolveModelSelectionFromDirective({
|
||||
directives,
|
||||
@@ -146,7 +148,9 @@ export async function handleDirectiveOnly(params: {
|
||||
allowedModelCatalog,
|
||||
provider,
|
||||
});
|
||||
if (modelResolution.errorText) return { text: modelResolution.errorText };
|
||||
if (modelResolution.errorText) {
|
||||
return { text: modelResolution.errorText };
|
||||
}
|
||||
const modelSelection = modelResolution.modelSelection;
|
||||
const profileOverride = modelResolution.profileOverride;
|
||||
|
||||
@@ -267,7 +271,9 @@ export async function handleDirectiveOnly(params: {
|
||||
channel: provider,
|
||||
sessionEntry,
|
||||
});
|
||||
if (queueAck) return queueAck;
|
||||
if (queueAck) {
|
||||
return queueAck;
|
||||
}
|
||||
|
||||
if (
|
||||
directives.hasThinkDirective &&
|
||||
@@ -301,8 +307,11 @@ export async function handleDirectiveOnly(params: {
|
||||
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 (directives.thinkLevel === "off") {
|
||||
delete sessionEntry.thinkingLevel;
|
||||
} else {
|
||||
sessionEntry.thinkingLevel = directives.thinkLevel;
|
||||
}
|
||||
}
|
||||
if (shouldDowngradeXHigh) {
|
||||
sessionEntry.thinkingLevel = "high";
|
||||
@@ -311,8 +320,11 @@ export async function handleDirectiveOnly(params: {
|
||||
applyVerboseOverride(sessionEntry, directives.verboseLevel);
|
||||
}
|
||||
if (directives.hasReasoningDirective && directives.reasoningLevel) {
|
||||
if (directives.reasoningLevel === "off") delete sessionEntry.reasoningLevel;
|
||||
else sessionEntry.reasoningLevel = directives.reasoningLevel;
|
||||
if (directives.reasoningLevel === "off") {
|
||||
delete sessionEntry.reasoningLevel;
|
||||
} else {
|
||||
sessionEntry.reasoningLevel = directives.reasoningLevel;
|
||||
}
|
||||
reasoningChanged =
|
||||
directives.reasoningLevel !== prevReasoningLevel && directives.reasoningLevel !== undefined;
|
||||
}
|
||||
@@ -351,7 +363,9 @@ export async function handleDirectiveOnly(params: {
|
||||
delete sessionEntry.queueCap;
|
||||
delete sessionEntry.queueDrop;
|
||||
} else if (directives.hasQueueDirective) {
|
||||
if (directives.queueMode) sessionEntry.queueMode = directives.queueMode;
|
||||
if (directives.queueMode) {
|
||||
sessionEntry.queueMode = directives.queueMode;
|
||||
}
|
||||
if (typeof directives.debounceMs === "number") {
|
||||
sessionEntry.queueDebounceMs = directives.debounceMs;
|
||||
}
|
||||
@@ -427,14 +441,24 @@ export async function handleDirectiveOnly(params: {
|
||||
? formatDirectiveAck("Elevated mode set to full (auto-approve).")
|
||||
: formatDirectiveAck("Elevated mode set to ask (approvals may still apply)."),
|
||||
);
|
||||
if (shouldHintDirectRuntime) parts.push(formatElevatedRuntimeHint());
|
||||
if (shouldHintDirectRuntime) {
|
||||
parts.push(formatElevatedRuntimeHint());
|
||||
}
|
||||
}
|
||||
if (directives.hasExecDirective && directives.hasExecOptions) {
|
||||
const execParts: string[] = [];
|
||||
if (directives.execHost) execParts.push(`host=${directives.execHost}`);
|
||||
if (directives.execSecurity) execParts.push(`security=${directives.execSecurity}`);
|
||||
if (directives.execAsk) execParts.push(`ask=${directives.execAsk}`);
|
||||
if (directives.execNode) execParts.push(`node=${directives.execNode}`);
|
||||
if (directives.execHost) {
|
||||
execParts.push(`host=${directives.execHost}`);
|
||||
}
|
||||
if (directives.execSecurity) {
|
||||
execParts.push(`security=${directives.execSecurity}`);
|
||||
}
|
||||
if (directives.execAsk) {
|
||||
execParts.push(`ask=${directives.execAsk}`);
|
||||
}
|
||||
if (directives.execNode) {
|
||||
execParts.push(`node=${directives.execNode}`);
|
||||
}
|
||||
if (execParts.length > 0) {
|
||||
parts.push(formatDirectiveAck(`Exec defaults set (${execParts.join(", ")}).`));
|
||||
}
|
||||
@@ -471,6 +495,8 @@ export async function handleDirectiveOnly(params: {
|
||||
parts.push(formatDirectiveAck(`Queue drop set to ${directives.dropPolicy}.`));
|
||||
}
|
||||
const ack = parts.join(" ").trim();
|
||||
if (!ack && directives.hasStatusDirective) return undefined;
|
||||
if (!ack && directives.hasStatusDirective) {
|
||||
return undefined;
|
||||
}
|
||||
return { text: ack || "OK." };
|
||||
}
|
||||
|
||||
@@ -34,9 +34,15 @@ const PROVIDER_RANK = new Map<string, number>(
|
||||
function compareProvidersForPicker(a: string, b: string): number {
|
||||
const pa = PROVIDER_RANK.get(a);
|
||||
const pb = PROVIDER_RANK.get(b);
|
||||
if (pa !== undefined && pb !== undefined) return pa - pb;
|
||||
if (pa !== undefined) return -1;
|
||||
if (pb !== undefined) return 1;
|
||||
if (pa !== undefined && pb !== undefined) {
|
||||
return pa - pb;
|
||||
}
|
||||
if (pa !== undefined) {
|
||||
return -1;
|
||||
}
|
||||
if (pb !== undefined) {
|
||||
return 1;
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
@@ -47,10 +53,14 @@ export function buildModelPickerItems(catalog: ModelPickerCatalogEntry[]): Model
|
||||
for (const entry of catalog) {
|
||||
const provider = normalizeProviderId(entry.provider);
|
||||
const model = entry.id?.trim();
|
||||
if (!provider || !model) continue;
|
||||
if (!provider || !model) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${provider}/${model}`;
|
||||
if (seen.has(key)) continue;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
|
||||
out.push({ model, provider });
|
||||
@@ -59,7 +69,9 @@ export function buildModelPickerItems(catalog: ModelPickerCatalogEntry[]): Model
|
||||
// Sort by provider preference first, then by model name
|
||||
out.sort((a, b) => {
|
||||
const providerOrder = compareProvidersForPicker(a.provider, b.provider);
|
||||
if (providerOrder !== 0) return providerOrder;
|
||||
if (providerOrder !== 0) {
|
||||
return providerOrder;
|
||||
}
|
||||
return a.model.toLowerCase().localeCompare(b.model.toLowerCase());
|
||||
});
|
||||
|
||||
|
||||
@@ -43,22 +43,30 @@ function buildModelPickerCatalog(params: {
|
||||
const pushRef = (ref: { provider: string; model: string }, name?: string) => {
|
||||
const provider = normalizeProviderId(ref.provider);
|
||||
const id = String(ref.model ?? "").trim();
|
||||
if (!provider || !id) return;
|
||||
if (!provider || !id) {
|
||||
return;
|
||||
}
|
||||
const key = modelKey(provider, id);
|
||||
if (keys.has(key)) return;
|
||||
if (keys.has(key)) {
|
||||
return;
|
||||
}
|
||||
keys.add(key);
|
||||
out.push({ provider, id, name: name ?? id });
|
||||
};
|
||||
|
||||
const pushRaw = (raw?: string) => {
|
||||
const value = String(raw ?? "").trim();
|
||||
if (!value) return;
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: value,
|
||||
defaultProvider: params.defaultProvider,
|
||||
aliasIndex: params.aliasIndex,
|
||||
});
|
||||
if (!resolved) return;
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
pushRef(resolved.ref);
|
||||
};
|
||||
|
||||
@@ -92,9 +100,13 @@ function buildModelPickerCatalog(params: {
|
||||
const push = (entry: ModelPickerCatalogEntry) => {
|
||||
const provider = normalizeProviderId(entry.provider);
|
||||
const id = String(entry.id ?? "").trim();
|
||||
if (!provider || !id) return;
|
||||
if (!provider || !id) {
|
||||
return;
|
||||
}
|
||||
const key = modelKey(provider, id);
|
||||
if (keys.has(key)) return;
|
||||
if (keys.has(key)) {
|
||||
return;
|
||||
}
|
||||
keys.add(key);
|
||||
out.push({ provider, id, name: entry.name });
|
||||
};
|
||||
@@ -131,7 +143,9 @@ function buildModelPickerCatalog(params: {
|
||||
defaultProvider: params.defaultProvider,
|
||||
aliasIndex: params.aliasIndex,
|
||||
});
|
||||
if (!resolved) continue;
|
||||
if (!resolved) {
|
||||
continue;
|
||||
}
|
||||
push({
|
||||
provider: resolved.ref.provider,
|
||||
id: resolved.ref.model,
|
||||
@@ -164,14 +178,18 @@ export async function maybeHandleModelDirectiveInfo(params: {
|
||||
allowedModelCatalog: Array<{ provider: string; id?: string; name?: string }>;
|
||||
resetModelOverride: boolean;
|
||||
}): Promise<ReplyPayload | undefined> {
|
||||
if (!params.directives.hasModelDirective) return undefined;
|
||||
if (!params.directives.hasModelDirective) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rawDirective = params.directives.rawModelDirective?.trim();
|
||||
const directive = rawDirective?.toLowerCase();
|
||||
const wantsStatus = directive === "status";
|
||||
const wantsSummary = !rawDirective;
|
||||
const wantsLegacyList = directive === "list";
|
||||
if (!wantsSummary && !wantsStatus && !wantsLegacyList) return undefined;
|
||||
if (!wantsSummary && !wantsStatus && !wantsLegacyList) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (params.directives.rawModelProfile) {
|
||||
return { text: "Auth profile override requires a model selection." };
|
||||
@@ -209,12 +227,16 @@ export async function maybeHandleModelDirectiveInfo(params: {
|
||||
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." };
|
||||
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;
|
||||
if (authByProvider.has(provider)) {
|
||||
continue;
|
||||
}
|
||||
const auth = await resolveAuthLabel(
|
||||
provider,
|
||||
params.cfg,
|
||||
@@ -250,7 +272,9 @@ export async function maybeHandleModelDirectiveInfo(params: {
|
||||
|
||||
for (const provider of byProvider.keys()) {
|
||||
const models = byProvider.get(provider);
|
||||
if (!models) continue;
|
||||
if (!models) {
|
||||
continue;
|
||||
}
|
||||
const authLabel = authByProvider.get(provider) ?? "missing";
|
||||
const endpoint = resolveProviderEndpointLabel(provider, params.cfg);
|
||||
const endpointSuffix = endpoint.endpoint
|
||||
|
||||
@@ -206,8 +206,9 @@ export function isDirectiveOnly(params: {
|
||||
!directives.hasExecDirective &&
|
||||
!directives.hasModelDirective &&
|
||||
!directives.hasQueueDirective
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const stripped = stripStructuralPrefixes(cleanedBody ?? "");
|
||||
const noMentions = isGroup ? stripMentions(stripped, ctx, cfg, agentId) : stripped;
|
||||
return noMentions.length === 0;
|
||||
|
||||
@@ -12,7 +12,9 @@ export function maybeHandleQueueDirective(params: {
|
||||
sessionEntry?: SessionEntry;
|
||||
}): ReplyPayload | undefined {
|
||||
const { directives } = params;
|
||||
if (!directives.hasQueueDirective) return undefined;
|
||||
if (!directives.hasQueueDirective) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const wantsStatus =
|
||||
!directives.queueMode &&
|
||||
|
||||
@@ -4,8 +4,12 @@ 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;
|
||||
if (!text) {
|
||||
return text;
|
||||
}
|
||||
if (text.startsWith(SYSTEM_MARK)) {
|
||||
return text;
|
||||
}
|
||||
return `${SYSTEM_MARK} ${text}`;
|
||||
};
|
||||
|
||||
@@ -27,8 +31,12 @@ export const formatElevatedEvent = (level: ElevatedLevel) => {
|
||||
};
|
||||
|
||||
export const formatReasoningEvent = (level: ReasoningLevel) => {
|
||||
if (level === "stream") return "Reasoning STREAM — emit live <think>.";
|
||||
if (level === "on") return "Reasoning ON — include <think>.";
|
||||
if (level === "stream") {
|
||||
return "Reasoning STREAM — emit live <think>.";
|
||||
}
|
||||
if (level === "on") {
|
||||
return "Reasoning ON — include <think>.";
|
||||
}
|
||||
return "Reasoning OFF — hide <think>.";
|
||||
};
|
||||
|
||||
|
||||
@@ -25,17 +25,25 @@ const matchLevelDirective = (
|
||||
): { start: number; end: number; rawLevel?: string } | null => {
|
||||
const namePattern = names.map(escapeRegExp).join("|");
|
||||
const match = body.match(new RegExp(`(?:^|\\s)\\/(?:${namePattern})(?=$|\\s|:)`, "i"));
|
||||
if (!match || match.index === undefined) return null;
|
||||
if (!match || match.index === undefined) {
|
||||
return null;
|
||||
}
|
||||
const start = match.index;
|
||||
let end = match.index + match[0].length;
|
||||
let i = end;
|
||||
while (i < body.length && /\s/.test(body[i])) i += 1;
|
||||
while (i < body.length && /\s/.test(body[i])) {
|
||||
i += 1;
|
||||
}
|
||||
if (body[i] === ":") {
|
||||
i += 1;
|
||||
while (i < body.length && /\s/.test(body[i])) i += 1;
|
||||
while (i < body.length && /\s/.test(body[i])) {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
const argStart = i;
|
||||
while (i < body.length && /[A-Za-z-]/.test(body[i])) i += 1;
|
||||
while (i < body.length && /[A-Za-z-]/.test(body[i])) {
|
||||
i += 1;
|
||||
}
|
||||
const rawLevel = i > argStart ? body.slice(argStart, i) : undefined;
|
||||
end = i;
|
||||
return { start, end, rawLevel };
|
||||
@@ -87,7 +95,9 @@ export function extractThinkDirective(body?: string): {
|
||||
rawLevel?: string;
|
||||
hasDirective: boolean;
|
||||
} {
|
||||
if (!body) return { cleaned: "", hasDirective: false };
|
||||
if (!body) {
|
||||
return { cleaned: "", hasDirective: false };
|
||||
}
|
||||
const extracted = extractLevelDirective(body, ["thinking", "think", "t"], normalizeThinkLevel);
|
||||
return {
|
||||
cleaned: extracted.cleaned,
|
||||
@@ -103,7 +113,9 @@ export function extractVerboseDirective(body?: string): {
|
||||
rawLevel?: string;
|
||||
hasDirective: boolean;
|
||||
} {
|
||||
if (!body) return { cleaned: "", hasDirective: false };
|
||||
if (!body) {
|
||||
return { cleaned: "", hasDirective: false };
|
||||
}
|
||||
const extracted = extractLevelDirective(body, ["verbose", "v"], normalizeVerboseLevel);
|
||||
return {
|
||||
cleaned: extracted.cleaned,
|
||||
@@ -119,7 +131,9 @@ export function extractNoticeDirective(body?: string): {
|
||||
rawLevel?: string;
|
||||
hasDirective: boolean;
|
||||
} {
|
||||
if (!body) return { cleaned: "", hasDirective: false };
|
||||
if (!body) {
|
||||
return { cleaned: "", hasDirective: false };
|
||||
}
|
||||
const extracted = extractLevelDirective(body, ["notice", "notices"], normalizeNoticeLevel);
|
||||
return {
|
||||
cleaned: extracted.cleaned,
|
||||
@@ -135,7 +149,9 @@ export function extractElevatedDirective(body?: string): {
|
||||
rawLevel?: string;
|
||||
hasDirective: boolean;
|
||||
} {
|
||||
if (!body) return { cleaned: "", hasDirective: false };
|
||||
if (!body) {
|
||||
return { cleaned: "", hasDirective: false };
|
||||
}
|
||||
const extracted = extractLevelDirective(body, ["elevated", "elev"], normalizeElevatedLevel);
|
||||
return {
|
||||
cleaned: extracted.cleaned,
|
||||
@@ -151,7 +167,9 @@ export function extractReasoningDirective(body?: string): {
|
||||
rawLevel?: string;
|
||||
hasDirective: boolean;
|
||||
} {
|
||||
if (!body) return { cleaned: "", hasDirective: false };
|
||||
if (!body) {
|
||||
return { cleaned: "", hasDirective: false };
|
||||
}
|
||||
const extracted = extractLevelDirective(body, ["reasoning", "reason"], normalizeReasoningLevel);
|
||||
return {
|
||||
cleaned: extracted.cleaned,
|
||||
@@ -165,7 +183,9 @@ export function extractStatusDirective(body?: string): {
|
||||
cleaned: string;
|
||||
hasDirective: boolean;
|
||||
} {
|
||||
if (!body) return { cleaned: "", hasDirective: false };
|
||||
if (!body) {
|
||||
return { cleaned: "", hasDirective: false };
|
||||
}
|
||||
return extractSimpleDirective(body, ["status"]);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,9 @@ const isInboundAudioContext = (ctx: FinalizedMsgContext): boolean => {
|
||||
...(Array.isArray(ctx.MediaTypes) ? ctx.MediaTypes : []),
|
||||
].filter(Boolean) as string[];
|
||||
const types = rawTypes.map((type) => normalizeMediaType(type));
|
||||
if (types.some((type) => type === "audio" || type.startsWith("audio/"))) return true;
|
||||
if (types.some((type) => type === "audio" || type.startsWith("audio/"))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const body =
|
||||
typeof ctx.BodyForCommands === "string"
|
||||
@@ -42,8 +44,12 @@ const isInboundAudioContext = (ctx: FinalizedMsgContext): boolean => {
|
||||
? ctx.Body
|
||||
: "";
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return false;
|
||||
if (AUDIO_PLACEHOLDER_RE.test(trimmed)) return true;
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
if (AUDIO_PLACEHOLDER_RE.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
return AUDIO_HEADER_RE.test(trimmed);
|
||||
};
|
||||
|
||||
@@ -54,7 +60,9 @@ const resolveSessionTtsAuto = (
|
||||
const targetSessionKey =
|
||||
ctx.CommandSource === "native" ? ctx.CommandTargetSessionKey?.trim() : undefined;
|
||||
const sessionKey = (targetSessionKey ?? ctx.SessionKey)?.trim();
|
||||
if (!sessionKey) return undefined;
|
||||
if (!sessionKey) {
|
||||
return undefined;
|
||||
}
|
||||
const agentId = resolveSessionAgentId({ sessionKey, config: cfg });
|
||||
const storePath = resolveStorePath(cfg.session?.store, { agentId });
|
||||
try {
|
||||
@@ -94,7 +102,9 @@ export async function dispatchReplyFromConfig(params: {
|
||||
error?: string;
|
||||
},
|
||||
) => {
|
||||
if (!diagnosticsEnabled) return;
|
||||
if (!diagnosticsEnabled) {
|
||||
return;
|
||||
}
|
||||
logMessageProcessed({
|
||||
channel,
|
||||
chatId,
|
||||
@@ -108,7 +118,9 @@ export async function dispatchReplyFromConfig(params: {
|
||||
};
|
||||
|
||||
const markProcessing = () => {
|
||||
if (!canTrackSession || !sessionKey) return;
|
||||
if (!canTrackSession || !sessionKey) {
|
||||
return;
|
||||
}
|
||||
logMessageQueued({ sessionKey, channel, source: "dispatch" });
|
||||
logSessionStateChange({
|
||||
sessionKey,
|
||||
@@ -118,7 +130,9 @@ export async function dispatchReplyFromConfig(params: {
|
||||
};
|
||||
|
||||
const markIdle = (reason: string) => {
|
||||
if (!canTrackSession || !sessionKey) return;
|
||||
if (!canTrackSession || !sessionKey) {
|
||||
return;
|
||||
}
|
||||
logSessionStateChange({
|
||||
sessionKey,
|
||||
state: "idle",
|
||||
@@ -210,8 +224,12 @@ export async function dispatchReplyFromConfig(params: {
|
||||
): Promise<void> => {
|
||||
// TypeScript doesn't narrow these from the shouldRouteToOriginating check,
|
||||
// but they're guaranteed non-null when this function is called.
|
||||
if (!originatingChannel || !originatingTo) return;
|
||||
if (abortSignal?.aborted) return;
|
||||
if (!originatingChannel || !originatingTo) {
|
||||
return;
|
||||
}
|
||||
if (abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
const result = await routeReply({
|
||||
payload,
|
||||
channel: originatingChannel,
|
||||
@@ -249,7 +267,9 @@ export async function dispatchReplyFromConfig(params: {
|
||||
cfg,
|
||||
});
|
||||
queuedFinal = result.ok;
|
||||
if (result.ok) routedFinalCount += 1;
|
||||
if (result.ok) {
|
||||
routedFinalCount += 1;
|
||||
}
|
||||
if (!result.ok) {
|
||||
logVerbose(
|
||||
`dispatch-from-config: route-reply (abort) failed: ${result.error ?? "unknown error"}`,
|
||||
@@ -357,7 +377,9 @@ export async function dispatchReplyFromConfig(params: {
|
||||
);
|
||||
}
|
||||
queuedFinal = result.ok || queuedFinal;
|
||||
if (result.ok) routedFinalCount += 1;
|
||||
if (result.ok) {
|
||||
routedFinalCount += 1;
|
||||
}
|
||||
} else {
|
||||
queuedFinal = dispatcher.sendFinalReply(ttsReply) || queuedFinal;
|
||||
}
|
||||
@@ -400,7 +422,9 @@ export async function dispatchReplyFromConfig(params: {
|
||||
cfg,
|
||||
});
|
||||
queuedFinal = result.ok || queuedFinal;
|
||||
if (result.ok) routedFinalCount += 1;
|
||||
if (result.ok) {
|
||||
routedFinalCount += 1;
|
||||
}
|
||||
if (!result.ok) {
|
||||
logVerbose(
|
||||
`dispatch-from-config: route-reply (tts-only) failed: ${result.error ?? "unknown error"}`,
|
||||
|
||||
@@ -20,15 +20,17 @@ type ExecDirectiveParse = {
|
||||
|
||||
function normalizeExecHost(value?: string): ExecHost | undefined {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (normalized === "sandbox" || normalized === "gateway" || normalized === "node")
|
||||
if (normalized === "sandbox" || normalized === "gateway" || normalized === "node") {
|
||||
return normalized;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeExecSecurity(value?: string): ExecSecurity | undefined {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (normalized === "deny" || normalized === "allowlist" || normalized === "full")
|
||||
if (normalized === "deny" || normalized === "allowlist" || normalized === "full") {
|
||||
return normalized;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -48,10 +50,14 @@ function parseExecDirectiveArgs(raw: string): Omit<
|
||||
} {
|
||||
let i = 0;
|
||||
const len = raw.length;
|
||||
while (i < len && /\s/.test(raw[i])) i += 1;
|
||||
while (i < len && /\s/.test(raw[i])) {
|
||||
i += 1;
|
||||
}
|
||||
if (raw[i] === ":") {
|
||||
i += 1;
|
||||
while (i < len && /\s/.test(raw[i])) i += 1;
|
||||
while (i < len && /\s/.test(raw[i])) {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
let consumed = i;
|
||||
let execHost: ExecHost | undefined;
|
||||
@@ -69,12 +75,20 @@ function parseExecDirectiveArgs(raw: string): Omit<
|
||||
let invalidNode = false;
|
||||
|
||||
const takeToken = (): string | null => {
|
||||
if (i >= len) return null;
|
||||
if (i >= len) {
|
||||
return null;
|
||||
}
|
||||
const start = i;
|
||||
while (i < len && !/\s/.test(raw[i])) i += 1;
|
||||
if (start === i) return null;
|
||||
while (i < len && !/\s/.test(raw[i])) {
|
||||
i += 1;
|
||||
}
|
||||
if (start === i) {
|
||||
return null;
|
||||
}
|
||||
const token = raw.slice(start, i);
|
||||
while (i < len && /\s/.test(raw[i])) i += 1;
|
||||
while (i < len && /\s/.test(raw[i])) {
|
||||
i += 1;
|
||||
}
|
||||
return token;
|
||||
};
|
||||
|
||||
@@ -82,23 +96,33 @@ function parseExecDirectiveArgs(raw: string): Omit<
|
||||
const eq = token.indexOf("=");
|
||||
const colon = token.indexOf(":");
|
||||
const idx = eq === -1 ? colon : colon === -1 ? eq : Math.min(eq, colon);
|
||||
if (idx === -1) return null;
|
||||
if (idx === -1) {
|
||||
return null;
|
||||
}
|
||||
const key = token.slice(0, idx).trim().toLowerCase();
|
||||
const value = token.slice(idx + 1).trim();
|
||||
if (!key) return null;
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
return { key, value };
|
||||
};
|
||||
|
||||
while (i < len) {
|
||||
const token = takeToken();
|
||||
if (!token) break;
|
||||
if (!token) {
|
||||
break;
|
||||
}
|
||||
const parsed = splitToken(token);
|
||||
if (!parsed) break;
|
||||
if (!parsed) {
|
||||
break;
|
||||
}
|
||||
const { key, value } = parsed;
|
||||
if (key === "host") {
|
||||
rawExecHost = value;
|
||||
execHost = normalizeExecHost(value);
|
||||
if (!execHost) invalidHost = true;
|
||||
if (!execHost) {
|
||||
invalidHost = true;
|
||||
}
|
||||
hasExecOptions = true;
|
||||
consumed = i;
|
||||
continue;
|
||||
@@ -106,7 +130,9 @@ function parseExecDirectiveArgs(raw: string): Omit<
|
||||
if (key === "security") {
|
||||
rawExecSecurity = value;
|
||||
execSecurity = normalizeExecSecurity(value);
|
||||
if (!execSecurity) invalidSecurity = true;
|
||||
if (!execSecurity) {
|
||||
invalidSecurity = true;
|
||||
}
|
||||
hasExecOptions = true;
|
||||
consumed = i;
|
||||
continue;
|
||||
@@ -114,7 +140,9 @@ function parseExecDirectiveArgs(raw: string): Omit<
|
||||
if (key === "ask") {
|
||||
rawExecAsk = value;
|
||||
execAsk = normalizeExecAsk(value);
|
||||
if (!execAsk) invalidAsk = true;
|
||||
if (!execAsk) {
|
||||
invalidAsk = true;
|
||||
}
|
||||
hasExecOptions = true;
|
||||
consumed = i;
|
||||
continue;
|
||||
|
||||
@@ -172,7 +172,9 @@ export function createFollowupRunner(params: {
|
||||
runId,
|
||||
blockReplyBreak: queued.run.blockReplyBreak,
|
||||
onAgentEvent: (evt) => {
|
||||
if (evt.stream !== "compaction") return;
|
||||
if (evt.stream !== "compaction") {
|
||||
return;
|
||||
}
|
||||
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||
const willRetry = Boolean(evt.data.willRetry);
|
||||
if (phase === "end" && !willRetry) {
|
||||
@@ -212,13 +214,19 @@ export function createFollowupRunner(params: {
|
||||
}
|
||||
|
||||
const payloadArray = runResult.payloads ?? [];
|
||||
if (payloadArray.length === 0) return;
|
||||
if (payloadArray.length === 0) {
|
||||
return;
|
||||
}
|
||||
const sanitizedPayloads = payloadArray.flatMap((payload) => {
|
||||
const text = payload.text;
|
||||
if (!text || !text.includes("HEARTBEAT_OK")) return [payload];
|
||||
if (!text || !text.includes("HEARTBEAT_OK")) {
|
||||
return [payload];
|
||||
}
|
||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||
if (stripped.shouldSkip && !hasMedia) return [];
|
||||
if (stripped.shouldSkip && !hasMedia) {
|
||||
return [];
|
||||
}
|
||||
return [{ ...payload, text: stripped.text }];
|
||||
});
|
||||
const replyToChannel =
|
||||
@@ -249,7 +257,9 @@ export function createFollowupRunner(params: {
|
||||
});
|
||||
const finalPayloads = suppressMessagingToolReplies ? [] : dedupedPayloads;
|
||||
|
||||
if (finalPayloads.length === 0) return;
|
||||
if (finalPayloads.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoCompactionCompleted) {
|
||||
const count = await incrementCompactionCount({
|
||||
|
||||
@@ -73,7 +73,9 @@ function resolveExecOverrides(params: {
|
||||
(params.sessionEntry?.execSecurity as ExecOverrides["security"]);
|
||||
const ask = params.directives.execAsk ?? (params.sessionEntry?.execAsk as ExecOverrides["ask"]);
|
||||
const node = params.directives.execNode ?? params.sessionEntry?.execNode;
|
||||
if (!host && !security && !ask && !node) return undefined;
|
||||
if (!host && !security && !ask && !node) {
|
||||
return undefined;
|
||||
}
|
||||
return { host, security, ask, node };
|
||||
}
|
||||
|
||||
@@ -270,7 +272,9 @@ export async function resolveReplyDirectives(params: {
|
||||
};
|
||||
const existingBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
||||
let cleanedBody = (() => {
|
||||
if (!existingBody) return parsedDirectives.cleaned;
|
||||
if (!existingBody) {
|
||||
return parsedDirectives.cleaned;
|
||||
}
|
||||
if (!sessionCtx.CommandBody && !sessionCtx.RawBody) {
|
||||
return parseInlineDirectives(existingBody, {
|
||||
modelAliases: configuredAliases,
|
||||
|
||||
@@ -26,17 +26,23 @@ export type InlineActionResult =
|
||||
};
|
||||
|
||||
function extractTextFromToolResult(result: any): string | null {
|
||||
if (!result || typeof result !== "object") return null;
|
||||
if (!result || typeof result !== "object") {
|
||||
return null;
|
||||
}
|
||||
const content = (result as { content?: unknown }).content;
|
||||
if (typeof content === "string") {
|
||||
const trimmed = content.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
if (!Array.isArray(content)) return null;
|
||||
if (!Array.isArray(content)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") continue;
|
||||
if (!block || typeof block !== "object") {
|
||||
continue;
|
||||
}
|
||||
const rec = block as { type?: unknown; text?: unknown };
|
||||
if (rec.type === "text" && typeof rec.text === "string") {
|
||||
parts.push(rec.text);
|
||||
@@ -212,8 +218,12 @@ export async function handleInlineActions(params: {
|
||||
}
|
||||
|
||||
const sendInlineReply = async (reply?: ReplyPayload) => {
|
||||
if (!reply) return;
|
||||
if (!opts?.onBlockReply) return;
|
||||
if (!reply) {
|
||||
return;
|
||||
}
|
||||
if (!opts?.onBlockReply) {
|
||||
return;
|
||||
}
|
||||
await opts.onBlockReply(reply);
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ import type { TemplateContext } from "../templating.js";
|
||||
|
||||
function extractGroupId(raw: string | undefined | null): string | undefined {
|
||||
const trimmed = (raw ?? "").trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const parts = trimmed.split(":").filter(Boolean);
|
||||
if (parts.length >= 3 && (parts[1] === "group" || parts[1] === "channel")) {
|
||||
return parts.slice(2).join(":") || undefined;
|
||||
@@ -34,7 +36,9 @@ export function resolveGroupRequireMention(params: {
|
||||
const { cfg, ctx, groupResolution } = params;
|
||||
const rawChannel = groupResolution?.channel ?? ctx.Provider?.trim();
|
||||
const channel = normalizeChannelId(rawChannel);
|
||||
if (!channel) return true;
|
||||
if (!channel) {
|
||||
return true;
|
||||
}
|
||||
const groupId = groupResolution?.id ?? extractGroupId(ctx.From);
|
||||
const groupChannel = ctx.GroupChannel?.trim() ?? ctx.GroupSubject?.trim();
|
||||
const groupSpace = ctx.GroupSpace?.trim();
|
||||
@@ -45,7 +49,9 @@ export function resolveGroupRequireMention(params: {
|
||||
groupSpace,
|
||||
accountId: ctx.AccountId,
|
||||
});
|
||||
if (typeof requireMention === "boolean") return requireMention;
|
||||
if (typeof requireMention === "boolean") {
|
||||
return requireMention;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -68,9 +74,15 @@ export function buildGroupIntro(params: {
|
||||
const providerKey = rawProvider?.toLowerCase() ?? "";
|
||||
const providerId = normalizeChannelId(rawProvider);
|
||||
const providerLabel = (() => {
|
||||
if (!providerKey) return "chat";
|
||||
if (isInternalMessageChannel(providerKey)) return "WebChat";
|
||||
if (providerId) return getChannelPlugin(providerId)?.meta.label ?? providerId;
|
||||
if (!providerKey) {
|
||||
return "chat";
|
||||
}
|
||||
if (isInternalMessageChannel(providerKey)) {
|
||||
return "WebChat";
|
||||
}
|
||||
if (providerId) {
|
||||
return getChannelPlugin(providerId)?.meta.label ?? providerId;
|
||||
}
|
||||
return `${providerKey.at(0)?.toUpperCase() ?? ""}${providerKey.slice(1)}`;
|
||||
})();
|
||||
const subjectLine = subject
|
||||
|
||||
@@ -14,12 +14,16 @@ export function evictOldHistoryKeys<T>(
|
||||
historyMap: Map<string, T[]>,
|
||||
maxKeys: number = MAX_HISTORY_KEYS,
|
||||
): void {
|
||||
if (historyMap.size <= maxKeys) return;
|
||||
if (historyMap.size <= maxKeys) {
|
||||
return;
|
||||
}
|
||||
const keysToDelete = historyMap.size - maxKeys;
|
||||
const iterator = historyMap.keys();
|
||||
for (let i = 0; i < keysToDelete; i++) {
|
||||
const key = iterator.next().value;
|
||||
if (key !== undefined) historyMap.delete(key);
|
||||
if (key !== undefined) {
|
||||
historyMap.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +41,9 @@ export function buildHistoryContext(params: {
|
||||
}): string {
|
||||
const { historyText, currentMessage } = params;
|
||||
const lineBreak = params.lineBreak ?? "\n";
|
||||
if (!historyText.trim()) return currentMessage;
|
||||
if (!historyText.trim()) {
|
||||
return currentMessage;
|
||||
}
|
||||
return [HISTORY_CONTEXT_MARKER, historyText, "", CURRENT_MESSAGE_MARKER, currentMessage].join(
|
||||
lineBreak,
|
||||
);
|
||||
@@ -50,10 +56,14 @@ export function appendHistoryEntry<T extends HistoryEntry>(params: {
|
||||
limit: number;
|
||||
}): T[] {
|
||||
const { historyMap, historyKey, entry } = params;
|
||||
if (params.limit <= 0) return [];
|
||||
if (params.limit <= 0) {
|
||||
return [];
|
||||
}
|
||||
const history = historyMap.get(historyKey) ?? [];
|
||||
history.push(entry);
|
||||
while (history.length > params.limit) history.shift();
|
||||
while (history.length > params.limit) {
|
||||
history.shift();
|
||||
}
|
||||
if (historyMap.has(historyKey)) {
|
||||
// Refresh insertion order so eviction keeps recently used histories.
|
||||
historyMap.delete(historyKey);
|
||||
@@ -79,8 +89,12 @@ export function recordPendingHistoryEntryIfEnabled<T extends HistoryEntry>(param
|
||||
entry?: T | null;
|
||||
limit: number;
|
||||
}): T[] {
|
||||
if (!params.entry) return [];
|
||||
if (params.limit <= 0) return [];
|
||||
if (!params.entry) {
|
||||
return [];
|
||||
}
|
||||
if (params.limit <= 0) {
|
||||
return [];
|
||||
}
|
||||
return recordPendingHistoryEntry({
|
||||
historyMap: params.historyMap,
|
||||
historyKey: params.historyKey,
|
||||
@@ -97,7 +111,9 @@ export function buildPendingHistoryContextFromMap(params: {
|
||||
formatEntry: (entry: HistoryEntry) => string;
|
||||
lineBreak?: string;
|
||||
}): string {
|
||||
if (params.limit <= 0) return params.currentMessage;
|
||||
if (params.limit <= 0) {
|
||||
return params.currentMessage;
|
||||
}
|
||||
const entries = params.historyMap.get(params.historyKey) ?? [];
|
||||
return buildHistoryContextFromEntries({
|
||||
entries,
|
||||
@@ -118,7 +134,9 @@ export function buildHistoryContextFromMap(params: {
|
||||
lineBreak?: string;
|
||||
excludeLast?: boolean;
|
||||
}): string {
|
||||
if (params.limit <= 0) return params.currentMessage;
|
||||
if (params.limit <= 0) {
|
||||
return params.currentMessage;
|
||||
}
|
||||
const entries = params.entry
|
||||
? appendHistoryEntry({
|
||||
historyMap: params.historyMap,
|
||||
@@ -148,7 +166,9 @@ export function clearHistoryEntriesIfEnabled(params: {
|
||||
historyKey: string;
|
||||
limit: number;
|
||||
}): void {
|
||||
if (params.limit <= 0) return;
|
||||
if (params.limit <= 0) {
|
||||
return;
|
||||
}
|
||||
clearHistoryEntries({ historyMap: params.historyMap, historyKey: params.historyKey });
|
||||
}
|
||||
|
||||
@@ -161,7 +181,9 @@ export function buildHistoryContextFromEntries(params: {
|
||||
}): string {
|
||||
const lineBreak = params.lineBreak ?? "\n";
|
||||
const entries = params.excludeLast === false ? params.entries : params.entries.slice(0, -1);
|
||||
if (entries.length === 0) return params.currentMessage;
|
||||
if (entries.length === 0) {
|
||||
return params.currentMessage;
|
||||
}
|
||||
const historyText = entries.map(params.formatEntry).join(lineBreak);
|
||||
return buildHistoryContext({
|
||||
historyText,
|
||||
|
||||
@@ -12,7 +12,9 @@ export type FinalizeInboundContextOptions = {
|
||||
};
|
||||
|
||||
function normalizeTextField(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeInboundTextNewlines(value);
|
||||
}
|
||||
|
||||
@@ -51,7 +53,9 @@ export function finalizeInboundContext<T extends Record<string, unknown>>(
|
||||
const explicitLabel = normalized.ConversationLabel?.trim();
|
||||
if (opts.forceConversationLabel || !explicitLabel) {
|
||||
const resolved = resolveConversationLabel(normalized)?.trim();
|
||||
if (resolved) normalized.ConversationLabel = resolved;
|
||||
if (resolved) {
|
||||
normalized.ConversationLabel = resolved;
|
||||
}
|
||||
} else {
|
||||
normalized.ConversationLabel = explicitLabel;
|
||||
}
|
||||
|
||||
@@ -18,9 +18,13 @@ const resolveInboundPeerId = (ctx: MsgContext) =>
|
||||
export function buildInboundDedupeKey(ctx: MsgContext): string | null {
|
||||
const provider = normalizeProvider(ctx.OriginatingChannel ?? ctx.Provider ?? ctx.Surface);
|
||||
const messageId = ctx.MessageSid?.trim();
|
||||
if (!provider || !messageId) return null;
|
||||
if (!provider || !messageId) {
|
||||
return null;
|
||||
}
|
||||
const peerId = resolveInboundPeerId(ctx);
|
||||
if (!peerId) return null;
|
||||
if (!peerId) {
|
||||
return null;
|
||||
}
|
||||
const sessionKey = ctx.SessionKey?.trim() ?? "";
|
||||
const accountId = ctx.AccountId?.trim() ?? "";
|
||||
const threadId =
|
||||
@@ -35,7 +39,9 @@ export function shouldSkipDuplicateInbound(
|
||||
opts?: { cache?: DedupeCache; now?: number },
|
||||
): boolean {
|
||||
const key = buildInboundDedupeKey(ctx);
|
||||
if (!key) return false;
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
const cache = opts?.cache ?? inboundDedupeCache;
|
||||
const skipped = cache.check(key, opts?.now);
|
||||
if (skipped && shouldLogVerbose()) {
|
||||
|
||||
@@ -4,10 +4,16 @@ import { listSenderLabelCandidates, resolveSenderLabel } from "../../channels/se
|
||||
|
||||
export function formatInboundBodyWithSenderMeta(params: { body: string; ctx: MsgContext }): string {
|
||||
const body = params.body;
|
||||
if (!body.trim()) return body;
|
||||
if (!body.trim()) {
|
||||
return body;
|
||||
}
|
||||
const chatType = normalizeChatType(params.ctx.ChatType);
|
||||
if (!chatType || chatType === "direct") return body;
|
||||
if (hasSenderMetaLine(body, params.ctx)) return body;
|
||||
if (!chatType || chatType === "direct") {
|
||||
return body;
|
||||
}
|
||||
if (hasSenderMetaLine(body, params.ctx)) {
|
||||
return body;
|
||||
}
|
||||
|
||||
const senderLabel = resolveSenderLabel({
|
||||
name: params.ctx.SenderName,
|
||||
@@ -16,13 +22,17 @@ export function formatInboundBodyWithSenderMeta(params: { body: string; ctx: Msg
|
||||
e164: params.ctx.SenderE164,
|
||||
id: params.ctx.SenderId,
|
||||
});
|
||||
if (!senderLabel) return body;
|
||||
if (!senderLabel) {
|
||||
return body;
|
||||
}
|
||||
|
||||
return `${body}\n[from: ${senderLabel}]`;
|
||||
}
|
||||
|
||||
function hasSenderMetaLine(body: string, ctx: MsgContext): boolean {
|
||||
if (/(^|\n)\[from:/i.test(body)) return true;
|
||||
if (/(^|\n)\[from:/i.test(body)) {
|
||||
return true;
|
||||
}
|
||||
const candidates = listSenderLabelCandidates({
|
||||
name: ctx.SenderName,
|
||||
username: ctx.SenderUsername,
|
||||
@@ -30,7 +40,9 @@ function hasSenderMetaLine(body: string, ctx: MsgContext): boolean {
|
||||
e164: ctx.SenderE164,
|
||||
id: ctx.SenderId,
|
||||
});
|
||||
if (candidates.length === 0) return false;
|
||||
if (candidates.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return candidates.some((candidate) => {
|
||||
const escaped = escapeRegExp(candidate);
|
||||
// Envelope bodies look like "[Signal ...] Alice: hi".
|
||||
|
||||
@@ -26,7 +26,9 @@ import {
|
||||
*/
|
||||
export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
let text = payload.text;
|
||||
if (!text) return payload;
|
||||
if (!text) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const result: ReplyPayload = { ...payload };
|
||||
const lineData: LineChannelData = {
|
||||
@@ -121,9 +123,13 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
// Find first colon delimiter, ignoring URLs without a label.
|
||||
const colonIndex = (() => {
|
||||
const index = trimmed.indexOf(":");
|
||||
if (index === -1) return -1;
|
||||
if (index === -1) {
|
||||
return -1;
|
||||
}
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.startsWith("http://") || lower.startsWith("https://")) return -1;
|
||||
if (lower.startsWith("http://") || lower.startsWith("https://")) {
|
||||
return -1;
|
||||
}
|
||||
return index;
|
||||
})();
|
||||
|
||||
|
||||
@@ -28,7 +28,9 @@ export type MemoryFlushSettings = {
|
||||
};
|
||||
|
||||
const normalizeNonNegativeInt = (value: unknown): number | null => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
const int = Math.floor(value);
|
||||
return int >= 0 ? int : null;
|
||||
};
|
||||
@@ -36,7 +38,9 @@ const normalizeNonNegativeInt = (value: unknown): number | null => {
|
||||
export function resolveMemoryFlushSettings(cfg?: OpenClawConfig): MemoryFlushSettings | null {
|
||||
const defaults = cfg?.agents?.defaults?.compaction?.memoryFlush;
|
||||
const enabled = defaults?.enabled ?? true;
|
||||
if (!enabled) return null;
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
const softThresholdTokens =
|
||||
normalizeNonNegativeInt(defaults?.softThresholdTokens) ?? DEFAULT_MEMORY_FLUSH_SOFT_TOKENS;
|
||||
const prompt = defaults?.prompt?.trim() || DEFAULT_MEMORY_FLUSH_PROMPT;
|
||||
@@ -55,7 +59,9 @@ export function resolveMemoryFlushSettings(cfg?: OpenClawConfig): MemoryFlushSet
|
||||
}
|
||||
|
||||
function ensureNoReplyHint(text: string): string {
|
||||
if (text.includes(SILENT_REPLY_TOKEN)) return text;
|
||||
if (text.includes(SILENT_REPLY_TOKEN)) {
|
||||
return text;
|
||||
}
|
||||
return `${text}\n\nIf no user-visible reply is needed, start with ${SILENT_REPLY_TOKEN}.`;
|
||||
}
|
||||
|
||||
@@ -75,13 +81,19 @@ export function shouldRunMemoryFlush(params: {
|
||||
softThresholdTokens: number;
|
||||
}): boolean {
|
||||
const totalTokens = params.entry?.totalTokens;
|
||||
if (!totalTokens || totalTokens <= 0) return false;
|
||||
if (!totalTokens || totalTokens <= 0) {
|
||||
return false;
|
||||
}
|
||||
const contextWindow = Math.max(1, Math.floor(params.contextWindowTokens));
|
||||
const reserveTokens = Math.max(0, Math.floor(params.reserveTokensFloor));
|
||||
const softThreshold = Math.max(0, Math.floor(params.softThresholdTokens));
|
||||
const threshold = Math.max(0, contextWindow - reserveTokens - softThreshold);
|
||||
if (threshold <= 0) return false;
|
||||
if (totalTokens < threshold) return false;
|
||||
if (threshold <= 0) {
|
||||
return false;
|
||||
}
|
||||
if (totalTokens < threshold) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const compactionCount = params.entry?.compactionCount ?? 0;
|
||||
const lastFlushAt = params.entry?.memoryFlushCompactionCount;
|
||||
|
||||
@@ -28,7 +28,9 @@ const BACKSPACE_CHAR = "\u0008";
|
||||
export const CURRENT_MESSAGE_MARKER = "[Current message - respond to this]";
|
||||
|
||||
function normalizeMentionPattern(pattern: string): string {
|
||||
if (!pattern.includes(BACKSPACE_CHAR)) return pattern;
|
||||
if (!pattern.includes(BACKSPACE_CHAR)) {
|
||||
return pattern;
|
||||
}
|
||||
return pattern.split(BACKSPACE_CHAR).join("\\b");
|
||||
}
|
||||
|
||||
@@ -37,7 +39,9 @@ function normalizeMentionPatterns(patterns: string[]): string[] {
|
||||
}
|
||||
|
||||
function resolveMentionPatterns(cfg: OpenClawConfig | undefined, agentId?: string): string[] {
|
||||
if (!cfg) return [];
|
||||
if (!cfg) {
|
||||
return [];
|
||||
}
|
||||
const agentConfig = agentId ? resolveAgentConfig(cfg, agentId) : undefined;
|
||||
const agentGroupChat = agentConfig?.groupChat;
|
||||
if (agentGroupChat && Object.hasOwn(agentGroupChat, "mentionPatterns")) {
|
||||
@@ -69,9 +73,13 @@ export function normalizeMentionText(text: string): string {
|
||||
}
|
||||
|
||||
export function matchesMentionPatterns(text: string, mentionRegexes: RegExp[]): boolean {
|
||||
if (mentionRegexes.length === 0) return false;
|
||||
if (mentionRegexes.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const cleaned = normalizeMentionText(text ?? "");
|
||||
if (!cleaned) return false;
|
||||
if (!cleaned) {
|
||||
return false;
|
||||
}
|
||||
return mentionRegexes.some((re) => re.test(cleaned));
|
||||
}
|
||||
|
||||
@@ -93,7 +101,9 @@ export function matchesMentionWithExplicit(params: {
|
||||
if (hasAnyMention && explicitAvailable) {
|
||||
return explicit || params.mentionRegexes.some((re) => re.test(cleaned));
|
||||
}
|
||||
if (!cleaned) return explicit;
|
||||
if (!cleaned) {
|
||||
return explicit;
|
||||
}
|
||||
return explicit || params.mentionRegexes.some((re) => re.test(cleaned));
|
||||
}
|
||||
|
||||
|
||||
@@ -48,11 +48,17 @@ const FUZZY_VARIANT_TOKENS = [
|
||||
];
|
||||
|
||||
function boundedLevenshteinDistance(a: string, b: string, maxDistance: number): number | null {
|
||||
if (a === b) return 0;
|
||||
if (!a || !b) return null;
|
||||
if (a === b) {
|
||||
return 0;
|
||||
}
|
||||
if (!a || !b) {
|
||||
return null;
|
||||
}
|
||||
const aLen = a.length;
|
||||
const bLen = b.length;
|
||||
if (Math.abs(aLen - bLen) > maxDistance) return null;
|
||||
if (Math.abs(aLen - bLen) > maxDistance) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Standard DP with early exit. O(maxDistance * minLen) in common cases.
|
||||
const prev = Array.from({ length: bLen + 1 }, (_, idx) => idx);
|
||||
@@ -66,16 +72,24 @@ function boundedLevenshteinDistance(a: string, b: string, maxDistance: number):
|
||||
for (let j = 1; j <= bLen; j++) {
|
||||
const cost = aChar === b.charCodeAt(j - 1) ? 0 : 1;
|
||||
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
||||
if (curr[j] < rowMin) rowMin = curr[j];
|
||||
if (curr[j] < rowMin) {
|
||||
rowMin = curr[j];
|
||||
}
|
||||
}
|
||||
|
||||
if (rowMin > maxDistance) return null;
|
||||
if (rowMin > maxDistance) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let j = 0; j <= bLen; j++) prev[j] = curr[j] ?? 0;
|
||||
for (let j = 0; j <= bLen; j++) {
|
||||
prev[j] = curr[j] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
const dist = prev[bLen] ?? null;
|
||||
if (dist == null || dist > maxDistance) return null;
|
||||
if (dist == null || dist > maxDistance) {
|
||||
return null;
|
||||
}
|
||||
return dist;
|
||||
}
|
||||
|
||||
@@ -90,7 +104,9 @@ function resolveModelOverrideFromEntry(entry?: SessionEntry): {
|
||||
model: string;
|
||||
} | null {
|
||||
const model = entry?.modelOverride?.trim();
|
||||
if (!model) return null;
|
||||
if (!model) {
|
||||
return null;
|
||||
}
|
||||
const provider = entry?.providerOverride?.trim() || undefined;
|
||||
return { provider, model };
|
||||
}
|
||||
@@ -100,9 +116,13 @@ function resolveParentSessionKeyCandidate(params: {
|
||||
parentSessionKey?: string;
|
||||
}): string | null {
|
||||
const explicit = params.parentSessionKey?.trim();
|
||||
if (explicit && explicit !== params.sessionKey) return explicit;
|
||||
if (explicit && explicit !== params.sessionKey) {
|
||||
return explicit;
|
||||
}
|
||||
const derived = resolveThreadParentSessionKey(params.sessionKey);
|
||||
if (derived && derived !== params.sessionKey) return derived;
|
||||
if (derived && derived !== params.sessionKey) {
|
||||
return derived;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -113,15 +133,21 @@ function resolveStoredModelOverride(params: {
|
||||
parentSessionKey?: string;
|
||||
}): StoredModelOverride | null {
|
||||
const direct = resolveModelOverrideFromEntry(params.sessionEntry);
|
||||
if (direct) return { ...direct, source: "session" };
|
||||
if (direct) {
|
||||
return { ...direct, source: "session" };
|
||||
}
|
||||
const parentKey = resolveParentSessionKeyCandidate({
|
||||
sessionKey: params.sessionKey,
|
||||
parentSessionKey: params.parentSessionKey,
|
||||
});
|
||||
if (!parentKey || !params.sessionStore) return null;
|
||||
if (!parentKey || !params.sessionStore) {
|
||||
return null;
|
||||
}
|
||||
const parentEntry = params.sessionStore[parentKey];
|
||||
const parentOverride = resolveModelOverrideFromEntry(parentEntry);
|
||||
if (!parentOverride) return null;
|
||||
if (!parentOverride) {
|
||||
return null;
|
||||
}
|
||||
return { ...parentOverride, source: "parent" };
|
||||
}
|
||||
|
||||
@@ -152,11 +178,19 @@ function scoreFuzzyMatch(params: {
|
||||
value: string,
|
||||
weights: { exact: number; starts: number; includes: number },
|
||||
) => {
|
||||
if (!fragment) return 0;
|
||||
if (!fragment) {
|
||||
return 0;
|
||||
}
|
||||
let score = 0;
|
||||
if (value === fragment) score = Math.max(score, weights.exact);
|
||||
if (value.startsWith(fragment)) score = Math.max(score, weights.starts);
|
||||
if (value.includes(fragment)) score = Math.max(score, weights.includes);
|
||||
if (value === fragment) {
|
||||
score = Math.max(score, weights.exact);
|
||||
}
|
||||
if (value.startsWith(fragment)) {
|
||||
score = Math.max(score, weights.starts);
|
||||
}
|
||||
if (value.includes(fragment)) {
|
||||
score = Math.max(score, weights.includes);
|
||||
}
|
||||
return score;
|
||||
};
|
||||
|
||||
@@ -200,13 +234,19 @@ function scoreFuzzyMatch(params: {
|
||||
if (fragmentVariants.length === 0 && variantCount > 0) {
|
||||
score -= variantCount * 30;
|
||||
} else if (fragmentVariants.length > 0) {
|
||||
if (variantMatchCount > 0) score += variantMatchCount * 40;
|
||||
if (variantMatchCount === 0) score -= 20;
|
||||
if (variantMatchCount > 0) {
|
||||
score += variantMatchCount * 40;
|
||||
}
|
||||
if (variantMatchCount === 0) {
|
||||
score -= 20;
|
||||
}
|
||||
}
|
||||
|
||||
const defaultProvider = normalizeProviderId(params.defaultProvider);
|
||||
const isDefault = provider === defaultProvider && model === params.defaultModel;
|
||||
if (isDefault) score += 20;
|
||||
if (isDefault) {
|
||||
score += 20;
|
||||
}
|
||||
|
||||
return {
|
||||
score,
|
||||
@@ -331,7 +371,9 @@ export async function createModelSelectionState(params: {
|
||||
|
||||
let defaultThinkingLevel: ThinkLevel | undefined;
|
||||
const resolveDefaultThinkingLevel = async () => {
|
||||
if (defaultThinkingLevel) return defaultThinkingLevel;
|
||||
if (defaultThinkingLevel) {
|
||||
return defaultThinkingLevel;
|
||||
}
|
||||
let catalogForThinking = modelCatalog ?? allowedModelCatalog;
|
||||
if (!catalogForThinking || catalogForThinking.length === 0) {
|
||||
modelCatalog = await loadModelCatalog({ config: cfg });
|
||||
@@ -389,17 +431,23 @@ export function resolveModelDirectiveSelection(params: {
|
||||
fragment: string;
|
||||
}): { selection?: ModelDirectiveSelection; error?: string } => {
|
||||
const fragment = params.fragment.trim().toLowerCase();
|
||||
if (!fragment) return {};
|
||||
if (!fragment) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const providerFilter = params.provider ? normalizeProviderId(params.provider) : undefined;
|
||||
|
||||
const candidates: Array<{ provider: string; model: string }> = [];
|
||||
for (const key of allowedModelKeys) {
|
||||
const slash = key.indexOf("/");
|
||||
if (slash <= 0) continue;
|
||||
if (slash <= 0) {
|
||||
continue;
|
||||
}
|
||||
const provider = normalizeProviderId(key.slice(0, slash));
|
||||
const model = key.slice(slash + 1);
|
||||
if (providerFilter && provider !== providerFilter) continue;
|
||||
if (providerFilter && provider !== providerFilter) {
|
||||
continue;
|
||||
}
|
||||
candidates.push({ provider, model });
|
||||
}
|
||||
|
||||
@@ -407,7 +455,9 @@ export function resolveModelDirectiveSelection(params: {
|
||||
if (!params.provider) {
|
||||
const aliasMatches: Array<{ provider: string; model: string }> = [];
|
||||
for (const [aliasKey, entry] of aliasIndex.byAlias.entries()) {
|
||||
if (!aliasKey.includes(fragment)) continue;
|
||||
if (!aliasKey.includes(fragment)) {
|
||||
continue;
|
||||
}
|
||||
aliasMatches.push({
|
||||
provider: entry.ref.provider,
|
||||
model: entry.ref.model,
|
||||
@@ -415,14 +465,18 @@ export function resolveModelDirectiveSelection(params: {
|
||||
}
|
||||
for (const match of aliasMatches) {
|
||||
const key = modelKey(match.provider, match.model);
|
||||
if (!allowedModelKeys.has(key)) continue;
|
||||
if (!allowedModelKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
if (!candidates.some((c) => c.provider === match.provider && c.model === match.model)) {
|
||||
candidates.push(match);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) return {};
|
||||
if (candidates.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const scored = candidates
|
||||
.map((candidate) => {
|
||||
@@ -437,21 +491,34 @@ export function resolveModelDirectiveSelection(params: {
|
||||
return Object.assign({ candidate }, details);
|
||||
})
|
||||
.toSorted((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1;
|
||||
if (a.variantMatchCount !== b.variantMatchCount)
|
||||
if (b.score !== a.score) {
|
||||
return b.score - a.score;
|
||||
}
|
||||
if (a.isDefault !== b.isDefault) {
|
||||
return a.isDefault ? -1 : 1;
|
||||
}
|
||||
if (a.variantMatchCount !== b.variantMatchCount) {
|
||||
return b.variantMatchCount - a.variantMatchCount;
|
||||
if (a.variantCount !== b.variantCount) return a.variantCount - b.variantCount;
|
||||
if (a.modelLength !== b.modelLength) return a.modelLength - b.modelLength;
|
||||
}
|
||||
if (a.variantCount !== b.variantCount) {
|
||||
return a.variantCount - b.variantCount;
|
||||
}
|
||||
if (a.modelLength !== b.modelLength) {
|
||||
return a.modelLength - b.modelLength;
|
||||
}
|
||||
return a.key.localeCompare(b.key);
|
||||
});
|
||||
|
||||
const bestScored = scored[0];
|
||||
const best = bestScored?.candidate;
|
||||
if (!best || !bestScored) return {};
|
||||
if (!best || !bestScored) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const minScore = providerFilter ? 90 : 120;
|
||||
if (bestScored.score < minScore) return {};
|
||||
if (bestScored.score < minScore) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return { selection: buildSelection(best.provider, best.model) };
|
||||
};
|
||||
@@ -464,7 +531,9 @@ export function resolveModelDirectiveSelection(params: {
|
||||
|
||||
if (!resolved) {
|
||||
const fuzzy = resolveFuzzy({ fragment: rawTrimmed });
|
||||
if (fuzzy.selection || fuzzy.error) return fuzzy;
|
||||
if (fuzzy.selection || fuzzy.error) {
|
||||
return fuzzy;
|
||||
}
|
||||
return {
|
||||
error: `Unrecognized model "${rawTrimmed}". Use /models to list providers, or /models <provider> to list models.`,
|
||||
};
|
||||
@@ -489,12 +558,16 @@ export function resolveModelDirectiveSelection(params: {
|
||||
const provider = normalizeProviderId(rawTrimmed.slice(0, slash).trim());
|
||||
const fragment = rawTrimmed.slice(slash + 1).trim();
|
||||
const fuzzy = resolveFuzzy({ provider, fragment });
|
||||
if (fuzzy.selection || fuzzy.error) return fuzzy;
|
||||
if (fuzzy.selection || fuzzy.error) {
|
||||
return fuzzy;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, try fuzzy matching across allowlisted models.
|
||||
const fuzzy = resolveFuzzy({ fragment: rawTrimmed });
|
||||
if (fuzzy.selection || fuzzy.error) return fuzzy;
|
||||
if (fuzzy.selection || fuzzy.error) {
|
||||
return fuzzy;
|
||||
}
|
||||
|
||||
return {
|
||||
error: `Model "${resolved.ref.provider}/${resolved.ref.model}" is not allowed. Use /models to list providers, or /models <provider> to list models.`,
|
||||
|
||||
@@ -51,7 +51,9 @@ export function normalizeReplyPayload(
|
||||
const shouldStripHeartbeat = opts.stripHeartbeat ?? true;
|
||||
if (shouldStripHeartbeat && text?.includes(HEARTBEAT_TOKEN)) {
|
||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||
if (stripped.didStrip) opts.onHeartbeatStrip?.();
|
||||
if (stripped.didStrip) {
|
||||
opts.onHeartbeatStrip?.();
|
||||
}
|
||||
if (stripped.shouldSkip && !hasMedia && !hasChannelData) {
|
||||
opts.onSkip?.("heartbeat");
|
||||
return null;
|
||||
|
||||
@@ -16,7 +16,9 @@ export function clearSessionQueues(keys: Array<string | undefined>): ClearSessio
|
||||
|
||||
for (const key of keys) {
|
||||
const cleaned = key?.trim();
|
||||
if (!cleaned || seen.has(cleaned)) continue;
|
||||
if (!cleaned || seen.has(cleaned)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(cleaned);
|
||||
clearedKeys.push(cleaned);
|
||||
followupCleared += clearFollowupQueue(cleaned);
|
||||
|
||||
@@ -3,10 +3,14 @@ import { normalizeQueueDropPolicy, normalizeQueueMode } from "./normalize.js";
|
||||
import type { QueueDropPolicy, QueueMode } from "./types.js";
|
||||
|
||||
function parseQueueDebounce(raw?: string): number | undefined {
|
||||
if (!raw) return undefined;
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = parseDurationMs(raw.trim(), { defaultUnit: "ms" });
|
||||
if (!parsed || parsed < 0) return undefined;
|
||||
if (!parsed || parsed < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.round(parsed);
|
||||
} catch {
|
||||
return undefined;
|
||||
@@ -14,11 +18,17 @@ function parseQueueDebounce(raw?: string): number | undefined {
|
||||
}
|
||||
|
||||
function parseQueueCap(raw?: string): number | undefined {
|
||||
if (!raw) return undefined;
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const num = Number(raw);
|
||||
if (!Number.isFinite(num)) return undefined;
|
||||
if (!Number.isFinite(num)) {
|
||||
return undefined;
|
||||
}
|
||||
const cap = Math.floor(num);
|
||||
if (cap < 1) return undefined;
|
||||
if (cap < 1) {
|
||||
return undefined;
|
||||
}
|
||||
return cap;
|
||||
}
|
||||
|
||||
@@ -37,10 +47,14 @@ function parseQueueDirectiveArgs(raw: string): {
|
||||
} {
|
||||
let i = 0;
|
||||
const len = raw.length;
|
||||
while (i < len && /\s/.test(raw[i])) i += 1;
|
||||
while (i < len && /\s/.test(raw[i])) {
|
||||
i += 1;
|
||||
}
|
||||
if (raw[i] === ":") {
|
||||
i += 1;
|
||||
while (i < len && /\s/.test(raw[i])) i += 1;
|
||||
while (i < len && /\s/.test(raw[i])) {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
let consumed = i;
|
||||
let queueMode: QueueMode | undefined;
|
||||
@@ -54,17 +68,27 @@ function parseQueueDirectiveArgs(raw: string): {
|
||||
let rawDrop: string | undefined;
|
||||
let hasOptions = false;
|
||||
const takeToken = (): string | null => {
|
||||
if (i >= len) return null;
|
||||
if (i >= len) {
|
||||
return null;
|
||||
}
|
||||
const start = i;
|
||||
while (i < len && !/\s/.test(raw[i])) i += 1;
|
||||
if (start === i) return null;
|
||||
while (i < len && !/\s/.test(raw[i])) {
|
||||
i += 1;
|
||||
}
|
||||
if (start === i) {
|
||||
return null;
|
||||
}
|
||||
const token = raw.slice(start, i);
|
||||
while (i < len && /\s/.test(raw[i])) i += 1;
|
||||
while (i < len && /\s/.test(raw[i])) {
|
||||
i += 1;
|
||||
}
|
||||
return token;
|
||||
};
|
||||
while (i < len) {
|
||||
const token = takeToken();
|
||||
if (!token) break;
|
||||
if (!token) {
|
||||
break;
|
||||
}
|
||||
const lowered = token.trim().toLowerCase();
|
||||
if (lowered === "default" || lowered === "reset" || lowered === "clear") {
|
||||
queueReset = true;
|
||||
|
||||
@@ -14,7 +14,9 @@ export function scheduleFollowupDrain(
|
||||
runFollowup: (run: FollowupRun) => Promise<void>,
|
||||
): void {
|
||||
const queue = FOLLOWUP_QUEUES.get(key);
|
||||
if (!queue || queue.draining) return;
|
||||
if (!queue || queue.draining) {
|
||||
return;
|
||||
}
|
||||
queue.draining = true;
|
||||
void (async () => {
|
||||
try {
|
||||
@@ -28,7 +30,9 @@ export function scheduleFollowupDrain(
|
||||
// Debug: `pnpm test src/auto-reply/reply/queue.collect-routing.test.ts`
|
||||
if (forceIndividualCollect) {
|
||||
const next = queue.items.shift();
|
||||
if (!next) break;
|
||||
if (!next) {
|
||||
break;
|
||||
}
|
||||
await runFollowup(next);
|
||||
continue;
|
||||
}
|
||||
@@ -55,7 +59,9 @@ export function scheduleFollowupDrain(
|
||||
if (isCrossChannel) {
|
||||
forceIndividualCollect = true;
|
||||
const next = queue.items.shift();
|
||||
if (!next) break;
|
||||
if (!next) {
|
||||
break;
|
||||
}
|
||||
await runFollowup(next);
|
||||
continue;
|
||||
}
|
||||
@@ -63,7 +69,9 @@ export function scheduleFollowupDrain(
|
||||
const items = queue.items.splice(0, queue.items.length);
|
||||
const summary = buildQueueSummaryPrompt({ state: queue, noun: "message" });
|
||||
const run = items.at(-1)?.run ?? queue.lastRun;
|
||||
if (!run) break;
|
||||
if (!run) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Preserve originating channel from items when collecting same-channel.
|
||||
const originatingChannel = items.find((i) => i.originatingChannel)?.originatingChannel;
|
||||
@@ -96,7 +104,9 @@ export function scheduleFollowupDrain(
|
||||
const summaryPrompt = buildQueueSummaryPrompt({ state: queue, noun: "message" });
|
||||
if (summaryPrompt) {
|
||||
const run = queue.lastRun;
|
||||
if (!run) break;
|
||||
if (!run) {
|
||||
break;
|
||||
}
|
||||
await runFollowup({
|
||||
prompt: summaryPrompt,
|
||||
run,
|
||||
@@ -106,7 +116,9 @@ export function scheduleFollowupDrain(
|
||||
}
|
||||
|
||||
const next = queue.items.shift();
|
||||
if (!next) break;
|
||||
if (!next) {
|
||||
break;
|
||||
}
|
||||
await runFollowup(next);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -17,7 +17,9 @@ function isRunAlreadyQueued(
|
||||
if (messageId) {
|
||||
return items.some((item) => item.messageId?.trim() === messageId && hasSameRouting(item));
|
||||
}
|
||||
if (!allowPromptFallback) return false;
|
||||
if (!allowPromptFallback) {
|
||||
return false;
|
||||
}
|
||||
return items.some((item) => item.prompt === run.prompt && hasSameRouting(item));
|
||||
}
|
||||
|
||||
@@ -35,7 +37,9 @@ export function enqueueFollowupRun(
|
||||
isRunAlreadyQueued(item, items, dedupeMode === "prompt");
|
||||
|
||||
// Deduplicate: skip if the same message is already queued.
|
||||
if (shouldSkipQueueItem({ item: run, items: queue.items, dedupe })) return false;
|
||||
if (shouldSkipQueueItem({ item: run, items: queue.items, dedupe })) {
|
||||
return false;
|
||||
}
|
||||
|
||||
queue.lastEnqueuedAt = Date.now();
|
||||
queue.lastRun = run.run;
|
||||
@@ -44,7 +48,9 @@ export function enqueueFollowupRun(
|
||||
queue,
|
||||
summarize: (item) => item.summaryLine?.trim() || item.prompt.trim(),
|
||||
});
|
||||
if (!shouldEnqueue) return false;
|
||||
if (!shouldEnqueue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
queue.items.push(run);
|
||||
return true;
|
||||
@@ -52,8 +58,12 @@ export function enqueueFollowupRun(
|
||||
|
||||
export function getFollowupQueueDepth(key: string): number {
|
||||
const cleaned = key.trim();
|
||||
if (!cleaned) return 0;
|
||||
if (!cleaned) {
|
||||
return 0;
|
||||
}
|
||||
const queue = FOLLOWUP_QUEUES.get(cleaned);
|
||||
if (!queue) return 0;
|
||||
if (!queue) {
|
||||
return 0;
|
||||
}
|
||||
return queue.items.length;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,44 @@
|
||||
import type { QueueDropPolicy, QueueMode } from "./types.js";
|
||||
|
||||
export function normalizeQueueMode(raw?: string): QueueMode | undefined {
|
||||
if (!raw) return undefined;
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const cleaned = raw.trim().toLowerCase();
|
||||
if (cleaned === "queue" || cleaned === "queued") return "steer";
|
||||
if (cleaned === "interrupt" || cleaned === "interrupts" || cleaned === "abort")
|
||||
if (cleaned === "queue" || cleaned === "queued") {
|
||||
return "steer";
|
||||
}
|
||||
if (cleaned === "interrupt" || cleaned === "interrupts" || cleaned === "abort") {
|
||||
return "interrupt";
|
||||
if (cleaned === "steer" || cleaned === "steering") return "steer";
|
||||
if (cleaned === "followup" || cleaned === "follow-ups" || cleaned === "followups")
|
||||
}
|
||||
if (cleaned === "steer" || cleaned === "steering") {
|
||||
return "steer";
|
||||
}
|
||||
if (cleaned === "followup" || cleaned === "follow-ups" || cleaned === "followups") {
|
||||
return "followup";
|
||||
if (cleaned === "collect" || cleaned === "coalesce") return "collect";
|
||||
if (cleaned === "steer+backlog" || cleaned === "steer-backlog" || cleaned === "steer_backlog")
|
||||
}
|
||||
if (cleaned === "collect" || cleaned === "coalesce") {
|
||||
return "collect";
|
||||
}
|
||||
if (cleaned === "steer+backlog" || cleaned === "steer-backlog" || cleaned === "steer_backlog") {
|
||||
return "steer-backlog";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function normalizeQueueDropPolicy(raw?: string): QueueDropPolicy | undefined {
|
||||
if (!raw) return undefined;
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const cleaned = raw.trim().toLowerCase();
|
||||
if (cleaned === "old" || cleaned === "oldest") return "old";
|
||||
if (cleaned === "new" || cleaned === "newest") return "new";
|
||||
if (cleaned === "summarize" || cleaned === "summary") return "summarize";
|
||||
if (cleaned === "old" || cleaned === "oldest") {
|
||||
return "old";
|
||||
}
|
||||
if (cleaned === "new" || cleaned === "newest") {
|
||||
return "new";
|
||||
}
|
||||
if (cleaned === "summarize" || cleaned === "summary") {
|
||||
return "summarize";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -13,13 +13,17 @@ function resolveChannelDebounce(
|
||||
byChannel: InboundDebounceByProvider | undefined,
|
||||
channelKey: string | undefined,
|
||||
): number | undefined {
|
||||
if (!channelKey || !byChannel) return undefined;
|
||||
if (!channelKey || !byChannel) {
|
||||
return undefined;
|
||||
}
|
||||
const value = byChannel[channelKey];
|
||||
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : undefined;
|
||||
}
|
||||
|
||||
function resolvePluginDebounce(channelKey: string | undefined): number | undefined {
|
||||
if (!channelKey) return undefined;
|
||||
if (!channelKey) {
|
||||
return undefined;
|
||||
}
|
||||
const plugin = getChannelPlugin(channelKey);
|
||||
const value = plugin?.defaults?.queue?.debounceMs;
|
||||
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : undefined;
|
||||
|
||||
@@ -58,9 +58,13 @@ export function getFollowupQueue(key: string, settings: QueueSettings): Followup
|
||||
|
||||
export function clearFollowupQueue(key: string): number {
|
||||
const cleaned = key.trim();
|
||||
if (!cleaned) return 0;
|
||||
if (!cleaned) {
|
||||
return 0;
|
||||
}
|
||||
const queue = FOLLOWUP_QUEUES.get(cleaned);
|
||||
if (!queue) return 0;
|
||||
if (!queue) {
|
||||
return 0;
|
||||
}
|
||||
const cleared = queue.items.length + queue.droppedCount;
|
||||
queue.items.length = 0;
|
||||
queue.droppedCount = 0;
|
||||
|
||||
@@ -24,12 +24,16 @@ const DEFAULT_HUMAN_DELAY_MAX_MS = 2500;
|
||||
/** Generate a random delay within the configured range. */
|
||||
function getHumanDelay(config: HumanDelayConfig | undefined): number {
|
||||
const mode = config?.mode ?? "off";
|
||||
if (mode === "off") return 0;
|
||||
if (mode === "off") {
|
||||
return 0;
|
||||
}
|
||||
const min =
|
||||
mode === "custom" ? (config?.minMs ?? DEFAULT_HUMAN_DELAY_MIN_MS) : DEFAULT_HUMAN_DELAY_MIN_MS;
|
||||
const max =
|
||||
mode === "custom" ? (config?.maxMs ?? DEFAULT_HUMAN_DELAY_MAX_MS) : DEFAULT_HUMAN_DELAY_MAX_MS;
|
||||
if (max <= min) return min;
|
||||
if (max <= min) {
|
||||
return min;
|
||||
}
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
@@ -115,20 +119,26 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis
|
||||
onHeartbeatStrip: options.onHeartbeatStrip,
|
||||
onSkip: (reason) => options.onSkip?.(payload, { kind, reason }),
|
||||
});
|
||||
if (!normalized) return false;
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
queuedCounts[kind] += 1;
|
||||
pending += 1;
|
||||
|
||||
// Determine if we should add human-like delay (only for block replies after the first).
|
||||
const shouldDelay = kind === "block" && sentFirstBlock;
|
||||
if (kind === "block") sentFirstBlock = true;
|
||||
if (kind === "block") {
|
||||
sentFirstBlock = true;
|
||||
}
|
||||
|
||||
sendChain = sendChain
|
||||
.then(async () => {
|
||||
// Add human-like delay between block replies for natural rhythm.
|
||||
if (shouldDelay) {
|
||||
const delayMs = getHumanDelay(options.humanDelay);
|
||||
if (delayMs > 0) await sleep(delayMs);
|
||||
if (delayMs > 0) {
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
await options.deliver(normalized, { kind });
|
||||
})
|
||||
|
||||
@@ -8,14 +8,20 @@ import { formatCliCommand } from "../../cli/command-format.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
|
||||
function normalizeAllowToken(value?: string) {
|
||||
if (!value) return "";
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function slugAllowToken(value?: string) {
|
||||
if (!value) return "";
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
let text = value.trim().toLowerCase();
|
||||
if (!text) return "";
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
text = text.replace(/^[@#]+/, "");
|
||||
text = text.replace(/[\s_]+/g, "-");
|
||||
text = text.replace(/[^a-z0-9-]+/g, "-");
|
||||
@@ -32,7 +38,9 @@ const SENDER_PREFIXES = [
|
||||
const SENDER_PREFIX_RE = new RegExp(`^(${SENDER_PREFIXES.join("|")}):`, "i");
|
||||
|
||||
function stripSenderPrefix(value?: string) {
|
||||
if (!value) return "";
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.replace(SENDER_PREFIX_RE, "");
|
||||
}
|
||||
@@ -42,7 +50,9 @@ function resolveElevatedAllowList(
|
||||
provider: string,
|
||||
fallbackAllowFrom?: Array<string | number>,
|
||||
): Array<string | number> | undefined {
|
||||
if (!allowFrom) return fallbackAllowFrom;
|
||||
if (!allowFrom) {
|
||||
return fallbackAllowFrom;
|
||||
}
|
||||
const value = allowFrom[provider];
|
||||
return Array.isArray(value) ? value : fallbackAllowFrom;
|
||||
}
|
||||
@@ -58,22 +68,36 @@ function isApprovedElevatedSender(params: {
|
||||
params.provider,
|
||||
params.fallbackAllowFrom,
|
||||
);
|
||||
if (!rawAllow || rawAllow.length === 0) return false;
|
||||
if (!rawAllow || rawAllow.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const allowTokens = rawAllow.map((entry) => String(entry).trim()).filter(Boolean);
|
||||
if (allowTokens.length === 0) return false;
|
||||
if (allowTokens.some((entry) => entry === "*")) return true;
|
||||
if (allowTokens.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (allowTokens.some((entry) => entry === "*")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const tokens = new Set<string>();
|
||||
const addToken = (value?: string) => {
|
||||
if (!value) return;
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
tokens.add(trimmed);
|
||||
const normalized = normalizeAllowToken(trimmed);
|
||||
if (normalized) tokens.add(normalized);
|
||||
if (normalized) {
|
||||
tokens.add(normalized);
|
||||
}
|
||||
const slugged = slugAllowToken(trimmed);
|
||||
if (slugged) tokens.add(slugged);
|
||||
if (slugged) {
|
||||
tokens.add(slugged);
|
||||
}
|
||||
};
|
||||
|
||||
addToken(params.ctx.SenderName);
|
||||
@@ -87,13 +111,21 @@ function isApprovedElevatedSender(params: {
|
||||
|
||||
for (const rawEntry of allowTokens) {
|
||||
const entry = rawEntry.trim();
|
||||
if (!entry) continue;
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
const stripped = stripSenderPrefix(entry);
|
||||
if (tokens.has(entry) || tokens.has(stripped)) return true;
|
||||
if (tokens.has(entry) || tokens.has(stripped)) {
|
||||
return true;
|
||||
}
|
||||
const normalized = normalizeAllowToken(stripped);
|
||||
if (normalized && tokens.has(normalized)) return true;
|
||||
if (normalized && tokens.has(normalized)) {
|
||||
return true;
|
||||
}
|
||||
const slugged = slugAllowToken(stripped);
|
||||
if (slugged && tokens.has(slugged)) return true;
|
||||
if (slugged && tokens.has(slugged)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -115,13 +147,18 @@ export function resolveElevatedPermissions(params: {
|
||||
const agentEnabled = agentConfig?.enabled !== false;
|
||||
const enabled = globalEnabled && agentEnabled;
|
||||
const failures: Array<{ gate: string; key: string }> = [];
|
||||
if (!globalEnabled) failures.push({ gate: "enabled", key: "tools.elevated.enabled" });
|
||||
if (!agentEnabled)
|
||||
if (!globalEnabled) {
|
||||
failures.push({ gate: "enabled", key: "tools.elevated.enabled" });
|
||||
}
|
||||
if (!agentEnabled) {
|
||||
failures.push({
|
||||
gate: "enabled",
|
||||
key: "agents.list[].tools.elevated.enabled",
|
||||
});
|
||||
if (!enabled) return { enabled, allowed: false, failures };
|
||||
}
|
||||
if (!enabled) {
|
||||
return { enabled, allowed: false, failures };
|
||||
}
|
||||
if (!params.provider) {
|
||||
failures.push({ gate: "provider", key: "ctx.Provider" });
|
||||
return { enabled, allowed: false, failures };
|
||||
|
||||
@@ -12,12 +12,18 @@ export function extractInlineSimpleCommand(body?: string): {
|
||||
command: string;
|
||||
cleaned: string;
|
||||
} | null {
|
||||
if (!body) return null;
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
const match = body.match(INLINE_SIMPLE_COMMAND_RE);
|
||||
if (!match || match.index === undefined) return null;
|
||||
if (!match || match.index === undefined) {
|
||||
return null;
|
||||
}
|
||||
const alias = `/${match[1].toLowerCase()}`;
|
||||
const command = INLINE_SIMPLE_COMMAND_ALIASES.get(alias);
|
||||
if (!command) return null;
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
const cleaned = body.replace(match[0], " ").replace(/\s+/g, " ").trim();
|
||||
return { command, cleaned };
|
||||
}
|
||||
@@ -27,7 +33,9 @@ export function stripInlineStatus(body: string): {
|
||||
didStrip: boolean;
|
||||
} {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return { cleaned: "", didStrip: false };
|
||||
if (!trimmed) {
|
||||
return { cleaned: "", didStrip: false };
|
||||
}
|
||||
const cleaned = trimmed.replace(INLINE_STATUS_RE, " ").replace(/\s+/g, " ").trim();
|
||||
return { cleaned, didStrip: cleaned !== trimmed };
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ export function applyReplyTagsToPayload(
|
||||
currentMessageId?: string,
|
||||
): ReplyPayload {
|
||||
if (typeof payload.text !== "string") {
|
||||
if (!payload.replyToCurrent || payload.replyToId) return payload;
|
||||
if (!payload.replyToCurrent || payload.replyToId) {
|
||||
return payload;
|
||||
}
|
||||
return {
|
||||
...payload,
|
||||
replyToId: currentMessageId?.trim() || undefined,
|
||||
@@ -20,7 +22,9 @@ export function applyReplyTagsToPayload(
|
||||
}
|
||||
const shouldParseTags = payload.text.includes("[[");
|
||||
if (!shouldParseTags) {
|
||||
if (!payload.replyToCurrent || payload.replyToId) return payload;
|
||||
if (!payload.replyToCurrent || payload.replyToId) {
|
||||
return payload;
|
||||
}
|
||||
return {
|
||||
...payload,
|
||||
replyToId: currentMessageId?.trim() || undefined,
|
||||
@@ -69,7 +73,9 @@ export function filterMessagingToolDuplicates(params: {
|
||||
sentTexts: string[];
|
||||
}): ReplyPayload[] {
|
||||
const { payloads, sentTexts } = params;
|
||||
if (sentTexts.length === 0) return payloads;
|
||||
if (sentTexts.length === 0) {
|
||||
return payloads;
|
||||
}
|
||||
return payloads.filter((payload) => !isMessagingToolDuplicate(payload.text ?? "", sentTexts));
|
||||
}
|
||||
|
||||
@@ -85,17 +91,29 @@ export function shouldSuppressMessagingToolReplies(params: {
|
||||
accountId?: string;
|
||||
}): boolean {
|
||||
const provider = params.messageProvider?.trim().toLowerCase();
|
||||
if (!provider) return false;
|
||||
if (!provider) {
|
||||
return false;
|
||||
}
|
||||
const originTarget = normalizeTargetForProvider(provider, params.originatingTo);
|
||||
if (!originTarget) return false;
|
||||
if (!originTarget) {
|
||||
return false;
|
||||
}
|
||||
const originAccount = normalizeAccountId(params.accountId);
|
||||
const sentTargets = params.messagingToolSentTargets ?? [];
|
||||
if (sentTargets.length === 0) return false;
|
||||
if (sentTargets.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return sentTargets.some((target) => {
|
||||
if (!target?.provider) return false;
|
||||
if (target.provider.trim().toLowerCase() !== provider) return false;
|
||||
if (!target?.provider) {
|
||||
return false;
|
||||
}
|
||||
if (target.provider.trim().toLowerCase() !== provider) {
|
||||
return false;
|
||||
}
|
||||
const targetKey = normalizeTargetForProvider(provider, target.to);
|
||||
if (!targetKey) return false;
|
||||
if (!targetKey) {
|
||||
return false;
|
||||
}
|
||||
const targetAccount = normalizeAccountId(target.accountId);
|
||||
if (originAccount && targetAccount && originAccount !== targetAccount) {
|
||||
return false;
|
||||
|
||||
@@ -26,13 +26,19 @@ export function createReplyReferencePlanner(options: {
|
||||
const startId = options.startId?.trim();
|
||||
|
||||
const use = (): string | undefined => {
|
||||
if (!allowReference) return undefined;
|
||||
if (!allowReference) {
|
||||
return undefined;
|
||||
}
|
||||
if (existingId) {
|
||||
hasReplied = true;
|
||||
return existingId;
|
||||
}
|
||||
if (!startId) return undefined;
|
||||
if (options.replyToMode === "off") return undefined;
|
||||
if (!startId) {
|
||||
return undefined;
|
||||
}
|
||||
if (options.replyToMode === "off") {
|
||||
return undefined;
|
||||
}
|
||||
if (options.replyToMode === "all") {
|
||||
hasReplied = true;
|
||||
return startId;
|
||||
|
||||
@@ -12,7 +12,9 @@ export function resolveReplyToMode(
|
||||
chatType?: string | null,
|
||||
): ReplyToMode {
|
||||
const provider = normalizeChannelId(channel);
|
||||
if (!provider) return "all";
|
||||
if (!provider) {
|
||||
return "all";
|
||||
}
|
||||
const resolved = getChannelDock(provider)?.threading?.resolveReplyToMode?.({
|
||||
cfg,
|
||||
accountId,
|
||||
@@ -27,12 +29,18 @@ export function createReplyToModeFilter(
|
||||
) {
|
||||
let hasThreaded = false;
|
||||
return (payload: ReplyPayload): ReplyPayload => {
|
||||
if (!payload.replyToId) return payload;
|
||||
if (!payload.replyToId) {
|
||||
return payload;
|
||||
}
|
||||
if (mode === "off") {
|
||||
if (opts.allowTagsWhenOff && payload.replyToTag) return payload;
|
||||
if (opts.allowTagsWhenOff && payload.replyToTag) {
|
||||
return payload;
|
||||
}
|
||||
return { ...payload, replyToId: undefined };
|
||||
}
|
||||
if (mode === "all") return payload;
|
||||
if (mode === "all") {
|
||||
return payload;
|
||||
}
|
||||
if (hasThreaded) {
|
||||
return { ...payload, replyToId: undefined };
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@ export function resolveResponsePrefixTemplate(
|
||||
template: string | undefined,
|
||||
context: ResponsePrefixContext,
|
||||
): string | undefined {
|
||||
if (!template) return undefined;
|
||||
if (!template) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return template.replace(TEMPLATE_VAR_PATTERN, (match, varName: string) => {
|
||||
const normalizedVar = varName.toLowerCase();
|
||||
@@ -90,7 +92,9 @@ export function extractShortModelName(fullModel: string): string {
|
||||
* Check if a template string contains any template variables.
|
||||
*/
|
||||
export function hasTemplateVariables(template: string | undefined): boolean {
|
||||
if (!template) return false;
|
||||
if (!template) {
|
||||
return false;
|
||||
}
|
||||
// Reset lastIndex since we're using a global regex
|
||||
TEMPLATE_VAR_PATTERN.lastIndex = 0;
|
||||
return TEMPLATE_VAR_PATTERN.test(template);
|
||||
|
||||
@@ -72,7 +72,9 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
||||
const normalized = normalizeReplyPayload(payload, {
|
||||
responsePrefix,
|
||||
});
|
||||
if (!normalized) return { ok: true };
|
||||
if (!normalized) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
let text = normalized.text ?? "";
|
||||
let mediaUrls = (normalized.mediaUrls?.filter(Boolean) ?? []).length
|
||||
@@ -151,6 +153,8 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
||||
export function isRoutableChannel(
|
||||
channel: OriginatingChannelType | undefined,
|
||||
): channel is Exclude<OriginatingChannelType, typeof INTERNAL_MESSAGE_CHANNEL> {
|
||||
if (!channel || channel === INTERNAL_MESSAGE_CHANNEL) return false;
|
||||
if (!channel || channel === INTERNAL_MESSAGE_CHANNEL) {
|
||||
return false;
|
||||
}
|
||||
return normalizeChannelId(channel) !== null;
|
||||
}
|
||||
|
||||
@@ -41,9 +41,13 @@ function buildSelectionFromExplicit(params: {
|
||||
defaultProvider: params.defaultProvider,
|
||||
aliasIndex: params.aliasIndex,
|
||||
});
|
||||
if (!resolved) return undefined;
|
||||
if (!resolved) {
|
||||
return undefined;
|
||||
}
|
||||
const key = modelKey(resolved.ref.provider, resolved.ref.model);
|
||||
if (params.allowedModelKeys.size > 0 && !params.allowedModelKeys.has(key)) return undefined;
|
||||
if (params.allowedModelKeys.size > 0 && !params.allowedModelKeys.has(key)) {
|
||||
return undefined;
|
||||
}
|
||||
const isDefault =
|
||||
resolved.ref.provider === params.defaultProvider && resolved.ref.model === params.defaultModel;
|
||||
return {
|
||||
@@ -62,12 +66,16 @@ function applySelectionToSession(params: {
|
||||
storePath?: string;
|
||||
}) {
|
||||
const { selection, sessionEntry, sessionStore, sessionKey, storePath } = params;
|
||||
if (!sessionEntry || !sessionStore || !sessionKey) return;
|
||||
if (!sessionEntry || !sessionStore || !sessionKey) {
|
||||
return;
|
||||
}
|
||||
const { updated } = applyModelOverrideToSessionEntry({
|
||||
entry: sessionEntry,
|
||||
selection,
|
||||
});
|
||||
if (!updated) return;
|
||||
if (!updated) {
|
||||
return;
|
||||
}
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
updateSessionStore(storePath, (store) => {
|
||||
@@ -92,12 +100,18 @@ export async function applyResetModelOverride(params: {
|
||||
defaultModel: string;
|
||||
aliasIndex: ModelAliasIndex;
|
||||
}): Promise<ResetModelResult> {
|
||||
if (!params.resetTriggered) return {};
|
||||
if (!params.resetTriggered) {
|
||||
return {};
|
||||
}
|
||||
const rawBody = params.bodyStripped?.trim();
|
||||
if (!rawBody) return {};
|
||||
if (!rawBody) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const { tokens, first, second } = splitBody(rawBody);
|
||||
if (!first) return {};
|
||||
if (!first) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const catalog = await loadModelCatalog({ config: params.cfg });
|
||||
const allowed = buildAllowedModelSet({
|
||||
@@ -107,12 +121,16 @@ export async function applyResetModelOverride(params: {
|
||||
defaultModel: params.defaultModel,
|
||||
});
|
||||
const allowedModelKeys = allowed.allowedKeys;
|
||||
if (allowedModelKeys.size === 0) return {};
|
||||
if (allowedModelKeys.size === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const providers = new Set<string>();
|
||||
for (const key of allowedModelKeys) {
|
||||
const slash = key.indexOf("/");
|
||||
if (slash <= 0) continue;
|
||||
if (slash <= 0) {
|
||||
continue;
|
||||
}
|
||||
providers.add(normalizeProviderId(key.slice(0, slash)));
|
||||
}
|
||||
|
||||
@@ -145,7 +163,9 @@ export async function applyResetModelOverride(params: {
|
||||
aliasIndex: params.aliasIndex,
|
||||
allowedModelKeys,
|
||||
});
|
||||
if (selection) consumed = 1;
|
||||
if (selection) {
|
||||
consumed = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selection) {
|
||||
@@ -153,11 +173,15 @@ export async function applyResetModelOverride(params: {
|
||||
const allowFuzzy = providers.has(normalizeProviderId(first)) || first.trim().length >= 6;
|
||||
if (allowFuzzy) {
|
||||
selection = resolved.selection;
|
||||
if (selection) consumed = 1;
|
||||
if (selection) {
|
||||
consumed = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!selection) return {};
|
||||
if (!selection) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const cleanedBody = tokens.slice(consumed).join(" ").trim();
|
||||
params.sessionCtx.BodyStripped = formatInboundBodyWithSenderMeta({
|
||||
|
||||
@@ -18,14 +18,22 @@ export async function prependSystemEvents(params: {
|
||||
}): Promise<string> {
|
||||
const compactSystemEvent = (line: string): string | null => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return null;
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.includes("reason periodic")) return null;
|
||||
if (lower.includes("reason periodic")) {
|
||||
return null;
|
||||
}
|
||||
// Filter out the actual heartbeat prompt, but not cron jobs that mention "heartbeat"
|
||||
// The heartbeat prompt starts with "Read HEARTBEAT.md" - cron payloads won't match this
|
||||
if (lower.startsWith("read heartbeat.md")) return null;
|
||||
if (lower.startsWith("read heartbeat.md")) {
|
||||
return null;
|
||||
}
|
||||
// Also filter heartbeat poll/wake noise
|
||||
if (lower.includes("heartbeat poll") || lower.includes("heartbeat wake")) return null;
|
||||
if (lower.includes("heartbeat poll") || lower.includes("heartbeat wake")) {
|
||||
return null;
|
||||
}
|
||||
if (trimmed.startsWith("Node:")) {
|
||||
return trimmed.replace(/ · last input [^·]+/i, "").trim();
|
||||
}
|
||||
@@ -43,10 +51,16 @@ export async function prependSystemEvents(params: {
|
||||
|
||||
const resolveSystemEventTimezone = (cfg: OpenClawConfig) => {
|
||||
const raw = cfg.agents?.defaults?.envelopeTimezone?.trim();
|
||||
if (!raw) return { mode: "local" as const };
|
||||
if (!raw) {
|
||||
return { mode: "local" as const };
|
||||
}
|
||||
const lowered = raw.toLowerCase();
|
||||
if (lowered === "utc" || lowered === "gmt") return { mode: "utc" as const };
|
||||
if (lowered === "local" || lowered === "host") return { mode: "local" as const };
|
||||
if (lowered === "utc" || lowered === "gmt") {
|
||||
return { mode: "utc" as const };
|
||||
}
|
||||
if (lowered === "local" || lowered === "host") {
|
||||
return { mode: "local" as const };
|
||||
}
|
||||
if (lowered === "user") {
|
||||
return {
|
||||
mode: "iana" as const,
|
||||
@@ -90,16 +104,24 @@ export async function prependSystemEvents(params: {
|
||||
.toReversed()
|
||||
.find((part) => part.type === "timeZoneName")
|
||||
?.value?.trim();
|
||||
if (!yyyy || !mm || !dd || !hh || !min || !sec) return undefined;
|
||||
if (!yyyy || !mm || !dd || !hh || !min || !sec) {
|
||||
return undefined;
|
||||
}
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${sec}${tz ? ` ${tz}` : ""}`;
|
||||
};
|
||||
|
||||
const formatSystemEventTimestamp = (ts: number, cfg: OpenClawConfig) => {
|
||||
const date = new Date(ts);
|
||||
if (Number.isNaN(date.getTime())) return "unknown-time";
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return "unknown-time";
|
||||
}
|
||||
const zone = resolveSystemEventTimezone(cfg);
|
||||
if (zone.mode === "utc") return formatUtcTimestamp(date);
|
||||
if (zone.mode === "local") return formatZonedTimestamp(date) ?? "unknown-time";
|
||||
if (zone.mode === "utc") {
|
||||
return formatUtcTimestamp(date);
|
||||
}
|
||||
if (zone.mode === "local") {
|
||||
return formatZonedTimestamp(date) ?? "unknown-time";
|
||||
}
|
||||
return formatZonedTimestamp(date, zone.timeZone) ?? "unknown-time";
|
||||
};
|
||||
|
||||
@@ -109,16 +131,22 @@ export async function prependSystemEvents(params: {
|
||||
...queued
|
||||
.map((event) => {
|
||||
const compacted = compactSystemEvent(event.text);
|
||||
if (!compacted) return null;
|
||||
if (!compacted) {
|
||||
return null;
|
||||
}
|
||||
return `[${formatSystemEventTimestamp(event.ts, params.cfg)}] ${compacted}`;
|
||||
})
|
||||
.filter((v): v is string => Boolean(v)),
|
||||
);
|
||||
if (params.isMainSession && params.isNewSession) {
|
||||
const summary = await buildChannelSummary(params.cfg);
|
||||
if (summary.length > 0) systemLines.unshift(...summary);
|
||||
if (summary.length > 0) {
|
||||
systemLines.unshift(...summary);
|
||||
}
|
||||
}
|
||||
if (systemLines.length === 0) {
|
||||
return params.prefixedBodyBase;
|
||||
}
|
||||
if (systemLines.length === 0) return params.prefixedBodyBase;
|
||||
|
||||
const block = systemLines.map((l) => `System: ${l}`).join("\n");
|
||||
return `${block}\n\n${params.prefixedBodyBase}`;
|
||||
@@ -252,9 +280,13 @@ export async function incrementCompactionCount(params: {
|
||||
now = Date.now(),
|
||||
tokensAfter,
|
||||
} = params;
|
||||
if (!sessionStore || !sessionKey) return undefined;
|
||||
if (!sessionStore || !sessionKey) {
|
||||
return undefined;
|
||||
}
|
||||
const entry = sessionStore[sessionKey] ?? sessionEntry;
|
||||
if (!entry) return undefined;
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
const nextCount = (entry.compactionCount ?? 0) + 1;
|
||||
// Build update payload with compaction count and optionally updated token counts
|
||||
const updates: Partial<SessionEntry> = {
|
||||
|
||||
@@ -19,7 +19,9 @@ export async function persistSessionUsageUpdate(params: {
|
||||
logLabel?: string;
|
||||
}): Promise<void> {
|
||||
const { storePath, sessionKey } = params;
|
||||
if (!storePath || !sessionKey) return;
|
||||
if (!storePath || !sessionKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const label = params.logLabel ? `${params.logLabel} ` : "";
|
||||
if (hasNonzeroUsage(params.usage)) {
|
||||
|
||||
@@ -60,14 +60,18 @@ function forkSessionFromParent(params: {
|
||||
params.parentEntry.sessionId,
|
||||
params.parentEntry,
|
||||
);
|
||||
if (!parentSessionFile || !fs.existsSync(parentSessionFile)) return null;
|
||||
if (!parentSessionFile || !fs.existsSync(parentSessionFile)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const manager = SessionManager.open(parentSessionFile);
|
||||
const leafId = manager.getLeafId();
|
||||
if (leafId) {
|
||||
const sessionFile = manager.createBranchedSession(leafId) ?? manager.getSessionFile();
|
||||
const sessionId = manager.getSessionId();
|
||||
if (sessionFile && sessionId) return { sessionId, sessionFile };
|
||||
if (sessionFile && sessionId) {
|
||||
return { sessionId, sessionFile };
|
||||
}
|
||||
}
|
||||
const sessionId = crypto.randomUUID();
|
||||
const timestamp = new Date().toISOString();
|
||||
@@ -165,8 +169,12 @@ export async function initSessionState(params: {
|
||||
const strippedForResetLower = strippedForReset.toLowerCase();
|
||||
|
||||
for (const trigger of resetTriggers) {
|
||||
if (!trigger) continue;
|
||||
if (!resetAuthorized) break;
|
||||
if (!trigger) {
|
||||
continue;
|
||||
}
|
||||
if (!resetAuthorized) {
|
||||
break;
|
||||
}
|
||||
const triggerLower = trigger.toLowerCase();
|
||||
if (trimmedBodyLower === triggerLower || strippedForResetLower === triggerLower) {
|
||||
isNewSession = true;
|
||||
|
||||
@@ -24,7 +24,9 @@ export async function stageSandboxMedia(params: {
|
||||
: ctx.MediaPath?.trim()
|
||||
? [ctx.MediaPath.trim()]
|
||||
: [];
|
||||
if (rawPaths.length === 0 || !sessionKey) return;
|
||||
if (rawPaths.length === 0 || !sessionKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sandbox = await ensureSandboxWorkspaceForSession({
|
||||
config: cfg,
|
||||
@@ -37,11 +39,15 @@ export async function stageSandboxMedia(params: {
|
||||
? path.join(CONFIG_DIR, "media", "remote-cache", sessionKey)
|
||||
: null;
|
||||
const effectiveWorkspaceDir = sandbox?.workspaceDir ?? remoteMediaCacheDir;
|
||||
if (!effectiveWorkspaceDir) return;
|
||||
if (!effectiveWorkspaceDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolveAbsolutePath = (value: string): string | null => {
|
||||
let resolved = value.trim();
|
||||
if (!resolved) return null;
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
if (resolved.startsWith("file://")) {
|
||||
try {
|
||||
resolved = fileURLToPath(resolved);
|
||||
@@ -49,7 +55,9 @@ export async function stageSandboxMedia(params: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!path.isAbsolute(resolved)) return null;
|
||||
if (!path.isAbsolute(resolved)) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
@@ -65,11 +73,17 @@ export async function stageSandboxMedia(params: {
|
||||
|
||||
for (const raw of rawPaths) {
|
||||
const source = resolveAbsolutePath(raw);
|
||||
if (!source) continue;
|
||||
if (staged.has(source)) continue;
|
||||
if (!source) {
|
||||
continue;
|
||||
}
|
||||
if (staged.has(source)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const baseName = path.basename(source);
|
||||
if (!baseName) continue;
|
||||
if (!baseName) {
|
||||
continue;
|
||||
}
|
||||
const parsed = path.parse(baseName);
|
||||
let fileName = baseName;
|
||||
let suffix = 1;
|
||||
@@ -93,9 +107,13 @@ export async function stageSandboxMedia(params: {
|
||||
|
||||
const rewriteIfStaged = (value: string | undefined): string | undefined => {
|
||||
const raw = value?.trim();
|
||||
if (!raw) return value;
|
||||
if (!raw) {
|
||||
return value;
|
||||
}
|
||||
const abs = resolveAbsolutePath(raw);
|
||||
if (!abs) return value;
|
||||
if (!abs) {
|
||||
return value;
|
||||
}
|
||||
const mapped = staged.get(abs);
|
||||
return mapped ?? value;
|
||||
};
|
||||
@@ -152,8 +170,11 @@ async function scpFile(remoteHost: string, remotePath: string, localPath: string
|
||||
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`scp failed (${code}): ${stderr.trim()}`));
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`scp failed (${code}): ${stderr.trim()}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,9 +20,13 @@ type ConsumeOptions = {
|
||||
|
||||
const splitTrailingDirective = (text: string): { text: string; tail: string } => {
|
||||
const openIndex = text.lastIndexOf("[[");
|
||||
if (openIndex < 0) return { text, tail: "" };
|
||||
if (openIndex < 0) {
|
||||
return { text, tail: "" };
|
||||
}
|
||||
const closeIndex = text.indexOf("]]", openIndex + 2);
|
||||
if (closeIndex >= 0) return { text, tail: "" };
|
||||
if (closeIndex >= 0) {
|
||||
return { text, tail: "" };
|
||||
}
|
||||
return {
|
||||
text: text.slice(0, openIndex),
|
||||
tail: text.slice(openIndex),
|
||||
|
||||
@@ -2,23 +2,37 @@ import type { SubagentRunRecord } from "../../agents/subagent-registry.js";
|
||||
import { truncateUtf16Safe } from "../../utils.js";
|
||||
|
||||
export function formatDurationShort(valueMs?: number) {
|
||||
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) return "n/a";
|
||||
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
|
||||
return "n/a";
|
||||
}
|
||||
const totalSeconds = Math.round(valueMs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (hours > 0) return `${hours}h${minutes}m`;
|
||||
if (minutes > 0) return `${minutes}m${seconds}s`;
|
||||
if (hours > 0) {
|
||||
return `${hours}h${minutes}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m${seconds}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
export function formatAgeShort(valueMs?: number) {
|
||||
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) return "n/a";
|
||||
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
|
||||
return "n/a";
|
||||
}
|
||||
const minutes = Math.round(valueMs / 60_000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (minutes < 1) {
|
||||
return "just now";
|
||||
}
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m ago`;
|
||||
}
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 48) return `${hours}h ago`;
|
||||
if (hours < 48) {
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
const days = Math.round(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
@@ -31,12 +45,16 @@ export function resolveSubagentLabel(entry: SubagentRunRecord, fallback = "subag
|
||||
export function formatRunLabel(entry: SubagentRunRecord, options?: { maxLength?: number }) {
|
||||
const raw = resolveSubagentLabel(entry);
|
||||
const maxLength = options?.maxLength ?? 72;
|
||||
if (!Number.isFinite(maxLength) || maxLength <= 0) return raw;
|
||||
if (!Number.isFinite(maxLength) || maxLength <= 0) {
|
||||
return raw;
|
||||
}
|
||||
return raw.length > maxLength ? `${truncateUtf16Safe(raw, maxLength).trimEnd()}…` : raw;
|
||||
}
|
||||
|
||||
export function formatRunStatus(entry: SubagentRunRecord) {
|
||||
if (!entry.endedAt) return "running";
|
||||
if (!entry.endedAt) {
|
||||
return "running";
|
||||
}
|
||||
const status = entry.outcome?.status ?? "done";
|
||||
return status === "ok" ? "done" : status;
|
||||
}
|
||||
|
||||
@@ -17,9 +17,15 @@ export function resolveTypingMode({
|
||||
wasMentioned,
|
||||
isHeartbeat,
|
||||
}: TypingModeContext): TypingMode {
|
||||
if (isHeartbeat) return "never";
|
||||
if (configured) return configured;
|
||||
if (!isGroupChat || wasMentioned) return "instant";
|
||||
if (isHeartbeat) {
|
||||
return "never";
|
||||
}
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
if (!isGroupChat || wasMentioned) {
|
||||
return "instant";
|
||||
}
|
||||
return DEFAULT_GROUP_TYPING_MODE;
|
||||
}
|
||||
|
||||
@@ -51,23 +57,33 @@ export function createTypingSignaler(params: {
|
||||
|
||||
const isRenderableText = (text?: string): boolean => {
|
||||
const trimmed = text?.trim();
|
||||
if (!trimmed) return false;
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
return !isSilentReplyText(trimmed, SILENT_REPLY_TOKEN);
|
||||
};
|
||||
|
||||
const signalRunStart = async () => {
|
||||
if (disabled || !shouldStartImmediately) return;
|
||||
if (disabled || !shouldStartImmediately) {
|
||||
return;
|
||||
}
|
||||
await typing.startTypingLoop();
|
||||
};
|
||||
|
||||
const signalMessageStart = async () => {
|
||||
if (disabled || !shouldStartOnMessageStart) return;
|
||||
if (!hasRenderableText) return;
|
||||
if (disabled || !shouldStartOnMessageStart) {
|
||||
return;
|
||||
}
|
||||
if (!hasRenderableText) {
|
||||
return;
|
||||
}
|
||||
await typing.startTypingLoop();
|
||||
};
|
||||
|
||||
const signalTextDelta = async (text?: string) => {
|
||||
if (disabled) return;
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
const renderable = isRenderableText(text);
|
||||
if (renderable) {
|
||||
hasRenderableText = true;
|
||||
@@ -87,14 +103,20 @@ export function createTypingSignaler(params: {
|
||||
};
|
||||
|
||||
const signalReasoningDelta = async () => {
|
||||
if (disabled || !shouldStartOnReasoning) return;
|
||||
if (!hasRenderableText) return;
|
||||
if (disabled || !shouldStartOnReasoning) {
|
||||
return;
|
||||
}
|
||||
if (!hasRenderableText) {
|
||||
return;
|
||||
}
|
||||
await typing.startTypingLoop();
|
||||
typing.refreshTypingTtl();
|
||||
};
|
||||
|
||||
const signalToolStart = async () => {
|
||||
if (disabled) return;
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
// Start typing as soon as tools begin executing, even before the first text delta.
|
||||
if (!typing.isActive()) {
|
||||
await typing.startTypingLoop();
|
||||
|
||||
@@ -38,7 +38,9 @@ export function createTypingController(params: {
|
||||
const typingIntervalMs = typingIntervalSeconds * 1000;
|
||||
|
||||
const formatTypingTtl = (ms: number) => {
|
||||
if (ms % 60_000 === 0) return `${ms / 60_000}m`;
|
||||
if (ms % 60_000 === 0) {
|
||||
return `${ms / 60_000}m`;
|
||||
}
|
||||
return `${Math.round(ms / 1000)}s`;
|
||||
};
|
||||
|
||||
@@ -50,7 +52,9 @@ export function createTypingController(params: {
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (sealed) return;
|
||||
if (sealed) {
|
||||
return;
|
||||
}
|
||||
if (typingTtlTimer) {
|
||||
clearTimeout(typingTtlTimer);
|
||||
typingTtlTimer = undefined;
|
||||
@@ -64,14 +68,22 @@ export function createTypingController(params: {
|
||||
};
|
||||
|
||||
const refreshTypingTtl = () => {
|
||||
if (sealed) return;
|
||||
if (!typingIntervalMs || typingIntervalMs <= 0) return;
|
||||
if (typingTtlMs <= 0) return;
|
||||
if (sealed) {
|
||||
return;
|
||||
}
|
||||
if (!typingIntervalMs || typingIntervalMs <= 0) {
|
||||
return;
|
||||
}
|
||||
if (typingTtlMs <= 0) {
|
||||
return;
|
||||
}
|
||||
if (typingTtlTimer) {
|
||||
clearTimeout(typingTtlTimer);
|
||||
}
|
||||
typingTtlTimer = setTimeout(() => {
|
||||
if (!typingTimer) return;
|
||||
if (!typingTimer) {
|
||||
return;
|
||||
}
|
||||
log?.(`typing TTL reached (${formatTypingTtl(typingTtlMs)}); stopping typing indicator`);
|
||||
cleanup();
|
||||
}, typingTtlMs);
|
||||
@@ -80,37 +92,59 @@ export function createTypingController(params: {
|
||||
const isActive = () => active && !sealed;
|
||||
|
||||
const triggerTyping = async () => {
|
||||
if (sealed) return;
|
||||
if (sealed) {
|
||||
return;
|
||||
}
|
||||
await onReplyStart?.();
|
||||
};
|
||||
|
||||
const ensureStart = async () => {
|
||||
if (sealed) return;
|
||||
if (sealed) {
|
||||
return;
|
||||
}
|
||||
// Late callbacks after a run completed should never restart typing.
|
||||
if (runComplete) return;
|
||||
if (runComplete) {
|
||||
return;
|
||||
}
|
||||
if (!active) {
|
||||
active = true;
|
||||
}
|
||||
if (started) return;
|
||||
if (started) {
|
||||
return;
|
||||
}
|
||||
started = true;
|
||||
await triggerTyping();
|
||||
};
|
||||
|
||||
const maybeStopOnIdle = () => {
|
||||
if (!active) return;
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
// Stop only when the model run is done and the dispatcher queue is empty.
|
||||
if (runComplete && dispatchIdle) cleanup();
|
||||
if (runComplete && dispatchIdle) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
const startTypingLoop = async () => {
|
||||
if (sealed) return;
|
||||
if (runComplete) return;
|
||||
if (sealed) {
|
||||
return;
|
||||
}
|
||||
if (runComplete) {
|
||||
return;
|
||||
}
|
||||
// Always refresh TTL when called, even if loop already running.
|
||||
// This keeps typing alive during long tool executions.
|
||||
refreshTypingTtl();
|
||||
if (!onReplyStart) return;
|
||||
if (typingIntervalMs <= 0) return;
|
||||
if (typingTimer) return;
|
||||
if (!onReplyStart) {
|
||||
return;
|
||||
}
|
||||
if (typingIntervalMs <= 0) {
|
||||
return;
|
||||
}
|
||||
if (typingTimer) {
|
||||
return;
|
||||
}
|
||||
await ensureStart();
|
||||
typingTimer = setInterval(() => {
|
||||
void triggerTyping();
|
||||
@@ -118,10 +152,16 @@ export function createTypingController(params: {
|
||||
};
|
||||
|
||||
const startTypingOnText = async (text?: string) => {
|
||||
if (sealed) return;
|
||||
if (sealed) {
|
||||
return;
|
||||
}
|
||||
const trimmed = text?.trim();
|
||||
if (!trimmed) return;
|
||||
if (silentToken && isSilentReplyText(trimmed, silentToken)) return;
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
if (silentToken && isSilentReplyText(trimmed, silentToken)) {
|
||||
return;
|
||||
}
|
||||
refreshTypingTtl();
|
||||
await startTypingLoop();
|
||||
};
|
||||
|
||||
@@ -4,9 +4,15 @@ export type SendPolicyOverride = "allow" | "deny";
|
||||
|
||||
export function normalizeSendPolicyOverride(raw?: string | null): SendPolicyOverride | undefined {
|
||||
const value = raw?.trim().toLowerCase();
|
||||
if (!value) return undefined;
|
||||
if (value === "allow" || value === "on") return "allow";
|
||||
if (value === "deny" || value === "off") return "deny";
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
if (value === "allow" || value === "on") {
|
||||
return "allow";
|
||||
}
|
||||
if (value === "deny" || value === "off") {
|
||||
return "deny";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -14,14 +20,22 @@ export function parseSendPolicyCommand(raw?: string): {
|
||||
hasCommand: boolean;
|
||||
mode?: SendPolicyOverride | "inherit";
|
||||
} {
|
||||
if (!raw) return { hasCommand: false };
|
||||
if (!raw) {
|
||||
return { hasCommand: false };
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { hasCommand: false };
|
||||
if (!trimmed) {
|
||||
return { hasCommand: false };
|
||||
}
|
||||
const normalized = normalizeCommandBody(trimmed);
|
||||
const match = normalized.match(/^\/send(?:\s+([a-zA-Z]+))?\s*$/i);
|
||||
if (!match) return { hasCommand: false };
|
||||
if (!match) {
|
||||
return { hasCommand: false };
|
||||
}
|
||||
const token = match[1]?.trim().toLowerCase();
|
||||
if (!token) return { hasCommand: true };
|
||||
if (!token) {
|
||||
return { hasCommand: true };
|
||||
}
|
||||
if (token === "inherit" || token === "default" || token === "reset") {
|
||||
return { hasCommand: true, mode: "inherit" };
|
||||
}
|
||||
|
||||
@@ -9,10 +9,14 @@ import { listChatCommands } from "./commands-registry.js";
|
||||
function resolveReservedCommandNames(): Set<string> {
|
||||
const reserved = new Set<string>();
|
||||
for (const command of listChatCommands()) {
|
||||
if (command.nativeName) reserved.add(command.nativeName.toLowerCase());
|
||||
if (command.nativeName) {
|
||||
reserved.add(command.nativeName.toLowerCase());
|
||||
}
|
||||
for (const alias of command.textAliases) {
|
||||
const trimmed = alias.trim();
|
||||
if (!trimmed.startsWith("/")) continue;
|
||||
if (!trimmed.startsWith("/")) {
|
||||
continue;
|
||||
}
|
||||
reserved.add(trimmed.slice(1).toLowerCase());
|
||||
}
|
||||
}
|
||||
@@ -41,7 +45,9 @@ export function listSkillCommandsForAgents(params: {
|
||||
const agentIds = params.agentIds ?? listAgentIds(params.cfg);
|
||||
for (const agentId of agentIds) {
|
||||
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, agentId);
|
||||
if (!fs.existsSync(workspaceDir)) continue;
|
||||
if (!fs.existsSync(workspaceDir)) {
|
||||
continue;
|
||||
}
|
||||
const commands = buildWorkspaceSkillCommandSpecs(workspaceDir, {
|
||||
config: params.cfg,
|
||||
eligibility: { remote: getRemoteSkillEligibility() },
|
||||
@@ -67,12 +73,18 @@ function findSkillCommand(
|
||||
rawName: string,
|
||||
): SkillCommandSpec | undefined {
|
||||
const trimmed = rawName.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const lowered = trimmed.toLowerCase();
|
||||
const normalized = normalizeSkillCommandLookup(trimmed);
|
||||
return skillCommands.find((entry) => {
|
||||
if (entry.name.toLowerCase() === lowered) return true;
|
||||
if (entry.skillName.toLowerCase() === lowered) return true;
|
||||
if (entry.name.toLowerCase() === lowered) {
|
||||
return true;
|
||||
}
|
||||
if (entry.skillName.toLowerCase() === lowered) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
normalizeSkillCommandLookup(entry.name) === normalized ||
|
||||
normalizeSkillCommandLookup(entry.skillName) === normalized
|
||||
@@ -85,23 +97,37 @@ export function resolveSkillCommandInvocation(params: {
|
||||
skillCommands: SkillCommandSpec[];
|
||||
}): { command: SkillCommandSpec; args?: string } | null {
|
||||
const trimmed = params.commandBodyNormalized.trim();
|
||||
if (!trimmed.startsWith("/")) return null;
|
||||
if (!trimmed.startsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
const match = trimmed.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/);
|
||||
if (!match) return null;
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const commandName = match[1]?.trim().toLowerCase();
|
||||
if (!commandName) return null;
|
||||
if (!commandName) {
|
||||
return null;
|
||||
}
|
||||
if (commandName === "skill") {
|
||||
const remainder = match[2]?.trim();
|
||||
if (!remainder) return null;
|
||||
if (!remainder) {
|
||||
return null;
|
||||
}
|
||||
const skillMatch = remainder.match(/^([^\s]+)(?:\s+([\s\S]+))?$/);
|
||||
if (!skillMatch) return null;
|
||||
if (!skillMatch) {
|
||||
return null;
|
||||
}
|
||||
const skillCommand = findSkillCommand(params.skillCommands, skillMatch[1] ?? "");
|
||||
if (!skillCommand) return null;
|
||||
if (!skillCommand) {
|
||||
return null;
|
||||
}
|
||||
const args = skillMatch[2]?.trim();
|
||||
return { command: skillCommand, args: args || undefined };
|
||||
}
|
||||
const command = params.skillCommands.find((entry) => entry.name.toLowerCase() === commandName);
|
||||
if (!command) return null;
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
const args = match[2]?.trim();
|
||||
return { command, args: args || undefined };
|
||||
}
|
||||
|
||||
+96
-32
@@ -84,16 +84,24 @@ function resolveRuntimeLabel(
|
||||
sessionKey,
|
||||
});
|
||||
const sandboxMode = runtimeStatus.mode ?? "off";
|
||||
if (sandboxMode === "off") return "direct";
|
||||
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";
|
||||
if (sandboxMode === "off") {
|
||||
return "direct";
|
||||
}
|
||||
const sandboxed = (() => {
|
||||
if (!sessionKey) return false;
|
||||
if (sandboxMode === "all") return true;
|
||||
if (!sessionKey) {
|
||||
return false;
|
||||
}
|
||||
if (sandboxMode === "all") {
|
||||
return true;
|
||||
}
|
||||
if (args.config) {
|
||||
return resolveSandboxRuntimeStatus({
|
||||
cfg: args.config,
|
||||
@@ -128,32 +136,48 @@ export const formatContextUsageShort = (
|
||||
) => `Context ${formatTokens(total, contextTokens ?? null)}`;
|
||||
|
||||
const formatAge = (ms?: number | null) => {
|
||||
if (!ms || ms < 0) return "unknown";
|
||||
if (!ms || ms < 0) {
|
||||
return "unknown";
|
||||
}
|
||||
const minutes = Math.round(ms / 60_000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (minutes < 1) {
|
||||
return "just now";
|
||||
}
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m ago`;
|
||||
}
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 48) return `${hours}h ago`;
|
||||
if (hours < 48) {
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
const days = Math.round(hours / 24);
|
||||
return `${days}d ago`;
|
||||
};
|
||||
|
||||
const formatQueueDetails = (queue?: QueueStatus) => {
|
||||
if (!queue) return "";
|
||||
if (!queue) {
|
||||
return "";
|
||||
}
|
||||
const depth = typeof queue.depth === "number" ? `depth ${queue.depth}` : null;
|
||||
if (!queue.showDetails) {
|
||||
return depth ? ` (${depth})` : "";
|
||||
}
|
||||
const detailParts: string[] = [];
|
||||
if (depth) detailParts.push(depth);
|
||||
if (depth) {
|
||||
detailParts.push(depth);
|
||||
}
|
||||
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`;
|
||||
detailParts.push(`debounce ${label}`);
|
||||
}
|
||||
if (typeof queue.cap === "number") detailParts.push(`cap ${queue.cap}`);
|
||||
if (queue.dropPolicy) detailParts.push(`drop ${queue.dropPolicy}`);
|
||||
if (typeof queue.cap === "number") {
|
||||
detailParts.push(`cap ${queue.cap}`);
|
||||
}
|
||||
if (queue.dropPolicy) {
|
||||
detailParts.push(`drop ${queue.dropPolicy}`);
|
||||
}
|
||||
return detailParts.length ? ` (${detailParts.join(" · ")})` : "";
|
||||
};
|
||||
|
||||
@@ -170,9 +194,13 @@ const readUsageFromSessionLog = (
|
||||
}
|
||||
| undefined => {
|
||||
// Transcripts are stored at the session file path (fallback: ~/.openclaw/sessions/<SessionId>.jsonl)
|
||||
if (!sessionId) return undefined;
|
||||
if (!sessionId) {
|
||||
return undefined;
|
||||
}
|
||||
const logPath = resolveSessionFilePath(sessionId, sessionEntry);
|
||||
if (!fs.existsSync(logPath)) return undefined;
|
||||
if (!fs.existsSync(logPath)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const lines = fs.readFileSync(logPath, "utf-8").split(/\n+/);
|
||||
@@ -183,7 +211,9 @@ const readUsageFromSessionLog = (
|
||||
let lastUsage: ReturnType<typeof normalizeUsage> | undefined;
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(line) as {
|
||||
message?: {
|
||||
@@ -195,19 +225,25 @@ const readUsageFromSessionLog = (
|
||||
};
|
||||
const usageRaw = parsed.message?.usage ?? parsed.usage;
|
||||
const usage = normalizeUsage(usageRaw);
|
||||
if (usage) lastUsage = usage;
|
||||
if (usage) {
|
||||
lastUsage = usage;
|
||||
}
|
||||
model = parsed.message?.model ?? parsed.model ?? model;
|
||||
} catch {
|
||||
// ignore bad lines
|
||||
}
|
||||
}
|
||||
|
||||
if (!lastUsage) return undefined;
|
||||
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;
|
||||
if (promptTokens === 0 && total === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return { input, output, promptTokens, total, model };
|
||||
} catch {
|
||||
return undefined;
|
||||
@@ -215,14 +251,18 @@ const readUsageFromSessionLog = (
|
||||
};
|
||||
|
||||
const formatUsagePair = (input?: number | null, output?: number | null) => {
|
||||
if (input == null && output == null) return null;
|
||||
if (input == null && output == null) {
|
||||
return null;
|
||||
}
|
||||
const inputLabel = typeof input === "number" ? formatTokenCount(input) : "?";
|
||||
const outputLabel = typeof output === "number" ? formatTokenCount(output) : "?";
|
||||
return `🧮 Tokens: ${inputLabel} in / ${outputLabel} out`;
|
||||
};
|
||||
|
||||
const formatMediaUnderstandingLine = (decisions?: MediaUnderstandingDecision[]) => {
|
||||
if (!decisions || decisions.length === 0) return null;
|
||||
if (!decisions || decisions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const parts = decisions
|
||||
.map((decision) => {
|
||||
const count = decision.attachments.length;
|
||||
@@ -253,8 +293,12 @@ const formatMediaUnderstandingLine = (decisions?: MediaUnderstandingDecision[])
|
||||
return null;
|
||||
})
|
||||
.filter((part): part is string => part != null);
|
||||
if (parts.length === 0) return null;
|
||||
if (parts.every((part) => part.endsWith(" none"))) return null;
|
||||
if (parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (parts.every((part) => part.endsWith(" none"))) {
|
||||
return null;
|
||||
}
|
||||
return `📎 Media: ${parts.join(" · ")}`;
|
||||
};
|
||||
|
||||
@@ -262,7 +306,9 @@ const formatVoiceModeLine = (
|
||||
config?: OpenClawConfig,
|
||||
sessionEntry?: SessionEntry,
|
||||
): string | null => {
|
||||
if (!config) return null;
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
const ttsConfig = resolveTtsConfig(config);
|
||||
const prefsPath = resolveTtsPrefsPath(ttsConfig);
|
||||
const autoMode = resolveTtsAutoMode({
|
||||
@@ -270,7 +316,9 @@ const formatVoiceModeLine = (
|
||||
prefsPath,
|
||||
sessionAuto: sessionEntry?.ttsAuto,
|
||||
});
|
||||
if (autoMode === "off") return null;
|
||||
if (autoMode === "off") {
|
||||
return null;
|
||||
}
|
||||
const provider = getTtsProvider(ttsConfig, prefsPath);
|
||||
const maxLength = getTtsMaxLength(prefsPath);
|
||||
const summarize = isSummarizationEnabled(prefsPath) ? "on" : "off";
|
||||
@@ -310,12 +358,18 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
if (!totalTokens || totalTokens === 0 || candidate > totalTokens) {
|
||||
totalTokens = candidate;
|
||||
}
|
||||
if (!model) model = logUsage.model ?? model;
|
||||
if (!model) {
|
||||
model = logUsage.model ?? model;
|
||||
}
|
||||
if (!contextTokens && logUsage.model) {
|
||||
contextTokens = lookupContextTokens(logUsage.model) ?? contextTokens;
|
||||
}
|
||||
if (!inputTokens || inputTokens === 0) inputTokens = logUsage.input;
|
||||
if (!outputTokens || outputTokens === 0) outputTokens = logUsage.output;
|
||||
if (!inputTokens || inputTokens === 0) {
|
||||
inputTokens = logUsage.input;
|
||||
}
|
||||
if (!outputTokens || outputTokens === 0) {
|
||||
outputTokens = logUsage.output;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,8 +530,12 @@ export function buildHelpMessage(cfg?: OpenClawConfig): string {
|
||||
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");
|
||||
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("");
|
||||
@@ -521,7 +579,9 @@ function formatCommandEntry(command: ChatCommandDefinition): string {
|
||||
.filter((alias) => alias.toLowerCase() !== primary.toLowerCase())
|
||||
.filter((alias) => {
|
||||
const key = alias.toLowerCase();
|
||||
if (seen.has(key)) return false;
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
@@ -544,7 +604,9 @@ function buildCommandItems(
|
||||
|
||||
for (const category of CATEGORY_ORDER) {
|
||||
const categoryCommands = grouped.get(category) ?? [];
|
||||
if (categoryCommands.length === 0) continue;
|
||||
if (categoryCommands.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const label = CATEGORY_LABELS[category];
|
||||
for (const command of categoryCommands) {
|
||||
items.push({ label, text: formatCommandEntry(command) });
|
||||
@@ -568,7 +630,9 @@ function formatCommandList(items: CommandsListItem[]): string {
|
||||
|
||||
for (const item of items) {
|
||||
if (item.label !== currentLabel) {
|
||||
if (lines.length > 0) lines.push("");
|
||||
if (lines.length > 0) {
|
||||
lines.push("");
|
||||
}
|
||||
lines.push(item.label);
|
||||
currentLabel = item.label;
|
||||
}
|
||||
|
||||
@@ -138,8 +138,12 @@ export type TemplateContext = MsgContext & {
|
||||
};
|
||||
|
||||
function formatTemplateValue(value: unknown): string {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
||||
return String(value);
|
||||
}
|
||||
@@ -149,8 +153,12 @@ function formatTemplateValue(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.flatMap((entry) => {
|
||||
if (entry == null) return [];
|
||||
if (typeof entry === "string") return [entry];
|
||||
if (entry == null) {
|
||||
return [];
|
||||
}
|
||||
if (typeof entry === "string") {
|
||||
return [entry];
|
||||
}
|
||||
if (typeof entry === "number" || typeof entry === "boolean" || typeof entry === "bigint") {
|
||||
return [String(entry)];
|
||||
}
|
||||
@@ -166,7 +174,9 @@ function formatTemplateValue(value: unknown): string {
|
||||
|
||||
// Simple {{Placeholder}} interpolation using inbound message context.
|
||||
export function applyTemplate(str: string | undefined, ctx: TemplateContext) {
|
||||
if (!str) return "";
|
||||
if (!str) {
|
||||
return "";
|
||||
}
|
||||
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
|
||||
const value = ctx[key as keyof TemplateContext];
|
||||
return formatTemplateValue(value);
|
||||
|
||||
+122
-41
@@ -7,9 +7,13 @@ export type ReasoningLevel = "off" | "on" | "stream";
|
||||
export type UsageDisplayLevel = "off" | "tokens" | "full";
|
||||
|
||||
function normalizeProviderId(provider?: string | null): string {
|
||||
if (!provider) return "";
|
||||
if (!provider) {
|
||||
return "";
|
||||
}
|
||||
const normalized = provider.trim().toLowerCase();
|
||||
if (normalized === "z.ai" || normalized === "z-ai") return "zai";
|
||||
if (normalized === "z.ai" || normalized === "z-ai") {
|
||||
return "zai";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -32,24 +36,44 @@ const XHIGH_MODEL_IDS = new Set(
|
||||
|
||||
// Normalize user-provided thinking level strings to the canonical enum.
|
||||
export function normalizeThinkLevel(raw?: string | null): ThinkLevel | undefined {
|
||||
if (!raw) return undefined;
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const key = raw.toLowerCase();
|
||||
if (["off"].includes(key)) return "off";
|
||||
if (["on", "enable", "enabled"].includes(key)) return "low";
|
||||
if (["min", "minimal"].includes(key)) return "minimal";
|
||||
if (["low", "thinkhard", "think-hard", "think_hard"].includes(key)) return "low";
|
||||
if (["mid", "med", "medium", "thinkharder", "think-harder", "harder"].includes(key))
|
||||
if (["off"].includes(key)) {
|
||||
return "off";
|
||||
}
|
||||
if (["on", "enable", "enabled"].includes(key)) {
|
||||
return "low";
|
||||
}
|
||||
if (["min", "minimal"].includes(key)) {
|
||||
return "minimal";
|
||||
}
|
||||
if (["low", "thinkhard", "think-hard", "think_hard"].includes(key)) {
|
||||
return "low";
|
||||
}
|
||||
if (["mid", "med", "medium", "thinkharder", "think-harder", "harder"].includes(key)) {
|
||||
return "medium";
|
||||
if (["high", "ultra", "ultrathink", "think-hard", "thinkhardest", "highest", "max"].includes(key))
|
||||
}
|
||||
if (
|
||||
["high", "ultra", "ultrathink", "think-hard", "thinkhardest", "highest", "max"].includes(key)
|
||||
) {
|
||||
return "high";
|
||||
if (["xhigh", "x-high", "x_high"].includes(key)) return "xhigh";
|
||||
if (["think"].includes(key)) return "minimal";
|
||||
}
|
||||
if (["xhigh", "x-high", "x_high"].includes(key)) {
|
||||
return "xhigh";
|
||||
}
|
||||
if (["think"].includes(key)) {
|
||||
return "minimal";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function supportsXHighThinking(provider?: string | null, model?: string | null): boolean {
|
||||
const modelKey = model?.trim().toLowerCase();
|
||||
if (!modelKey) return false;
|
||||
if (!modelKey) {
|
||||
return false;
|
||||
}
|
||||
const providerKey = provider?.trim().toLowerCase();
|
||||
if (providerKey) {
|
||||
return XHIGH_MODEL_SET.has(`${providerKey}/${modelKey}`);
|
||||
@@ -59,12 +83,16 @@ export function supportsXHighThinking(provider?: string | null, model?: string |
|
||||
|
||||
export function listThinkingLevels(provider?: string | null, model?: string | null): ThinkLevel[] {
|
||||
const levels: ThinkLevel[] = ["off", "minimal", "low", "medium", "high"];
|
||||
if (supportsXHighThinking(provider, model)) levels.push("xhigh");
|
||||
if (supportsXHighThinking(provider, model)) {
|
||||
levels.push("xhigh");
|
||||
}
|
||||
return levels;
|
||||
}
|
||||
|
||||
export function listThinkingLevelLabels(provider?: string | null, model?: string | null): string[] {
|
||||
if (isBinaryThinkingProvider(provider)) return ["off", "on"];
|
||||
if (isBinaryThinkingProvider(provider)) {
|
||||
return ["off", "on"];
|
||||
}
|
||||
return listThinkingLevels(provider, model);
|
||||
}
|
||||
|
||||
@@ -78,40 +106,72 @@ export function formatThinkingLevels(
|
||||
|
||||
export function formatXHighModelHint(): string {
|
||||
const refs = [...XHIGH_MODEL_REFS] as string[];
|
||||
if (refs.length === 0) return "unknown model";
|
||||
if (refs.length === 1) return refs[0];
|
||||
if (refs.length === 2) return `${refs[0]} or ${refs[1]}`;
|
||||
if (refs.length === 0) {
|
||||
return "unknown model";
|
||||
}
|
||||
if (refs.length === 1) {
|
||||
return refs[0];
|
||||
}
|
||||
if (refs.length === 2) {
|
||||
return `${refs[0]} or ${refs[1]}`;
|
||||
}
|
||||
return `${refs.slice(0, -1).join(", ")} or ${refs[refs.length - 1]}`;
|
||||
}
|
||||
|
||||
// Normalize verbose flags used to toggle agent verbosity.
|
||||
export function normalizeVerboseLevel(raw?: string | null): VerboseLevel | undefined {
|
||||
if (!raw) return undefined;
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const key = raw.toLowerCase();
|
||||
if (["off", "false", "no", "0"].includes(key)) return "off";
|
||||
if (["full", "all", "everything"].includes(key)) return "full";
|
||||
if (["on", "minimal", "true", "yes", "1"].includes(key)) return "on";
|
||||
if (["off", "false", "no", "0"].includes(key)) {
|
||||
return "off";
|
||||
}
|
||||
if (["full", "all", "everything"].includes(key)) {
|
||||
return "full";
|
||||
}
|
||||
if (["on", "minimal", "true", "yes", "1"].includes(key)) {
|
||||
return "on";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Normalize system notice flags used to toggle system notifications.
|
||||
export function normalizeNoticeLevel(raw?: string | null): NoticeLevel | undefined {
|
||||
if (!raw) return undefined;
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const key = raw.toLowerCase();
|
||||
if (["off", "false", "no", "0"].includes(key)) return "off";
|
||||
if (["full", "all", "everything"].includes(key)) return "full";
|
||||
if (["on", "minimal", "true", "yes", "1"].includes(key)) return "on";
|
||||
if (["off", "false", "no", "0"].includes(key)) {
|
||||
return "off";
|
||||
}
|
||||
if (["full", "all", "everything"].includes(key)) {
|
||||
return "full";
|
||||
}
|
||||
if (["on", "minimal", "true", "yes", "1"].includes(key)) {
|
||||
return "on";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Normalize response-usage display modes used to toggle per-response usage footers.
|
||||
export function normalizeUsageDisplay(raw?: string | null): UsageDisplayLevel | undefined {
|
||||
if (!raw) return undefined;
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const key = raw.toLowerCase();
|
||||
if (["off", "false", "no", "0", "disable", "disabled"].includes(key)) return "off";
|
||||
if (["on", "true", "yes", "1", "enable", "enabled"].includes(key)) return "tokens";
|
||||
if (["tokens", "token", "tok", "minimal", "min"].includes(key)) return "tokens";
|
||||
if (["full", "session"].includes(key)) return "full";
|
||||
if (["off", "false", "no", "0", "disable", "disabled"].includes(key)) {
|
||||
return "off";
|
||||
}
|
||||
if (["on", "true", "yes", "1", "enable", "enabled"].includes(key)) {
|
||||
return "tokens";
|
||||
}
|
||||
if (["tokens", "token", "tok", "minimal", "min"].includes(key)) {
|
||||
return "tokens";
|
||||
}
|
||||
if (["full", "session"].includes(key)) {
|
||||
return "full";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -121,28 +181,49 @@ export function resolveResponseUsageMode(raw?: string | null): UsageDisplayLevel
|
||||
|
||||
// Normalize elevated flags used to toggle elevated bash permissions.
|
||||
export function normalizeElevatedLevel(raw?: string | null): ElevatedLevel | undefined {
|
||||
if (!raw) return undefined;
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const key = raw.toLowerCase();
|
||||
if (["off", "false", "no", "0"].includes(key)) return "off";
|
||||
if (["full", "auto", "auto-approve", "autoapprove"].includes(key)) return "full";
|
||||
if (["ask", "prompt", "approval", "approve"].includes(key)) return "ask";
|
||||
if (["on", "true", "yes", "1"].includes(key)) return "on";
|
||||
if (["off", "false", "no", "0"].includes(key)) {
|
||||
return "off";
|
||||
}
|
||||
if (["full", "auto", "auto-approve", "autoapprove"].includes(key)) {
|
||||
return "full";
|
||||
}
|
||||
if (["ask", "prompt", "approval", "approve"].includes(key)) {
|
||||
return "ask";
|
||||
}
|
||||
if (["on", "true", "yes", "1"].includes(key)) {
|
||||
return "on";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveElevatedMode(level?: ElevatedLevel | null): ElevatedMode {
|
||||
if (!level || level === "off") return "off";
|
||||
if (level === "full") return "full";
|
||||
if (!level || level === "off") {
|
||||
return "off";
|
||||
}
|
||||
if (level === "full") {
|
||||
return "full";
|
||||
}
|
||||
return "ask";
|
||||
}
|
||||
|
||||
// Normalize reasoning visibility flags used to toggle reasoning exposure.
|
||||
export function normalizeReasoningLevel(raw?: string | null): ReasoningLevel | undefined {
|
||||
if (!raw) return undefined;
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const key = raw.toLowerCase();
|
||||
if (["off", "false", "no", "0", "hide", "hidden", "disable", "disabled"].includes(key))
|
||||
if (["off", "false", "no", "0", "hide", "hidden", "disable", "disabled"].includes(key)) {
|
||||
return "off";
|
||||
if (["on", "true", "yes", "1", "show", "visible", "enable", "enabled"].includes(key)) return "on";
|
||||
if (["stream", "streaming", "draft", "live"].includes(key)) return "stream";
|
||||
}
|
||||
if (["on", "true", "yes", "1", "show", "visible", "enable", "enabled"].includes(key)) {
|
||||
return "on";
|
||||
}
|
||||
if (["stream", "streaming", "draft", "live"].includes(key)) {
|
||||
return "stream";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -9,10 +9,14 @@ export function isSilentReplyText(
|
||||
text: string | undefined,
|
||||
token: string = SILENT_REPLY_TOKEN,
|
||||
): boolean {
|
||||
if (!text) return false;
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
const escaped = escapeRegExp(token);
|
||||
const prefix = new RegExp(`^\\s*${escaped}(?=$|\\W)`);
|
||||
if (prefix.test(text)) return true;
|
||||
if (prefix.test(text)) {
|
||||
return true;
|
||||
}
|
||||
const suffix = new RegExp(`\\b${escaped}\\b\\W*$`);
|
||||
return suffix.test(text);
|
||||
}
|
||||
|
||||
+45
-15
@@ -10,9 +10,13 @@ export function shortenPath(p: string): string {
|
||||
}
|
||||
|
||||
export function shortenMeta(meta: string): string {
|
||||
if (!meta) return meta;
|
||||
if (!meta) {
|
||||
return meta;
|
||||
}
|
||||
const colonIdx = meta.indexOf(":");
|
||||
if (colonIdx === -1) return shortenHomeInString(meta);
|
||||
if (colonIdx === -1) {
|
||||
return shortenHomeInString(meta);
|
||||
}
|
||||
const base = meta.slice(0, colonIdx);
|
||||
const rest = meta.slice(colonIdx);
|
||||
return `${shortenHomeInString(base)}${rest}`;
|
||||
@@ -26,7 +30,9 @@ export function formatToolAggregate(
|
||||
const filtered = (metas ?? []).filter(Boolean).map(shortenMeta);
|
||||
const display = resolveToolDisplay({ name: toolName });
|
||||
const prefix = `${display.emoji} ${display.label}`;
|
||||
if (!filtered.length) return prefix;
|
||||
if (!filtered.length) {
|
||||
return prefix;
|
||||
}
|
||||
|
||||
const rawSegments: string[] = [];
|
||||
// Group by directory and brace-collapse filenames
|
||||
@@ -44,17 +50,23 @@ export function formatToolAggregate(
|
||||
if (parts.length > 1) {
|
||||
const dir = parts.slice(0, -1).join("/");
|
||||
const base = parts.at(-1) ?? m;
|
||||
if (!grouped[dir]) grouped[dir] = [];
|
||||
if (!grouped[dir]) {
|
||||
grouped[dir] = [];
|
||||
}
|
||||
grouped[dir].push(base);
|
||||
} else {
|
||||
if (!grouped["."]) grouped["."] = [];
|
||||
if (!grouped["."]) {
|
||||
grouped["."] = [];
|
||||
}
|
||||
grouped["."].push(m);
|
||||
}
|
||||
}
|
||||
|
||||
const segments = Object.entries(grouped).map(([dir, files]) => {
|
||||
const brace = files.length > 1 ? `{${files.join(", ")}}` : files[0];
|
||||
if (dir === ".") return brace;
|
||||
if (dir === ".") {
|
||||
return brace;
|
||||
}
|
||||
return `${dir}/${brace}`;
|
||||
});
|
||||
|
||||
@@ -78,7 +90,9 @@ function formatMetaForDisplay(
|
||||
if (normalized === "exec" || normalized === "bash") {
|
||||
const { flags, body } = splitExecFlags(meta);
|
||||
if (flags.length > 0) {
|
||||
if (!body) return flags.join(" · ");
|
||||
if (!body) {
|
||||
return flags.join(" · ");
|
||||
}
|
||||
return `${flags.join(" · ")} · ${maybeWrapMarkdown(body, markdown)}`;
|
||||
}
|
||||
}
|
||||
@@ -90,7 +104,9 @@ function splitExecFlags(meta: string): { flags: string[]; body: string } {
|
||||
.split(" · ")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
if (parts.length === 0) return { flags: [], body: "" };
|
||||
if (parts.length === 0) {
|
||||
return { flags: [], body: "" };
|
||||
}
|
||||
const flags: string[] = [];
|
||||
const bodyParts: string[] = [];
|
||||
for (const part of parts) {
|
||||
@@ -104,16 +120,30 @@ function splitExecFlags(meta: string): { flags: string[]; body: string } {
|
||||
}
|
||||
|
||||
function isPathLike(value: string): boolean {
|
||||
if (!value) return false;
|
||||
if (value.includes(" ")) return false;
|
||||
if (value.includes("://")) return false;
|
||||
if (value.includes("·")) return false;
|
||||
if (value.includes("&&") || value.includes("||")) return false;
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
if (value.includes(" ")) {
|
||||
return false;
|
||||
}
|
||||
if (value.includes("://")) {
|
||||
return false;
|
||||
}
|
||||
if (value.includes("·")) {
|
||||
return false;
|
||||
}
|
||||
if (value.includes("&&") || value.includes("||")) {
|
||||
return false;
|
||||
}
|
||||
return /^~?(\/[^\s]+)+$/.test(value);
|
||||
}
|
||||
|
||||
function maybeWrapMarkdown(value: string, markdown?: boolean): string {
|
||||
if (!markdown) return value;
|
||||
if (value.includes("`")) return value;
|
||||
if (!markdown) {
|
||||
return value;
|
||||
}
|
||||
if (value.includes("`")) {
|
||||
return value;
|
||||
}
|
||||
return `\`${value}\``;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user