mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-03 20:08:35 +00:00
chore: Enable "curly" rule to avoid single-statement if confusion/errors.
This commit is contained in:
@@ -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();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user