refactor(auto-reply): split reply flow
This commit is contained in:
File diff suppressed because it is too large
Load Diff
16
src/auto-reply/reply/abort.ts
Normal file
16
src/auto-reply/reply/abort.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
const ABORT_TRIGGERS = new Set(["stop", "esc", "abort", "wait", "exit"]);
|
||||
const ABORT_MEMORY = new Map<string, boolean>();
|
||||
|
||||
export function isAbortTrigger(text?: string): boolean {
|
||||
if (!text) return false;
|
||||
const normalized = text.trim().toLowerCase();
|
||||
return ABORT_TRIGGERS.has(normalized);
|
||||
}
|
||||
|
||||
export function getAbortMemory(key: string): boolean | undefined {
|
||||
return ABORT_MEMORY.get(key);
|
||||
}
|
||||
|
||||
export function setAbortMemory(key: string, value: boolean): void {
|
||||
ABORT_MEMORY.set(key, value);
|
||||
}
|
||||
449
src/auto-reply/reply/agent-runner.ts
Normal file
449
src/auto-reply/reply/agent-runner.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
import crypto from "node:crypto";
|
||||
import { lookupContextTokens } from "../../agents/context.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
|
||||
import {
|
||||
queueEmbeddedPiMessage,
|
||||
runEmbeddedPiAgent,
|
||||
} from "../../agents/pi-embedded.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
type SessionEntry,
|
||||
saveSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { stripHeartbeatToken } from "../heartbeat.js";
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import { createFollowupRunner } from "./followup-runner.js";
|
||||
import {
|
||||
enqueueFollowupRun,
|
||||
type FollowupRun,
|
||||
type QueueSettings,
|
||||
scheduleFollowupDrain,
|
||||
} from "./queue.js";
|
||||
import { extractReplyToTag } from "./reply-tags.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
|
||||
export async function runReplyAgent(params: {
|
||||
commandBody: string;
|
||||
followupRun: FollowupRun;
|
||||
queueKey: string;
|
||||
resolvedQueue: QueueSettings;
|
||||
shouldSteer: boolean;
|
||||
shouldFollowup: boolean;
|
||||
isActive: boolean;
|
||||
isStreaming: boolean;
|
||||
opts?: GetReplyOptions;
|
||||
typing: TypingController;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
defaultModel: string;
|
||||
agentCfgContextTokens?: number;
|
||||
resolvedVerboseLevel: VerboseLevel;
|
||||
isNewSession: boolean;
|
||||
blockStreamingEnabled: boolean;
|
||||
blockReplyChunking?: {
|
||||
minChars: number;
|
||||
maxChars: number;
|
||||
breakPreference: "paragraph" | "newline" | "sentence";
|
||||
};
|
||||
resolvedBlockStreamingBreak: "text_end" | "message_end";
|
||||
sessionCtx: TemplateContext;
|
||||
shouldInjectGroupIntro: boolean;
|
||||
}): Promise<ReplyPayload | ReplyPayload[] | undefined> {
|
||||
const {
|
||||
commandBody,
|
||||
followupRun,
|
||||
queueKey,
|
||||
resolvedQueue,
|
||||
shouldSteer,
|
||||
shouldFollowup,
|
||||
isActive,
|
||||
isStreaming,
|
||||
opts,
|
||||
typing,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
defaultModel,
|
||||
agentCfgContextTokens,
|
||||
resolvedVerboseLevel,
|
||||
isNewSession,
|
||||
blockStreamingEnabled,
|
||||
blockReplyChunking,
|
||||
resolvedBlockStreamingBreak,
|
||||
sessionCtx,
|
||||
shouldInjectGroupIntro,
|
||||
} = params;
|
||||
|
||||
const shouldEmitToolResult = () => {
|
||||
if (!sessionKey || !storePath) {
|
||||
return resolvedVerboseLevel === "on";
|
||||
}
|
||||
try {
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = store[sessionKey];
|
||||
const current = normalizeVerboseLevel(entry?.verboseLevel);
|
||||
if (current) return current === "on";
|
||||
} catch {
|
||||
// ignore store read failures
|
||||
}
|
||||
return resolvedVerboseLevel === "on";
|
||||
};
|
||||
|
||||
const streamedPayloadKeys = new Set<string>();
|
||||
const pendingStreamedPayloadKeys = new Set<string>();
|
||||
const pendingBlockTasks = new Set<Promise<void>>();
|
||||
let didStreamBlockReply = false;
|
||||
const buildPayloadKey = (payload: ReplyPayload) => {
|
||||
const text = payload.text?.trim() ?? "";
|
||||
const mediaList = payload.mediaUrls?.length
|
||||
? payload.mediaUrls
|
||||
: payload.mediaUrl
|
||||
? [payload.mediaUrl]
|
||||
: [];
|
||||
return JSON.stringify({
|
||||
text,
|
||||
mediaList,
|
||||
replyToId: payload.replyToId ?? null,
|
||||
});
|
||||
};
|
||||
|
||||
if (shouldSteer && isStreaming) {
|
||||
const steered = queueEmbeddedPiMessage(
|
||||
followupRun.run.sessionId,
|
||||
followupRun.prompt,
|
||||
);
|
||||
if (steered && !shouldFollowup) {
|
||||
if (sessionEntry && sessionStore && sessionKey) {
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
}
|
||||
typing.cleanup();
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (isActive && (shouldFollowup || resolvedQueue.mode === "steer")) {
|
||||
enqueueFollowupRun(queueKey, followupRun, resolvedQueue);
|
||||
if (sessionEntry && sessionStore && sessionKey) {
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
}
|
||||
typing.cleanup();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const runFollowupTurn = createFollowupRunner({
|
||||
opts,
|
||||
typing,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
defaultModel,
|
||||
agentCfgContextTokens,
|
||||
});
|
||||
|
||||
const finalizeWithFollowup = <T>(value: T): T => {
|
||||
scheduleFollowupDrain(queueKey, runFollowupTurn);
|
||||
return value;
|
||||
};
|
||||
|
||||
let didLogHeartbeatStrip = false;
|
||||
try {
|
||||
const runId = crypto.randomUUID();
|
||||
if (sessionKey) {
|
||||
registerAgentRunContext(runId, { sessionKey });
|
||||
}
|
||||
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
||||
try {
|
||||
runResult = await runEmbeddedPiAgent({
|
||||
sessionId: followupRun.run.sessionId,
|
||||
sessionKey,
|
||||
surface: sessionCtx.Surface?.trim().toLowerCase() || undefined,
|
||||
sessionFile: followupRun.run.sessionFile,
|
||||
workspaceDir: followupRun.run.workspaceDir,
|
||||
config: followupRun.run.config,
|
||||
skillsSnapshot: followupRun.run.skillsSnapshot,
|
||||
prompt: commandBody,
|
||||
extraSystemPrompt: followupRun.run.extraSystemPrompt,
|
||||
ownerNumbers: followupRun.run.ownerNumbers,
|
||||
enforceFinalTag: followupRun.run.enforceFinalTag,
|
||||
provider: followupRun.run.provider,
|
||||
model: followupRun.run.model,
|
||||
thinkLevel: followupRun.run.thinkLevel,
|
||||
verboseLevel: followupRun.run.verboseLevel,
|
||||
timeoutMs: followupRun.run.timeoutMs,
|
||||
runId,
|
||||
blockReplyBreak: resolvedBlockStreamingBreak,
|
||||
blockReplyChunking,
|
||||
onPartialReply: opts?.onPartialReply
|
||||
? async (payload) => {
|
||||
let text = payload.text;
|
||||
if (!opts?.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
|
||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||
if (stripped.didStrip && !didLogHeartbeatStrip) {
|
||||
didLogHeartbeatStrip = true;
|
||||
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
|
||||
}
|
||||
if (
|
||||
stripped.shouldSkip &&
|
||||
(payload.mediaUrls?.length ?? 0) === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
text = stripped.text;
|
||||
}
|
||||
await typing.startTypingOnText(text);
|
||||
await opts.onPartialReply?.({
|
||||
text,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
onBlockReply:
|
||||
blockStreamingEnabled && opts?.onBlockReply
|
||||
? async (payload) => {
|
||||
let text = payload.text;
|
||||
if (!opts?.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
|
||||
const stripped = stripHeartbeatToken(text, {
|
||||
mode: "message",
|
||||
});
|
||||
if (stripped.didStrip && !didLogHeartbeatStrip) {
|
||||
didLogHeartbeatStrip = true;
|
||||
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
|
||||
}
|
||||
const hasMedia = (payload.mediaUrls?.length ?? 0) > 0;
|
||||
if (stripped.shouldSkip && !hasMedia) return;
|
||||
text = stripped.text;
|
||||
}
|
||||
const tagResult = extractReplyToTag(
|
||||
text,
|
||||
sessionCtx.MessageSid,
|
||||
);
|
||||
const cleaned = tagResult.cleaned || undefined;
|
||||
const hasMedia = (payload.mediaUrls?.length ?? 0) > 0;
|
||||
if (!cleaned && !hasMedia) return;
|
||||
if (cleaned?.trim() === SILENT_REPLY_TOKEN && !hasMedia) return;
|
||||
const blockPayload: ReplyPayload = {
|
||||
text: cleaned,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
mediaUrl: payload.mediaUrls?.[0],
|
||||
replyToId: tagResult.replyToId,
|
||||
};
|
||||
const payloadKey = buildPayloadKey(blockPayload);
|
||||
if (
|
||||
streamedPayloadKeys.has(payloadKey) ||
|
||||
pendingStreamedPayloadKeys.has(payloadKey)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
pendingStreamedPayloadKeys.add(payloadKey);
|
||||
const task = (async () => {
|
||||
await typing.startTypingOnText(cleaned);
|
||||
await opts.onBlockReply?.(blockPayload);
|
||||
})()
|
||||
.then(() => {
|
||||
streamedPayloadKeys.add(payloadKey);
|
||||
didStreamBlockReply = true;
|
||||
})
|
||||
.catch((err) => {
|
||||
logVerbose(`block reply delivery failed: ${String(err)}`);
|
||||
})
|
||||
.finally(() => {
|
||||
pendingStreamedPayloadKeys.delete(payloadKey);
|
||||
});
|
||||
pendingBlockTasks.add(task);
|
||||
void task.finally(() => pendingBlockTasks.delete(task));
|
||||
}
|
||||
: undefined,
|
||||
shouldEmitToolResult,
|
||||
onToolResult: opts?.onToolResult
|
||||
? async (payload) => {
|
||||
let text = payload.text;
|
||||
if (!opts?.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
|
||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||
if (stripped.didStrip && !didLogHeartbeatStrip) {
|
||||
didLogHeartbeatStrip = true;
|
||||
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
|
||||
}
|
||||
if (
|
||||
stripped.shouldSkip &&
|
||||
(payload.mediaUrls?.length ?? 0) === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
text = stripped.text;
|
||||
}
|
||||
await typing.startTypingOnText(text);
|
||||
await opts.onToolResult?.({ text, mediaUrls: payload.mediaUrls });
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const isContextOverflow =
|
||||
/context.*overflow|too large|context window/i.test(message);
|
||||
defaultRuntime.error(`Embedded agent failed before reply: ${message}`);
|
||||
return finalizeWithFollowup({
|
||||
text: isContextOverflow
|
||||
? "⚠️ Context overflow - conversation too long. Starting fresh might help!"
|
||||
: `⚠️ Agent failed before reply: ${message}. Check gateway logs for details.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
shouldInjectGroupIntro &&
|
||||
sessionEntry &&
|
||||
sessionStore &&
|
||||
sessionKey &&
|
||||
sessionEntry.groupActivationNeedsSystemIntro
|
||||
) {
|
||||
sessionEntry.groupActivationNeedsSystemIntro = false;
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
}
|
||||
|
||||
const payloadArray = runResult.payloads ?? [];
|
||||
if (payloadArray.length === 0) return finalizeWithFollowup(undefined);
|
||||
if (pendingBlockTasks.size > 0) {
|
||||
await Promise.allSettled(pendingBlockTasks);
|
||||
}
|
||||
|
||||
const sanitizedPayloads = opts?.isHeartbeat
|
||||
? payloadArray
|
||||
: payloadArray.flatMap((payload) => {
|
||||
const text = payload.text;
|
||||
if (!text || !text.includes("HEARTBEAT_OK")) return [payload];
|
||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||
if (stripped.didStrip && !didLogHeartbeatStrip) {
|
||||
didLogHeartbeatStrip = true;
|
||||
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
|
||||
}
|
||||
const hasMedia =
|
||||
Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||
if (stripped.shouldSkip && !hasMedia) return [];
|
||||
return [{ ...payload, text: stripped.text }];
|
||||
});
|
||||
|
||||
const replyTaggedPayloads: ReplyPayload[] = sanitizedPayloads
|
||||
.map((payload) => {
|
||||
const { cleaned, replyToId } = extractReplyToTag(
|
||||
payload.text,
|
||||
sessionCtx.MessageSid,
|
||||
);
|
||||
return {
|
||||
...payload,
|
||||
text: cleaned ? cleaned : undefined,
|
||||
replyToId: replyToId ?? payload.replyToId,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(payload) =>
|
||||
payload.text ||
|
||||
payload.mediaUrl ||
|
||||
(payload.mediaUrls && payload.mediaUrls.length > 0),
|
||||
);
|
||||
|
||||
const shouldDropFinalPayloads =
|
||||
blockStreamingEnabled && didStreamBlockReply;
|
||||
const filteredPayloads = shouldDropFinalPayloads
|
||||
? []
|
||||
: blockStreamingEnabled
|
||||
? replyTaggedPayloads.filter(
|
||||
(payload) => !streamedPayloadKeys.has(buildPayloadKey(payload)),
|
||||
)
|
||||
: replyTaggedPayloads;
|
||||
|
||||
if (filteredPayloads.length === 0) return finalizeWithFollowup(undefined);
|
||||
|
||||
const shouldSignalTyping = filteredPayloads.some((payload) => {
|
||||
const trimmed = payload.text?.trim();
|
||||
if (trimmed && trimmed !== SILENT_REPLY_TOKEN) return true;
|
||||
if (payload.mediaUrl) return true;
|
||||
if (payload.mediaUrls && payload.mediaUrls.length > 0) return true;
|
||||
return false;
|
||||
});
|
||||
if (shouldSignalTyping) {
|
||||
await typing.startTypingLoop();
|
||||
}
|
||||
|
||||
if (sessionStore && sessionKey) {
|
||||
const usage = runResult.meta.agentMeta?.usage;
|
||||
const modelUsed = runResult.meta.agentMeta?.model ?? defaultModel;
|
||||
const contextTokensUsed =
|
||||
agentCfgContextTokens ??
|
||||
lookupContextTokens(modelUsed) ??
|
||||
sessionEntry?.contextTokens ??
|
||||
DEFAULT_CONTEXT_TOKENS;
|
||||
|
||||
if (usage) {
|
||||
const entry = sessionEntry ?? sessionStore[sessionKey];
|
||||
if (entry) {
|
||||
const input = usage.input ?? 0;
|
||||
const output = usage.output ?? 0;
|
||||
const promptTokens =
|
||||
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
||||
const nextEntry = {
|
||||
...entry,
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
totalTokens:
|
||||
promptTokens > 0 ? promptTokens : (usage.total ?? input),
|
||||
model: modelUsed,
|
||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
sessionStore[sessionKey] = nextEntry;
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
}
|
||||
} else if (modelUsed || contextTokensUsed) {
|
||||
const entry = sessionEntry ?? sessionStore[sessionKey];
|
||||
if (entry) {
|
||||
sessionStore[sessionKey] = {
|
||||
...entry,
|
||||
model: modelUsed ?? entry.model,
|
||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||
};
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If verbose is enabled and this is a new session, prepend a session hint.
|
||||
let finalPayloads = filteredPayloads;
|
||||
if (resolvedVerboseLevel === "on" && isNewSession) {
|
||||
finalPayloads = [
|
||||
{ text: `🧭 New session: ${followupRun.run.sessionId}` },
|
||||
...finalPayloads,
|
||||
];
|
||||
}
|
||||
|
||||
return finalizeWithFollowup(
|
||||
finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads,
|
||||
);
|
||||
} finally {
|
||||
typing.cleanup();
|
||||
}
|
||||
}
|
||||
51
src/auto-reply/reply/block-streaming.ts
Normal file
51
src/auto-reply/reply/block-streaming.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { ClawdisConfig } from "../../config/config.js";
|
||||
import { resolveTextChunkLimit, type TextChunkSurface } from "../chunk.js";
|
||||
|
||||
const DEFAULT_BLOCK_STREAM_MIN = 800;
|
||||
const DEFAULT_BLOCK_STREAM_MAX = 1200;
|
||||
|
||||
const BLOCK_CHUNK_SURFACES = new Set<TextChunkSurface>([
|
||||
"whatsapp",
|
||||
"telegram",
|
||||
"discord",
|
||||
"signal",
|
||||
"imessage",
|
||||
"webchat",
|
||||
]);
|
||||
|
||||
function normalizeChunkSurface(surface?: string): TextChunkSurface | undefined {
|
||||
if (!surface) return undefined;
|
||||
const cleaned = surface.trim().toLowerCase();
|
||||
return BLOCK_CHUNK_SURFACES.has(cleaned as TextChunkSurface)
|
||||
? (cleaned as TextChunkSurface)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function resolveBlockStreamingChunking(
|
||||
cfg: ClawdisConfig | undefined,
|
||||
surface?: string,
|
||||
): {
|
||||
minChars: number;
|
||||
maxChars: number;
|
||||
breakPreference: "paragraph" | "newline" | "sentence";
|
||||
} {
|
||||
const surfaceKey = normalizeChunkSurface(surface);
|
||||
const textLimit = resolveTextChunkLimit(cfg, surfaceKey);
|
||||
const chunkCfg = cfg?.agent?.blockStreamingChunk;
|
||||
const maxRequested = Math.max(
|
||||
1,
|
||||
Math.floor(chunkCfg?.maxChars ?? DEFAULT_BLOCK_STREAM_MAX),
|
||||
);
|
||||
const maxChars = Math.max(1, Math.min(maxRequested, textLimit));
|
||||
const minRequested = Math.max(
|
||||
1,
|
||||
Math.floor(chunkCfg?.minChars ?? DEFAULT_BLOCK_STREAM_MIN),
|
||||
);
|
||||
const minChars = Math.min(minRequested, maxChars);
|
||||
const breakPreference =
|
||||
chunkCfg?.breakPreference === "newline" ||
|
||||
chunkCfg?.breakPreference === "sentence"
|
||||
? chunkCfg.breakPreference
|
||||
: "paragraph";
|
||||
return { minChars, maxChars, breakPreference };
|
||||
}
|
||||
41
src/auto-reply/reply/body.ts
Normal file
41
src/auto-reply/reply/body.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import { saveSessionStore } from "../../config/sessions.js";
|
||||
import { setAbortMemory } from "./abort.js";
|
||||
|
||||
export async function applySessionHints(params: {
|
||||
baseBody: string;
|
||||
abortedLastRun: boolean;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
abortKey?: string;
|
||||
messageId?: string;
|
||||
}): Promise<string> {
|
||||
let prefixedBodyBase = params.baseBody;
|
||||
const abortedHint = params.abortedLastRun
|
||||
? "Note: The previous agent run was aborted by the user. Resume carefully or ask for clarification."
|
||||
: "";
|
||||
if (abortedHint) {
|
||||
prefixedBodyBase = `${abortedHint}\n\n${prefixedBodyBase}`;
|
||||
if (params.sessionEntry && params.sessionStore && params.sessionKey) {
|
||||
params.sessionEntry.abortedLastRun = false;
|
||||
params.sessionEntry.updatedAt = Date.now();
|
||||
params.sessionStore[params.sessionKey] = params.sessionEntry;
|
||||
if (params.storePath) {
|
||||
await saveSessionStore(params.storePath, params.sessionStore);
|
||||
}
|
||||
} else if (params.abortKey) {
|
||||
setAbortMemory(params.abortKey, false);
|
||||
}
|
||||
}
|
||||
|
||||
const messageIdHint = params.messageId?.trim()
|
||||
? `[message_id: ${params.messageId.trim()}]`
|
||||
: "";
|
||||
if (messageIdHint) {
|
||||
prefixedBodyBase = `${prefixedBodyBase}\n${messageIdHint}`;
|
||||
}
|
||||
|
||||
return prefixedBodyBase;
|
||||
}
|
||||
303
src/auto-reply/reply/commands.ts
Normal file
303
src/auto-reply/reply/commands.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import type { ClawdisConfig } from "../../config/config.js";
|
||||
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { triggerClawdisRestart } from "../../infra/restart.js";
|
||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||
import { normalizeE164 } from "../../utils.js";
|
||||
import { resolveHeartbeatSeconds } from "../../web/reconnect.js";
|
||||
import { getWebAuthAgeMs, webAuthExists } from "../../web/session.js";
|
||||
import {
|
||||
normalizeGroupActivation,
|
||||
parseActivationCommand,
|
||||
} from "../group-activation.js";
|
||||
import { parseSendPolicyCommand } from "../send-policy.js";
|
||||
import { buildStatusMessage } from "../status.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import type { ThinkLevel, VerboseLevel } from "../thinking.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import { isAbortTrigger, setAbortMemory } from "./abort.js";
|
||||
import { stripMentions } from "./mentions.js";
|
||||
|
||||
export type CommandContext = {
|
||||
surface: string;
|
||||
isWhatsAppSurface: boolean;
|
||||
ownerList: string[];
|
||||
isOwnerSender: boolean;
|
||||
senderE164?: string;
|
||||
abortKey?: string;
|
||||
rawBodyNormalized: string;
|
||||
commandBodyNormalized: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
export function buildCommandContext(params: {
|
||||
ctx: MsgContext;
|
||||
cfg: ClawdisConfig;
|
||||
sessionKey?: string;
|
||||
isGroup: boolean;
|
||||
triggerBodyNormalized: string;
|
||||
}): CommandContext {
|
||||
const { ctx, cfg, sessionKey, isGroup, triggerBodyNormalized } = params;
|
||||
const surface = (ctx.Surface ?? "").trim().toLowerCase();
|
||||
const isWhatsAppSurface =
|
||||
surface === "whatsapp" ||
|
||||
(ctx.From ?? "").startsWith("whatsapp:") ||
|
||||
(ctx.To ?? "").startsWith("whatsapp:");
|
||||
|
||||
const configuredAllowFrom = isWhatsAppSurface
|
||||
? cfg.whatsapp?.allowFrom
|
||||
: undefined;
|
||||
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
||||
const to = (ctx.To ?? "").replace(/^whatsapp:/, "");
|
||||
const defaultAllowFrom =
|
||||
isWhatsAppSurface &&
|
||||
(!configuredAllowFrom || configuredAllowFrom.length === 0) &&
|
||||
to
|
||||
? [to]
|
||||
: undefined;
|
||||
const allowFrom =
|
||||
configuredAllowFrom && configuredAllowFrom.length > 0
|
||||
? configuredAllowFrom
|
||||
: defaultAllowFrom;
|
||||
|
||||
const abortKey = sessionKey ?? (from || undefined) ?? (to || undefined);
|
||||
const rawBodyNormalized = triggerBodyNormalized;
|
||||
const commandBodyNormalized = isGroup
|
||||
? stripMentions(rawBodyNormalized, ctx, cfg)
|
||||
: rawBodyNormalized;
|
||||
const senderE164 = normalizeE164(ctx.SenderE164 ?? "");
|
||||
const ownerCandidates = isWhatsAppSurface
|
||||
? (allowFrom ?? []).filter((entry) => entry && entry !== "*")
|
||||
: [];
|
||||
if (isWhatsAppSurface && ownerCandidates.length === 0 && to) {
|
||||
ownerCandidates.push(to);
|
||||
}
|
||||
const ownerList = ownerCandidates
|
||||
.map((entry) => normalizeE164(entry))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
const isOwnerSender =
|
||||
Boolean(senderE164) && ownerList.includes(senderE164 ?? "");
|
||||
|
||||
return {
|
||||
surface,
|
||||
isWhatsAppSurface,
|
||||
ownerList,
|
||||
isOwnerSender,
|
||||
senderE164: senderE164 || undefined,
|
||||
abortKey,
|
||||
rawBodyNormalized,
|
||||
commandBodyNormalized,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleCommands(params: {
|
||||
ctx: MsgContext;
|
||||
cfg: ClawdisConfig;
|
||||
command: CommandContext;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
sessionScope: string;
|
||||
workspaceDir: string;
|
||||
defaultGroupActivation: () => "always" | "mention";
|
||||
resolvedThinkLevel?: ThinkLevel;
|
||||
resolvedVerboseLevel: VerboseLevel;
|
||||
resolveDefaultThinkingLevel: () => Promise<ThinkLevel | undefined>;
|
||||
provider: string;
|
||||
model: string;
|
||||
contextTokens: number;
|
||||
isGroup: boolean;
|
||||
}): Promise<{
|
||||
reply?: ReplyPayload;
|
||||
shouldContinue: boolean;
|
||||
}> {
|
||||
const {
|
||||
cfg,
|
||||
command,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionScope,
|
||||
workspaceDir,
|
||||
defaultGroupActivation,
|
||||
resolvedThinkLevel,
|
||||
resolvedVerboseLevel,
|
||||
resolveDefaultThinkingLevel,
|
||||
model,
|
||||
contextTokens,
|
||||
isGroup,
|
||||
} = params;
|
||||
|
||||
const activationCommand = parseActivationCommand(
|
||||
command.commandBodyNormalized,
|
||||
);
|
||||
const sendPolicyCommand = parseSendPolicyCommand(
|
||||
command.commandBodyNormalized,
|
||||
);
|
||||
|
||||
if (activationCommand.hasCommand) {
|
||||
if (!isGroup) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ Group activation only applies to group chats." },
|
||||
};
|
||||
}
|
||||
if (!command.isOwnerSender) {
|
||||
logVerbose(
|
||||
`Ignoring /activation from non-owner in group: ${command.senderE164 || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
if (!activationCommand.mode) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ Usage: /activation mention|always" },
|
||||
};
|
||||
}
|
||||
if (sessionEntry && sessionStore && sessionKey) {
|
||||
sessionEntry.groupActivation = activationCommand.mode;
|
||||
sessionEntry.groupActivationNeedsSystemIntro = true;
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚙️ Group activation set to ${activationCommand.mode}.` },
|
||||
};
|
||||
}
|
||||
|
||||
if (sendPolicyCommand.hasCommand) {
|
||||
if (!command.isOwnerSender) {
|
||||
logVerbose(
|
||||
`Ignoring /send from non-owner: ${command.senderE164 || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
if (!sendPolicyCommand.mode) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ Usage: /send on|off|inherit" },
|
||||
};
|
||||
}
|
||||
if (sessionEntry && sessionStore && sessionKey) {
|
||||
if (sendPolicyCommand.mode === "inherit") {
|
||||
delete sessionEntry.sendPolicy;
|
||||
} else {
|
||||
sessionEntry.sendPolicy = sendPolicyCommand.mode;
|
||||
}
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
}
|
||||
const label =
|
||||
sendPolicyCommand.mode === "inherit"
|
||||
? "inherit"
|
||||
: sendPolicyCommand.mode === "allow"
|
||||
? "on"
|
||||
: "off";
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚙️ Send policy set to ${label}.` },
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
command.commandBodyNormalized === "/restart" ||
|
||||
command.commandBodyNormalized === "restart" ||
|
||||
command.commandBodyNormalized.startsWith("/restart ")
|
||||
) {
|
||||
if (isGroup && !command.isOwnerSender) {
|
||||
logVerbose(
|
||||
`Ignoring /restart from non-owner in group: ${command.senderE164 || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
const restartMethod = triggerClawdisRestart();
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚙️ Restarting clawdis via ${restartMethod}; give me a few seconds to come back online.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
command.commandBodyNormalized === "/status" ||
|
||||
command.commandBodyNormalized === "status" ||
|
||||
command.commandBodyNormalized.startsWith("/status ")
|
||||
) {
|
||||
if (isGroup && !command.isOwnerSender) {
|
||||
logVerbose(
|
||||
`Ignoring /status from non-owner in group: ${command.senderE164 || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
const webLinked = await webAuthExists();
|
||||
const webAuthAgeMs = getWebAuthAgeMs();
|
||||
const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined);
|
||||
const groupActivation = isGroup
|
||||
? (normalizeGroupActivation(sessionEntry?.groupActivation) ??
|
||||
defaultGroupActivation())
|
||||
: undefined;
|
||||
const statusText = buildStatusMessage({
|
||||
agent: {
|
||||
model,
|
||||
contextTokens,
|
||||
thinkingDefault: cfg.agent?.thinkingDefault,
|
||||
verboseDefault: cfg.agent?.verboseDefault,
|
||||
},
|
||||
workspaceDir,
|
||||
sessionEntry,
|
||||
sessionKey,
|
||||
sessionScope,
|
||||
storePath,
|
||||
groupActivation,
|
||||
resolvedThink:
|
||||
resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()),
|
||||
resolvedVerbose: resolvedVerboseLevel,
|
||||
webLinked,
|
||||
webAuthAgeMs,
|
||||
heartbeatSeconds,
|
||||
});
|
||||
return { shouldContinue: false, reply: { text: statusText } };
|
||||
}
|
||||
|
||||
const abortRequested = isAbortTrigger(command.rawBodyNormalized);
|
||||
if (abortRequested) {
|
||||
if (sessionEntry && sessionStore && sessionKey) {
|
||||
sessionEntry.abortedLastRun = true;
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
} else if (command.abortKey) {
|
||||
setAbortMemory(command.abortKey, true);
|
||||
}
|
||||
return { shouldContinue: false, reply: { text: "⚙️ Agent was aborted." } };
|
||||
}
|
||||
|
||||
const sendPolicy = resolveSendPolicy({
|
||||
cfg,
|
||||
entry: sessionEntry,
|
||||
sessionKey,
|
||||
surface: sessionEntry?.surface ?? command.surface,
|
||||
chatType: sessionEntry?.chatType,
|
||||
});
|
||||
if (sendPolicy === "deny") {
|
||||
logVerbose(`Send blocked by policy for session ${sessionKey ?? "unknown"}`);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
|
||||
return { shouldContinue: true };
|
||||
}
|
||||
513
src/auto-reply/reply/directive-handling.ts
Normal file
513
src/auto-reply/reply/directive-handling.ts
Normal file
@@ -0,0 +1,513 @@
|
||||
import { lookupContextTokens } from "../../agents/context.js";
|
||||
import {
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
DEFAULT_MODEL,
|
||||
DEFAULT_PROVIDER,
|
||||
} from "../../agents/defaults.js";
|
||||
import {
|
||||
buildModelAliasIndex,
|
||||
type ModelAliasIndex,
|
||||
modelKey,
|
||||
resolveConfiguredModelRef,
|
||||
resolveModelRefFromString,
|
||||
} from "../../agents/model-selection.js";
|
||||
import type { ClawdisConfig } from "../../config/config.js";
|
||||
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
|
||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||
import { extractModelDirective } from "../model.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import {
|
||||
extractThinkDirective,
|
||||
extractVerboseDirective,
|
||||
type ThinkLevel,
|
||||
type VerboseLevel,
|
||||
} from "./directives.js";
|
||||
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
||||
import {
|
||||
type ModelDirectiveSelection,
|
||||
resolveModelDirectiveSelection,
|
||||
} from "./model-selection.js";
|
||||
import {
|
||||
extractQueueDirective,
|
||||
type QueueDropPolicy,
|
||||
type QueueMode,
|
||||
} from "./queue.js";
|
||||
|
||||
const SYSTEM_MARK = "⚙️";
|
||||
|
||||
export type InlineDirectives = {
|
||||
cleaned: string;
|
||||
hasThinkDirective: boolean;
|
||||
thinkLevel?: ThinkLevel;
|
||||
rawThinkLevel?: string;
|
||||
hasVerboseDirective: boolean;
|
||||
verboseLevel?: VerboseLevel;
|
||||
rawVerboseLevel?: string;
|
||||
hasModelDirective: boolean;
|
||||
rawModelDirective?: string;
|
||||
hasQueueDirective: boolean;
|
||||
queueMode?: QueueMode;
|
||||
queueReset: boolean;
|
||||
rawQueueMode?: string;
|
||||
debounceMs?: number;
|
||||
cap?: number;
|
||||
dropPolicy?: QueueDropPolicy;
|
||||
rawDebounce?: string;
|
||||
rawCap?: string;
|
||||
rawDrop?: string;
|
||||
hasQueueOptions: boolean;
|
||||
};
|
||||
|
||||
export function parseInlineDirectives(body: string): InlineDirectives {
|
||||
const {
|
||||
cleaned: thinkCleaned,
|
||||
thinkLevel,
|
||||
rawLevel: rawThinkLevel,
|
||||
hasDirective: hasThinkDirective,
|
||||
} = extractThinkDirective(body);
|
||||
const {
|
||||
cleaned: verboseCleaned,
|
||||
verboseLevel,
|
||||
rawLevel: rawVerboseLevel,
|
||||
hasDirective: hasVerboseDirective,
|
||||
} = extractVerboseDirective(thinkCleaned);
|
||||
const {
|
||||
cleaned: modelCleaned,
|
||||
rawModel,
|
||||
hasDirective: hasModelDirective,
|
||||
} = extractModelDirective(verboseCleaned);
|
||||
const {
|
||||
cleaned: queueCleaned,
|
||||
queueMode,
|
||||
queueReset,
|
||||
rawMode,
|
||||
debounceMs,
|
||||
cap,
|
||||
dropPolicy,
|
||||
rawDebounce,
|
||||
rawCap,
|
||||
rawDrop,
|
||||
hasDirective: hasQueueDirective,
|
||||
hasOptions: hasQueueOptions,
|
||||
} = extractQueueDirective(modelCleaned);
|
||||
|
||||
return {
|
||||
cleaned: queueCleaned,
|
||||
hasThinkDirective,
|
||||
thinkLevel,
|
||||
rawThinkLevel,
|
||||
hasVerboseDirective,
|
||||
verboseLevel,
|
||||
rawVerboseLevel,
|
||||
hasModelDirective,
|
||||
rawModelDirective: rawModel,
|
||||
hasQueueDirective,
|
||||
queueMode,
|
||||
queueReset,
|
||||
rawQueueMode: rawMode,
|
||||
debounceMs,
|
||||
cap,
|
||||
dropPolicy,
|
||||
rawDebounce,
|
||||
rawCap,
|
||||
rawDrop,
|
||||
hasQueueOptions,
|
||||
};
|
||||
}
|
||||
|
||||
export function isDirectiveOnly(params: {
|
||||
directives: InlineDirectives;
|
||||
cleanedBody: string;
|
||||
ctx: MsgContext;
|
||||
cfg: ClawdisConfig;
|
||||
isGroup: boolean;
|
||||
}): boolean {
|
||||
const { directives, cleanedBody, ctx, cfg, isGroup } = params;
|
||||
if (
|
||||
!directives.hasThinkDirective &&
|
||||
!directives.hasVerboseDirective &&
|
||||
!directives.hasModelDirective &&
|
||||
!directives.hasQueueDirective
|
||||
)
|
||||
return false;
|
||||
const stripped = stripStructuralPrefixes(cleanedBody ?? "");
|
||||
const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped;
|
||||
return noMentions.length === 0;
|
||||
}
|
||||
|
||||
export async function handleDirectiveOnly(params: {
|
||||
directives: InlineDirectives;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
defaultProvider: string;
|
||||
defaultModel: string;
|
||||
aliasIndex: ModelAliasIndex;
|
||||
allowedModelKeys: Set<string>;
|
||||
allowedModelCatalog: Awaited<
|
||||
ReturnType<typeof import("../../agents/model-catalog.js").loadModelCatalog>
|
||||
>;
|
||||
resetModelOverride: boolean;
|
||||
provider: string;
|
||||
model: string;
|
||||
initialModelLabel: string;
|
||||
formatModelSwitchEvent: (label: string, alias?: string) => string;
|
||||
}): Promise<ReplyPayload | undefined> {
|
||||
const {
|
||||
directives,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
aliasIndex,
|
||||
allowedModelKeys,
|
||||
allowedModelCatalog,
|
||||
resetModelOverride,
|
||||
initialModelLabel,
|
||||
formatModelSwitchEvent,
|
||||
} = params;
|
||||
|
||||
if (directives.hasModelDirective) {
|
||||
const isModelListAlias =
|
||||
directives.rawModelDirective?.trim().toLowerCase() === "status";
|
||||
if (!directives.rawModelDirective || isModelListAlias) {
|
||||
if (allowedModelCatalog.length === 0) {
|
||||
return { text: "No models available." };
|
||||
}
|
||||
const current = `${params.provider}/${params.model}`;
|
||||
const defaultLabel = `${defaultProvider}/${defaultModel}`;
|
||||
const header =
|
||||
current === defaultLabel
|
||||
? `Models (current: ${current}):`
|
||||
: `Models (current: ${current}, default: ${defaultLabel}):`;
|
||||
const lines = [header];
|
||||
if (resetModelOverride) {
|
||||
lines.push(`(previous selection reset to default)`);
|
||||
}
|
||||
for (const entry of allowedModelCatalog) {
|
||||
const label = `${entry.provider}/${entry.id}`;
|
||||
const aliases = aliasIndex.byKey.get(label);
|
||||
const aliasSuffix =
|
||||
aliases && aliases.length > 0
|
||||
? ` (alias: ${aliases.join(", ")})`
|
||||
: "";
|
||||
const suffix =
|
||||
entry.name && entry.name !== entry.id ? ` — ${entry.name}` : "";
|
||||
lines.push(`- ${label}${aliasSuffix}${suffix}`);
|
||||
}
|
||||
return { text: lines.join("\n") };
|
||||
}
|
||||
}
|
||||
|
||||
if (directives.hasThinkDirective && !directives.thinkLevel) {
|
||||
return {
|
||||
text: `Unrecognized thinking level "${directives.rawThinkLevel ?? ""}". Valid levels: off, minimal, low, medium, high.`,
|
||||
};
|
||||
}
|
||||
if (directives.hasVerboseDirective && !directives.verboseLevel) {
|
||||
return {
|
||||
text: `Unrecognized verbose level "${directives.rawVerboseLevel ?? ""}". Valid levels: off, on.`,
|
||||
};
|
||||
}
|
||||
|
||||
const queueModeInvalid =
|
||||
directives.hasQueueDirective &&
|
||||
!directives.queueMode &&
|
||||
!directives.queueReset &&
|
||||
Boolean(directives.rawQueueMode);
|
||||
const queueDebounceInvalid =
|
||||
directives.hasQueueDirective &&
|
||||
directives.rawDebounce !== undefined &&
|
||||
typeof directives.debounceMs !== "number";
|
||||
const queueCapInvalid =
|
||||
directives.hasQueueDirective &&
|
||||
directives.rawCap !== undefined &&
|
||||
typeof directives.cap !== "number";
|
||||
const queueDropInvalid =
|
||||
directives.hasQueueDirective &&
|
||||
directives.rawDrop !== undefined &&
|
||||
!directives.dropPolicy;
|
||||
if (
|
||||
queueModeInvalid ||
|
||||
queueDebounceInvalid ||
|
||||
queueCapInvalid ||
|
||||
queueDropInvalid
|
||||
) {
|
||||
const errors: string[] = [];
|
||||
if (queueModeInvalid) {
|
||||
errors.push(
|
||||
`Unrecognized queue mode "${directives.rawQueueMode ?? ""}". Valid modes: steer, followup, collect, steer+backlog, interrupt.`,
|
||||
);
|
||||
}
|
||||
if (queueDebounceInvalid) {
|
||||
errors.push(
|
||||
`Invalid debounce "${directives.rawDebounce ?? ""}". Use ms/s/m (e.g. debounce:1500ms, debounce:2s).`,
|
||||
);
|
||||
}
|
||||
if (queueCapInvalid) {
|
||||
errors.push(
|
||||
`Invalid cap "${directives.rawCap ?? ""}". Use a positive integer (e.g. cap:10).`,
|
||||
);
|
||||
}
|
||||
if (queueDropInvalid) {
|
||||
errors.push(
|
||||
`Invalid drop policy "${directives.rawDrop ?? ""}". Use drop:old, drop:new, or drop:summarize.`,
|
||||
);
|
||||
}
|
||||
return { text: errors.join(" ") };
|
||||
}
|
||||
|
||||
let modelSelection: ModelDirectiveSelection | undefined;
|
||||
if (directives.hasModelDirective && directives.rawModelDirective) {
|
||||
const resolved = resolveModelDirectiveSelection({
|
||||
raw: directives.rawModelDirective,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
aliasIndex,
|
||||
allowedModelKeys,
|
||||
});
|
||||
if (resolved.error) {
|
||||
return { text: resolved.error };
|
||||
}
|
||||
modelSelection = resolved.selection;
|
||||
if (modelSelection) {
|
||||
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
|
||||
if (nextLabel !== initialModelLabel) {
|
||||
enqueueSystemEvent(
|
||||
formatModelSwitchEvent(nextLabel, modelSelection.alias),
|
||||
{
|
||||
contextKey: `model:${nextLabel}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionEntry && sessionStore && sessionKey) {
|
||||
if (directives.hasThinkDirective && directives.thinkLevel) {
|
||||
if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel;
|
||||
else sessionEntry.thinkingLevel = directives.thinkLevel;
|
||||
}
|
||||
if (directives.hasVerboseDirective && directives.verboseLevel) {
|
||||
if (directives.verboseLevel === "off") delete sessionEntry.verboseLevel;
|
||||
else sessionEntry.verboseLevel = directives.verboseLevel;
|
||||
}
|
||||
if (modelSelection) {
|
||||
if (modelSelection.isDefault) {
|
||||
delete sessionEntry.providerOverride;
|
||||
delete sessionEntry.modelOverride;
|
||||
} else {
|
||||
sessionEntry.providerOverride = modelSelection.provider;
|
||||
sessionEntry.modelOverride = modelSelection.model;
|
||||
}
|
||||
}
|
||||
if (directives.hasQueueDirective && directives.queueReset) {
|
||||
delete sessionEntry.queueMode;
|
||||
delete sessionEntry.queueDebounceMs;
|
||||
delete sessionEntry.queueCap;
|
||||
delete sessionEntry.queueDrop;
|
||||
} else if (directives.hasQueueDirective) {
|
||||
if (directives.queueMode) sessionEntry.queueMode = directives.queueMode;
|
||||
if (typeof directives.debounceMs === "number") {
|
||||
sessionEntry.queueDebounceMs = directives.debounceMs;
|
||||
}
|
||||
if (typeof directives.cap === "number") {
|
||||
sessionEntry.queueCap = directives.cap;
|
||||
}
|
||||
if (directives.dropPolicy) {
|
||||
sessionEntry.queueDrop = directives.dropPolicy;
|
||||
}
|
||||
}
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
if (directives.hasThinkDirective && directives.thinkLevel) {
|
||||
parts.push(
|
||||
directives.thinkLevel === "off"
|
||||
? "Thinking disabled."
|
||||
: `Thinking level set to ${directives.thinkLevel}.`,
|
||||
);
|
||||
}
|
||||
if (directives.hasVerboseDirective && directives.verboseLevel) {
|
||||
parts.push(
|
||||
directives.verboseLevel === "off"
|
||||
? `${SYSTEM_MARK} Verbose logging disabled.`
|
||||
: `${SYSTEM_MARK} Verbose logging enabled.`,
|
||||
);
|
||||
}
|
||||
if (modelSelection) {
|
||||
const label = `${modelSelection.provider}/${modelSelection.model}`;
|
||||
const labelWithAlias = modelSelection.alias
|
||||
? `${modelSelection.alias} (${label})`
|
||||
: label;
|
||||
parts.push(
|
||||
modelSelection.isDefault
|
||||
? `Model reset to default (${labelWithAlias}).`
|
||||
: `Model set to ${labelWithAlias}.`,
|
||||
);
|
||||
}
|
||||
if (directives.hasQueueDirective && directives.queueMode) {
|
||||
parts.push(`${SYSTEM_MARK} Queue mode set to ${directives.queueMode}.`);
|
||||
} else if (directives.hasQueueDirective && directives.queueReset) {
|
||||
parts.push(`${SYSTEM_MARK} Queue mode reset to default.`);
|
||||
}
|
||||
if (
|
||||
directives.hasQueueDirective &&
|
||||
typeof directives.debounceMs === "number"
|
||||
) {
|
||||
parts.push(
|
||||
`${SYSTEM_MARK} Queue debounce set to ${directives.debounceMs}ms.`,
|
||||
);
|
||||
}
|
||||
if (directives.hasQueueDirective && typeof directives.cap === "number") {
|
||||
parts.push(`${SYSTEM_MARK} Queue cap set to ${directives.cap}.`);
|
||||
}
|
||||
if (directives.hasQueueDirective && directives.dropPolicy) {
|
||||
parts.push(`${SYSTEM_MARK} Queue drop set to ${directives.dropPolicy}.`);
|
||||
}
|
||||
const ack = parts.join(" ").trim();
|
||||
return { text: ack || "OK." };
|
||||
}
|
||||
|
||||
export async function persistInlineDirectives(params: {
|
||||
directives: InlineDirectives;
|
||||
effectiveModelDirective?: string;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
defaultProvider: string;
|
||||
defaultModel: string;
|
||||
aliasIndex: ModelAliasIndex;
|
||||
allowedModelKeys: Set<string>;
|
||||
provider: string;
|
||||
model: string;
|
||||
initialModelLabel: string;
|
||||
formatModelSwitchEvent: (label: string, alias?: string) => string;
|
||||
agentCfg: ClawdisConfig["agent"] | undefined;
|
||||
}): Promise<{ provider: string; model: string; contextTokens: number }> {
|
||||
const {
|
||||
directives,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
aliasIndex,
|
||||
allowedModelKeys,
|
||||
initialModelLabel,
|
||||
formatModelSwitchEvent,
|
||||
agentCfg,
|
||||
} = params;
|
||||
let { provider, model } = params;
|
||||
|
||||
if (sessionEntry && sessionStore && sessionKey) {
|
||||
let updated = false;
|
||||
if (directives.hasThinkDirective && directives.thinkLevel) {
|
||||
if (directives.thinkLevel === "off") {
|
||||
delete sessionEntry.thinkingLevel;
|
||||
} else {
|
||||
sessionEntry.thinkingLevel = directives.thinkLevel;
|
||||
}
|
||||
updated = true;
|
||||
}
|
||||
if (directives.hasVerboseDirective && directives.verboseLevel) {
|
||||
if (directives.verboseLevel === "off") {
|
||||
delete sessionEntry.verboseLevel;
|
||||
} else {
|
||||
sessionEntry.verboseLevel = directives.verboseLevel;
|
||||
}
|
||||
updated = true;
|
||||
}
|
||||
const modelDirective =
|
||||
directives.hasModelDirective && params.effectiveModelDirective
|
||||
? params.effectiveModelDirective
|
||||
: undefined;
|
||||
if (modelDirective) {
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: modelDirective,
|
||||
defaultProvider,
|
||||
aliasIndex,
|
||||
});
|
||||
if (resolved) {
|
||||
const key = modelKey(resolved.ref.provider, resolved.ref.model);
|
||||
if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) {
|
||||
const isDefault =
|
||||
resolved.ref.provider === defaultProvider &&
|
||||
resolved.ref.model === defaultModel;
|
||||
if (isDefault) {
|
||||
delete sessionEntry.providerOverride;
|
||||
delete sessionEntry.modelOverride;
|
||||
} else {
|
||||
sessionEntry.providerOverride = resolved.ref.provider;
|
||||
sessionEntry.modelOverride = resolved.ref.model;
|
||||
}
|
||||
provider = resolved.ref.provider;
|
||||
model = resolved.ref.model;
|
||||
const nextLabel = `${provider}/${model}`;
|
||||
if (nextLabel !== initialModelLabel) {
|
||||
enqueueSystemEvent(
|
||||
formatModelSwitchEvent(nextLabel, resolved.alias),
|
||||
{
|
||||
contextKey: `model:${nextLabel}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (directives.hasQueueDirective && directives.queueReset) {
|
||||
delete sessionEntry.queueMode;
|
||||
delete sessionEntry.queueDebounceMs;
|
||||
delete sessionEntry.queueCap;
|
||||
delete sessionEntry.queueDrop;
|
||||
updated = true;
|
||||
}
|
||||
if (updated) {
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
model,
|
||||
contextTokens:
|
||||
agentCfg?.contextTokens ??
|
||||
lookupContextTokens(model) ??
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveDefaultModel(params: { cfg: ClawdisConfig }): {
|
||||
defaultProvider: string;
|
||||
defaultModel: string;
|
||||
aliasIndex: ModelAliasIndex;
|
||||
} {
|
||||
const mainModel = resolveConfiguredModelRef({
|
||||
cfg: params.cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
const defaultProvider = mainModel.provider;
|
||||
const defaultModel = mainModel.model;
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg: params.cfg,
|
||||
defaultProvider,
|
||||
});
|
||||
return { defaultProvider, defaultModel, aliasIndex };
|
||||
}
|
||||
53
src/auto-reply/reply/directives.ts
Normal file
53
src/auto-reply/reply/directives.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
normalizeThinkLevel,
|
||||
normalizeVerboseLevel,
|
||||
type ThinkLevel,
|
||||
type VerboseLevel,
|
||||
} from "../thinking.js";
|
||||
|
||||
export function extractThinkDirective(body?: string): {
|
||||
cleaned: string;
|
||||
thinkLevel?: ThinkLevel;
|
||||
rawLevel?: string;
|
||||
hasDirective: boolean;
|
||||
} {
|
||||
if (!body) return { cleaned: "", hasDirective: false };
|
||||
// Match the longest keyword first to avoid partial captures (e.g. "/think:high")
|
||||
const match = body.match(
|
||||
/(?:^|\s)\/(?:thinking|think|t)\s*:?\s*([a-zA-Z-]+)\b/i,
|
||||
);
|
||||
const thinkLevel = normalizeThinkLevel(match?.[1]);
|
||||
const cleaned = match
|
||||
? body.replace(match[0], "").replace(/\s+/g, " ").trim()
|
||||
: body.trim();
|
||||
return {
|
||||
cleaned,
|
||||
thinkLevel,
|
||||
rawLevel: match?.[1],
|
||||
hasDirective: !!match,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractVerboseDirective(body?: string): {
|
||||
cleaned: string;
|
||||
verboseLevel?: VerboseLevel;
|
||||
rawLevel?: string;
|
||||
hasDirective: boolean;
|
||||
} {
|
||||
if (!body) return { cleaned: "", hasDirective: false };
|
||||
const match = body.match(
|
||||
/(?:^|\s)\/(?:verbose|v)(?=$|\s|:)\s*:?\s*([a-zA-Z-]+)\b/i,
|
||||
);
|
||||
const verboseLevel = normalizeVerboseLevel(match?.[1]);
|
||||
const cleaned = match
|
||||
? body.replace(match[0], "").replace(/\s+/g, " ").trim()
|
||||
: body.trim();
|
||||
return {
|
||||
cleaned,
|
||||
verboseLevel,
|
||||
rawLevel: match?.[1],
|
||||
hasDirective: !!match,
|
||||
};
|
||||
}
|
||||
|
||||
export type { ThinkLevel, VerboseLevel };
|
||||
168
src/auto-reply/reply/followup-runner.ts
Normal file
168
src/auto-reply/reply/followup-runner.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import crypto from "node:crypto";
|
||||
import { lookupContextTokens } from "../../agents/context.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
|
||||
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
|
||||
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { stripHeartbeatToken } from "../heartbeat.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import type { FollowupRun } from "./queue.js";
|
||||
import { extractReplyToTag } from "./reply-tags.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
|
||||
export function createFollowupRunner(params: {
|
||||
opts?: GetReplyOptions;
|
||||
typing: TypingController;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
defaultModel: string;
|
||||
agentCfgContextTokens?: number;
|
||||
}): (queued: FollowupRun) => Promise<void> {
|
||||
const {
|
||||
opts,
|
||||
typing,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
defaultModel,
|
||||
agentCfgContextTokens,
|
||||
} = params;
|
||||
|
||||
const sendFollowupPayloads = async (payloads: ReplyPayload[]) => {
|
||||
if (!opts?.onBlockReply) {
|
||||
logVerbose("followup queue: no onBlockReply handler; dropping payloads");
|
||||
return;
|
||||
}
|
||||
for (const payload of payloads) {
|
||||
if (!payload?.text && !payload?.mediaUrl && !payload?.mediaUrls?.length) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
payload.text?.trim() === SILENT_REPLY_TOKEN &&
|
||||
!payload.mediaUrl &&
|
||||
!payload.mediaUrls?.length
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
await typing.startTypingOnText(payload.text);
|
||||
await opts.onBlockReply(payload);
|
||||
}
|
||||
};
|
||||
|
||||
return async (queued: FollowupRun) => {
|
||||
const runId = crypto.randomUUID();
|
||||
if (queued.run.sessionKey) {
|
||||
registerAgentRunContext(runId, { sessionKey: queued.run.sessionKey });
|
||||
}
|
||||
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
||||
try {
|
||||
runResult = await runEmbeddedPiAgent({
|
||||
sessionId: queued.run.sessionId,
|
||||
sessionKey: queued.run.sessionKey,
|
||||
surface: queued.run.surface,
|
||||
sessionFile: queued.run.sessionFile,
|
||||
workspaceDir: queued.run.workspaceDir,
|
||||
config: queued.run.config,
|
||||
skillsSnapshot: queued.run.skillsSnapshot,
|
||||
prompt: queued.prompt,
|
||||
extraSystemPrompt: queued.run.extraSystemPrompt,
|
||||
ownerNumbers: queued.run.ownerNumbers,
|
||||
enforceFinalTag: queued.run.enforceFinalTag,
|
||||
provider: queued.run.provider,
|
||||
model: queued.run.model,
|
||||
thinkLevel: queued.run.thinkLevel,
|
||||
verboseLevel: queued.run.verboseLevel,
|
||||
timeoutMs: queued.run.timeoutMs,
|
||||
runId,
|
||||
blockReplyBreak: queued.run.blockReplyBreak,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
defaultRuntime.error?.(`Followup agent failed before reply: ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadArray = runResult.payloads ?? [];
|
||||
if (payloadArray.length === 0) return;
|
||||
const sanitizedPayloads = payloadArray.flatMap((payload) => {
|
||||
const text = payload.text;
|
||||
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 [];
|
||||
return [{ ...payload, text: stripped.text }];
|
||||
});
|
||||
|
||||
const replyTaggedPayloads: ReplyPayload[] = sanitizedPayloads
|
||||
.map((payload) => {
|
||||
const { cleaned, replyToId } = extractReplyToTag(payload.text);
|
||||
return {
|
||||
...payload,
|
||||
text: cleaned ? cleaned : undefined,
|
||||
replyToId: replyToId ?? payload.replyToId,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(payload) =>
|
||||
payload.text ||
|
||||
payload.mediaUrl ||
|
||||
(payload.mediaUrls && payload.mediaUrls.length > 0),
|
||||
);
|
||||
|
||||
if (replyTaggedPayloads.length === 0) return;
|
||||
|
||||
if (sessionStore && sessionKey) {
|
||||
const usage = runResult.meta.agentMeta?.usage;
|
||||
const modelUsed = runResult.meta.agentMeta?.model ?? defaultModel;
|
||||
const contextTokensUsed =
|
||||
agentCfgContextTokens ??
|
||||
lookupContextTokens(modelUsed) ??
|
||||
sessionEntry?.contextTokens ??
|
||||
DEFAULT_CONTEXT_TOKENS;
|
||||
|
||||
if (usage) {
|
||||
const entry = sessionStore[sessionKey];
|
||||
if (entry) {
|
||||
const input = usage.input ?? 0;
|
||||
const output = usage.output ?? 0;
|
||||
const promptTokens =
|
||||
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
||||
sessionStore[sessionKey] = {
|
||||
...entry,
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
totalTokens:
|
||||
promptTokens > 0 ? promptTokens : (usage.total ?? input),
|
||||
model: modelUsed,
|
||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
}
|
||||
} else if (modelUsed || contextTokensUsed) {
|
||||
const entry = sessionStore[sessionKey];
|
||||
if (entry) {
|
||||
sessionStore[sessionKey] = {
|
||||
...entry,
|
||||
model: modelUsed ?? entry.model,
|
||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||
};
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await sendFollowupPayloads(replyTaggedPayloads);
|
||||
};
|
||||
}
|
||||
108
src/auto-reply/reply/groups.ts
Normal file
108
src/auto-reply/reply/groups.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { ClawdisConfig } from "../../config/config.js";
|
||||
import type {
|
||||
GroupKeyResolution,
|
||||
SessionEntry,
|
||||
} from "../../config/sessions.js";
|
||||
import { normalizeGroupActivation } from "../group-activation.js";
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
|
||||
export function resolveGroupRequireMention(params: {
|
||||
cfg: ClawdisConfig;
|
||||
ctx: TemplateContext;
|
||||
groupResolution?: GroupKeyResolution;
|
||||
}): boolean {
|
||||
const { cfg, ctx, groupResolution } = params;
|
||||
const surface = groupResolution?.surface ?? ctx.Surface?.trim().toLowerCase();
|
||||
const groupId = groupResolution?.id ?? ctx.From?.replace(/^group:/, "");
|
||||
if (surface === "telegram") {
|
||||
if (groupId) {
|
||||
const groupConfig = cfg.telegram?.groups?.[groupId];
|
||||
if (typeof groupConfig?.requireMention === "boolean") {
|
||||
return groupConfig.requireMention;
|
||||
}
|
||||
}
|
||||
const groupDefault = cfg.telegram?.groups?.["*"]?.requireMention;
|
||||
if (typeof groupDefault === "boolean") return groupDefault;
|
||||
return true;
|
||||
}
|
||||
if (surface === "whatsapp") {
|
||||
if (groupId) {
|
||||
const groupConfig = cfg.whatsapp?.groups?.[groupId];
|
||||
if (typeof groupConfig?.requireMention === "boolean") {
|
||||
return groupConfig.requireMention;
|
||||
}
|
||||
}
|
||||
const groupDefault = cfg.whatsapp?.groups?.["*"]?.requireMention;
|
||||
if (typeof groupDefault === "boolean") return groupDefault;
|
||||
return true;
|
||||
}
|
||||
if (surface === "imessage") {
|
||||
if (groupId) {
|
||||
const groupConfig = cfg.imessage?.groups?.[groupId];
|
||||
if (typeof groupConfig?.requireMention === "boolean") {
|
||||
return groupConfig.requireMention;
|
||||
}
|
||||
}
|
||||
const groupDefault = cfg.imessage?.groups?.["*"]?.requireMention;
|
||||
if (typeof groupDefault === "boolean") return groupDefault;
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function defaultGroupActivation(
|
||||
requireMention: boolean,
|
||||
): "always" | "mention" {
|
||||
return requireMention === false ? "always" : "mention";
|
||||
}
|
||||
|
||||
export function buildGroupIntro(params: {
|
||||
sessionCtx: TemplateContext;
|
||||
sessionEntry?: SessionEntry;
|
||||
defaultActivation: "always" | "mention";
|
||||
silentToken: string;
|
||||
}): string {
|
||||
const activation =
|
||||
normalizeGroupActivation(params.sessionEntry?.groupActivation) ??
|
||||
params.defaultActivation;
|
||||
const subject = params.sessionCtx.GroupSubject?.trim();
|
||||
const members = params.sessionCtx.GroupMembers?.trim();
|
||||
const surface = params.sessionCtx.Surface?.trim().toLowerCase();
|
||||
const surfaceLabel = (() => {
|
||||
if (!surface) return "chat";
|
||||
if (surface === "whatsapp") return "WhatsApp";
|
||||
if (surface === "telegram") return "Telegram";
|
||||
if (surface === "discord") return "Discord";
|
||||
if (surface === "webchat") return "WebChat";
|
||||
return `${surface.at(0)?.toUpperCase() ?? ""}${surface.slice(1)}`;
|
||||
})();
|
||||
const subjectLine = subject
|
||||
? `You are replying inside the ${surfaceLabel} group "${subject}".`
|
||||
: `You are replying inside a ${surfaceLabel} group chat.`;
|
||||
const membersLine = members ? `Group members: ${members}.` : undefined;
|
||||
const activationLine =
|
||||
activation === "always"
|
||||
? "Activation: always-on (you receive every group message)."
|
||||
: "Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included).";
|
||||
const silenceLine =
|
||||
activation === "always"
|
||||
? `If no response is needed, reply with exactly "${params.silentToken}" (no other text) so Clawdis stays silent.`
|
||||
: undefined;
|
||||
const cautionLine =
|
||||
activation === "always"
|
||||
? "Be extremely selective: reply only when you are directly addressed, asked a question, or can add clear value. Otherwise stay silent."
|
||||
: undefined;
|
||||
const lurkLine =
|
||||
"Be a good group participant: lurk and follow the conversation, but only chime in when you have something genuinely helpful or relevant to add. Don't feel obligated to respond to every message — quality over quantity. Even when lurking silently, you can use emoji reactions to acknowledge messages, show support, or react to humor — reactions are always appreciated and don't clutter the chat.";
|
||||
return [
|
||||
subjectLine,
|
||||
membersLine,
|
||||
activationLine,
|
||||
silenceLine,
|
||||
cautionLine,
|
||||
lurkLine,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.concat(" Address the specific sender noted in the message context.");
|
||||
}
|
||||
45
src/auto-reply/reply/mentions.ts
Normal file
45
src/auto-reply/reply/mentions.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { ClawdisConfig } from "../../config/config.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
|
||||
export function stripStructuralPrefixes(text: string): string {
|
||||
// Ignore wrapper labels, timestamps, and sender prefixes so directive-only
|
||||
// detection still works in group batches that include history/context.
|
||||
const marker = "[Current message - respond to this]";
|
||||
const afterMarker = text.includes(marker)
|
||||
? text.slice(text.indexOf(marker) + marker.length)
|
||||
: text;
|
||||
return afterMarker
|
||||
.replace(/\[[^\]]+\]\s*/g, "")
|
||||
.replace(/^[ \t]*[A-Za-z0-9+()\-_. ]+:\s*/gm, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function stripMentions(
|
||||
text: string,
|
||||
ctx: MsgContext,
|
||||
cfg: ClawdisConfig | undefined,
|
||||
): string {
|
||||
let result = text;
|
||||
const patterns = cfg?.routing?.groupChat?.mentionPatterns ?? [];
|
||||
for (const p of patterns) {
|
||||
try {
|
||||
const re = new RegExp(p, "gi");
|
||||
result = result.replace(re, " ");
|
||||
} catch {
|
||||
// ignore invalid regex
|
||||
}
|
||||
}
|
||||
const selfE164 = (ctx.To ?? "").replace(/^whatsapp:/, "");
|
||||
if (selfE164) {
|
||||
const esc = selfE164.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
result = result
|
||||
.replace(new RegExp(esc, "gi"), " ")
|
||||
.replace(new RegExp(`@${esc}`, "gi"), " ");
|
||||
}
|
||||
// Generic mention patterns like @123456789 or plain digits
|
||||
result = result.replace(/@[0-9+]{5,}/g, " ");
|
||||
// Discord-style mentions (<@123> or <@!123>)
|
||||
result = result.replace(/<@!?\d+>/g, " ");
|
||||
return result.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
188
src/auto-reply/reply/model-selection.ts
Normal file
188
src/auto-reply/reply/model-selection.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { lookupContextTokens } from "../../agents/context.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
|
||||
import { loadModelCatalog } from "../../agents/model-catalog.js";
|
||||
import {
|
||||
buildAllowedModelSet,
|
||||
type ModelAliasIndex,
|
||||
modelKey,
|
||||
resolveModelRefFromString,
|
||||
resolveThinkingDefault,
|
||||
} from "../../agents/model-selection.js";
|
||||
import type { ClawdisConfig } from "../../config/config.js";
|
||||
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
|
||||
import type { ThinkLevel } from "./directives.js";
|
||||
|
||||
export type ModelDirectiveSelection = {
|
||||
provider: string;
|
||||
model: string;
|
||||
isDefault: boolean;
|
||||
alias?: string;
|
||||
};
|
||||
|
||||
type ModelCatalog = Awaited<ReturnType<typeof loadModelCatalog>>;
|
||||
|
||||
type ModelSelectionState = {
|
||||
provider: string;
|
||||
model: string;
|
||||
allowedModelKeys: Set<string>;
|
||||
allowedModelCatalog: ModelCatalog;
|
||||
resetModelOverride: boolean;
|
||||
resolveDefaultThinkingLevel: () => Promise<ThinkLevel | undefined>;
|
||||
needsModelCatalog: boolean;
|
||||
};
|
||||
|
||||
export async function createModelSelectionState(params: {
|
||||
cfg: ClawdisConfig;
|
||||
agentCfg: ClawdisConfig["agent"] | undefined;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
defaultProvider: string;
|
||||
defaultModel: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
hasModelDirective: boolean;
|
||||
}): Promise<ModelSelectionState> {
|
||||
const {
|
||||
cfg,
|
||||
agentCfg,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
defaultProvider,
|
||||
} = params;
|
||||
|
||||
let provider = params.provider;
|
||||
let model = params.model;
|
||||
|
||||
const hasAllowlist = (agentCfg?.allowedModels?.length ?? 0) > 0;
|
||||
const hasStoredOverride = Boolean(
|
||||
sessionEntry?.modelOverride || sessionEntry?.providerOverride,
|
||||
);
|
||||
const needsModelCatalog =
|
||||
params.hasModelDirective || hasAllowlist || hasStoredOverride;
|
||||
|
||||
let allowedModelKeys = new Set<string>();
|
||||
let allowedModelCatalog: ModelCatalog = [];
|
||||
let modelCatalog: ModelCatalog | null = null;
|
||||
let resetModelOverride = false;
|
||||
|
||||
if (needsModelCatalog) {
|
||||
modelCatalog = await loadModelCatalog({ config: cfg });
|
||||
const allowed = buildAllowedModelSet({
|
||||
cfg,
|
||||
catalog: modelCatalog,
|
||||
defaultProvider,
|
||||
});
|
||||
allowedModelCatalog = allowed.allowedCatalog;
|
||||
allowedModelKeys = allowed.allowedKeys;
|
||||
}
|
||||
|
||||
if (sessionEntry && sessionStore && sessionKey && hasStoredOverride) {
|
||||
const overrideProvider =
|
||||
sessionEntry.providerOverride?.trim() || defaultProvider;
|
||||
const overrideModel = sessionEntry.modelOverride?.trim();
|
||||
if (overrideModel) {
|
||||
const key = modelKey(overrideProvider, overrideModel);
|
||||
if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) {
|
||||
delete sessionEntry.providerOverride;
|
||||
delete sessionEntry.modelOverride;
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
resetModelOverride = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const storedProviderOverride = sessionEntry?.providerOverride?.trim();
|
||||
const storedModelOverride = sessionEntry?.modelOverride?.trim();
|
||||
if (storedModelOverride) {
|
||||
const candidateProvider = storedProviderOverride || defaultProvider;
|
||||
const key = modelKey(candidateProvider, storedModelOverride);
|
||||
if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) {
|
||||
provider = candidateProvider;
|
||||
model = storedModelOverride;
|
||||
}
|
||||
}
|
||||
|
||||
let defaultThinkingLevel: ThinkLevel | undefined;
|
||||
const resolveDefaultThinkingLevel = async () => {
|
||||
if (defaultThinkingLevel) return defaultThinkingLevel;
|
||||
let catalogForThinking = modelCatalog ?? allowedModelCatalog;
|
||||
if (!catalogForThinking || catalogForThinking.length === 0) {
|
||||
modelCatalog = await loadModelCatalog({ config: cfg });
|
||||
catalogForThinking = modelCatalog;
|
||||
}
|
||||
defaultThinkingLevel = resolveThinkingDefault({
|
||||
cfg,
|
||||
provider,
|
||||
model,
|
||||
catalog: catalogForThinking,
|
||||
});
|
||||
return defaultThinkingLevel;
|
||||
};
|
||||
|
||||
return {
|
||||
provider,
|
||||
model,
|
||||
allowedModelKeys,
|
||||
allowedModelCatalog,
|
||||
resetModelOverride,
|
||||
resolveDefaultThinkingLevel,
|
||||
needsModelCatalog,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveModelDirectiveSelection(params: {
|
||||
raw: string;
|
||||
defaultProvider: string;
|
||||
defaultModel: string;
|
||||
aliasIndex: ModelAliasIndex;
|
||||
allowedModelKeys: Set<string>;
|
||||
}): { selection?: ModelDirectiveSelection; error?: string } {
|
||||
const { raw, defaultProvider, defaultModel, aliasIndex, allowedModelKeys } =
|
||||
params;
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw,
|
||||
defaultProvider,
|
||||
aliasIndex,
|
||||
});
|
||||
if (!resolved) {
|
||||
return {
|
||||
error: `Unrecognized model "${raw}". Use /model to list available models.`,
|
||||
};
|
||||
}
|
||||
const key = modelKey(resolved.ref.provider, resolved.ref.model);
|
||||
if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) {
|
||||
return {
|
||||
error: `Model "${resolved.ref.provider}/${resolved.ref.model}" is not allowed. Use /model to list available models.`,
|
||||
};
|
||||
}
|
||||
const isDefault =
|
||||
resolved.ref.provider === defaultProvider &&
|
||||
resolved.ref.model === defaultModel;
|
||||
return {
|
||||
selection: {
|
||||
provider: resolved.ref.provider,
|
||||
model: resolved.ref.model,
|
||||
isDefault,
|
||||
alias: resolved.alias,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveContextTokens(params: {
|
||||
agentCfg: ClawdisConfig["agent"] | undefined;
|
||||
model: string;
|
||||
}): number {
|
||||
return (
|
||||
params.agentCfg?.contextTokens ??
|
||||
lookupContextTokens(params.model) ??
|
||||
DEFAULT_CONTEXT_TOKENS
|
||||
);
|
||||
}
|
||||
473
src/auto-reply/reply/queue.ts
Normal file
473
src/auto-reply/reply/queue.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
import type { SkillSnapshot } from "../../agents/skills.js";
|
||||
import { parseDurationMs } from "../../cli/parse-duration.js";
|
||||
import type { ClawdisConfig } from "../../config/config.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import type { ThinkLevel, VerboseLevel } from "./directives.js";
|
||||
export type QueueMode =
|
||||
| "steer"
|
||||
| "followup"
|
||||
| "collect"
|
||||
| "steer-backlog"
|
||||
| "interrupt"
|
||||
| "queue";
|
||||
export type QueueDropPolicy = "old" | "new" | "summarize";
|
||||
export type QueueSettings = {
|
||||
mode: QueueMode;
|
||||
debounceMs?: number;
|
||||
cap?: number;
|
||||
dropPolicy?: QueueDropPolicy;
|
||||
};
|
||||
export type FollowupRun = {
|
||||
prompt: string;
|
||||
summaryLine?: string;
|
||||
enqueuedAt: number;
|
||||
run: {
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
surface?: string;
|
||||
sessionFile: string;
|
||||
workspaceDir: string;
|
||||
config: ClawdisConfig;
|
||||
skillsSnapshot?: SkillSnapshot;
|
||||
provider: string;
|
||||
model: string;
|
||||
thinkLevel?: ThinkLevel;
|
||||
verboseLevel?: VerboseLevel;
|
||||
timeoutMs: number;
|
||||
blockReplyBreak: "text_end" | "message_end";
|
||||
ownerNumbers?: string[];
|
||||
extraSystemPrompt?: string;
|
||||
enforceFinalTag?: boolean;
|
||||
};
|
||||
};
|
||||
type FollowupQueueState = {
|
||||
items: FollowupRun[];
|
||||
draining: boolean;
|
||||
lastEnqueuedAt: number;
|
||||
mode: QueueMode;
|
||||
debounceMs: number;
|
||||
cap: number;
|
||||
dropPolicy: QueueDropPolicy;
|
||||
droppedCount: number;
|
||||
summaryLines: string[];
|
||||
lastRun?: FollowupRun["run"];
|
||||
};
|
||||
const DEFAULT_QUEUE_DEBOUNCE_MS = 1000;
|
||||
const DEFAULT_QUEUE_CAP = 20;
|
||||
const DEFAULT_QUEUE_DROP: QueueDropPolicy = "summarize";
|
||||
const FOLLOWUP_QUEUES = new Map<string, FollowupQueueState>();
|
||||
function normalizeQueueMode(raw?: string): QueueMode | undefined {
|
||||
if (!raw) return undefined;
|
||||
const cleaned = raw.trim().toLowerCase();
|
||||
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"
|
||||
)
|
||||
return "followup";
|
||||
if (cleaned === "collect" || cleaned === "coalesce") return "collect";
|
||||
if (
|
||||
cleaned === "steer+backlog" ||
|
||||
cleaned === "steer-backlog" ||
|
||||
cleaned === "steer_backlog"
|
||||
)
|
||||
return "steer-backlog";
|
||||
return undefined;
|
||||
}
|
||||
function normalizeQueueDropPolicy(raw?: string): QueueDropPolicy | 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";
|
||||
return undefined;
|
||||
}
|
||||
function parseQueueDebounce(raw?: string): number | undefined {
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
const parsed = parseDurationMs(raw.trim(), { defaultUnit: "ms" });
|
||||
if (!parsed || parsed < 0) return undefined;
|
||||
return Math.round(parsed);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
function parseQueueCap(raw?: string): number | undefined {
|
||||
if (!raw) return undefined;
|
||||
const num = Number(raw);
|
||||
if (!Number.isFinite(num)) return undefined;
|
||||
const cap = Math.floor(num);
|
||||
if (cap < 1) return undefined;
|
||||
return cap;
|
||||
}
|
||||
function parseQueueDirectiveArgs(raw: string): {
|
||||
consumed: number;
|
||||
queueMode?: QueueMode;
|
||||
queueReset: boolean;
|
||||
rawMode?: string;
|
||||
debounceMs?: number;
|
||||
cap?: number;
|
||||
dropPolicy?: QueueDropPolicy;
|
||||
rawDebounce?: string;
|
||||
rawCap?: string;
|
||||
rawDrop?: string;
|
||||
hasOptions: boolean;
|
||||
} {
|
||||
let i = 0;
|
||||
const len = raw.length;
|
||||
while (i < len && /\s/.test(raw[i])) i += 1;
|
||||
if (raw[i] === ":") {
|
||||
i += 1;
|
||||
while (i < len && /\s/.test(raw[i])) i += 1;
|
||||
}
|
||||
let consumed = i;
|
||||
let queueMode: QueueMode | undefined;
|
||||
let queueReset = false;
|
||||
let rawMode: string | undefined;
|
||||
let debounceMs: number | undefined;
|
||||
let cap: number | undefined;
|
||||
let dropPolicy: QueueDropPolicy | undefined;
|
||||
let rawDebounce: string | undefined;
|
||||
let rawCap: string | undefined;
|
||||
let rawDrop: string | undefined;
|
||||
let hasOptions = false;
|
||||
const takeToken = (): string | null => {
|
||||
if (i >= len) return null;
|
||||
const start = i;
|
||||
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;
|
||||
return token;
|
||||
};
|
||||
while (i < len) {
|
||||
const token = takeToken();
|
||||
if (!token) break;
|
||||
const lowered = token.trim().toLowerCase();
|
||||
if (lowered === "default" || lowered === "reset" || lowered === "clear") {
|
||||
queueReset = true;
|
||||
consumed = i;
|
||||
break;
|
||||
}
|
||||
if (lowered.startsWith("debounce:") || lowered.startsWith("debounce=")) {
|
||||
rawDebounce = token.split(/[:=]/)[1] ?? "";
|
||||
debounceMs = parseQueueDebounce(rawDebounce);
|
||||
hasOptions = true;
|
||||
consumed = i;
|
||||
continue;
|
||||
}
|
||||
if (lowered.startsWith("cap:") || lowered.startsWith("cap=")) {
|
||||
rawCap = token.split(/[:=]/)[1] ?? "";
|
||||
cap = parseQueueCap(rawCap);
|
||||
hasOptions = true;
|
||||
consumed = i;
|
||||
continue;
|
||||
}
|
||||
if (lowered.startsWith("drop:") || lowered.startsWith("drop=")) {
|
||||
rawDrop = token.split(/[:=]/)[1] ?? "";
|
||||
dropPolicy = normalizeQueueDropPolicy(rawDrop);
|
||||
hasOptions = true;
|
||||
consumed = i;
|
||||
continue;
|
||||
}
|
||||
const mode = normalizeQueueMode(token);
|
||||
if (mode) {
|
||||
queueMode = mode;
|
||||
rawMode = token;
|
||||
consumed = i;
|
||||
continue;
|
||||
}
|
||||
// Stop at first unrecognized token.
|
||||
break;
|
||||
}
|
||||
return {
|
||||
consumed,
|
||||
queueMode,
|
||||
queueReset,
|
||||
rawMode,
|
||||
debounceMs,
|
||||
cap,
|
||||
dropPolicy,
|
||||
rawDebounce,
|
||||
rawCap,
|
||||
rawDrop,
|
||||
hasOptions,
|
||||
};
|
||||
}
|
||||
export function extractQueueDirective(body?: string): {
|
||||
cleaned: string;
|
||||
queueMode?: QueueMode;
|
||||
queueReset: boolean;
|
||||
rawMode?: string;
|
||||
hasDirective: boolean;
|
||||
debounceMs?: number;
|
||||
cap?: number;
|
||||
dropPolicy?: QueueDropPolicy;
|
||||
rawDebounce?: string;
|
||||
rawCap?: string;
|
||||
rawDrop?: string;
|
||||
hasOptions: boolean;
|
||||
} {
|
||||
if (!body)
|
||||
return {
|
||||
cleaned: "",
|
||||
hasDirective: false,
|
||||
queueReset: false,
|
||||
hasOptions: false,
|
||||
};
|
||||
const re = /(?:^|\s)\/queue(?=$|\s|:)/i;
|
||||
const match = re.exec(body);
|
||||
if (!match) {
|
||||
return {
|
||||
cleaned: body.trim(),
|
||||
hasDirective: false,
|
||||
queueReset: false,
|
||||
hasOptions: false,
|
||||
};
|
||||
}
|
||||
const start = match.index + match[0].indexOf("/queue");
|
||||
const argsStart = start + "/queue".length;
|
||||
const args = body.slice(argsStart);
|
||||
const parsed = parseQueueDirectiveArgs(args);
|
||||
const cleanedRaw =
|
||||
body.slice(0, start) + body.slice(argsStart + parsed.consumed);
|
||||
const cleaned = cleanedRaw.replace(/\s+/g, " ").trim();
|
||||
return {
|
||||
cleaned,
|
||||
queueMode: parsed.queueMode,
|
||||
queueReset: parsed.queueReset,
|
||||
rawMode: parsed.rawMode,
|
||||
debounceMs: parsed.debounceMs,
|
||||
cap: parsed.cap,
|
||||
dropPolicy: parsed.dropPolicy,
|
||||
rawDebounce: parsed.rawDebounce,
|
||||
rawCap: parsed.rawCap,
|
||||
rawDrop: parsed.rawDrop,
|
||||
hasDirective: true,
|
||||
hasOptions: parsed.hasOptions,
|
||||
};
|
||||
}
|
||||
function elideText(text: string, limit = 140): string {
|
||||
if (text.length <= limit) return text;
|
||||
return `${text.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
|
||||
}
|
||||
function buildQueueSummaryLine(run: FollowupRun): string {
|
||||
const base = run.summaryLine?.trim() || run.prompt.trim();
|
||||
const cleaned = base.replace(/\s+/g, " ").trim();
|
||||
return elideText(cleaned, 160);
|
||||
}
|
||||
function getFollowupQueue(
|
||||
key: string,
|
||||
settings: QueueSettings,
|
||||
): FollowupQueueState {
|
||||
const existing = FOLLOWUP_QUEUES.get(key);
|
||||
if (existing) {
|
||||
existing.mode = settings.mode;
|
||||
existing.debounceMs =
|
||||
typeof settings.debounceMs === "number"
|
||||
? Math.max(0, settings.debounceMs)
|
||||
: existing.debounceMs;
|
||||
existing.cap =
|
||||
typeof settings.cap === "number" && settings.cap > 0
|
||||
? Math.floor(settings.cap)
|
||||
: existing.cap;
|
||||
existing.dropPolicy = settings.dropPolicy ?? existing.dropPolicy;
|
||||
return existing;
|
||||
}
|
||||
const created: FollowupQueueState = {
|
||||
items: [],
|
||||
draining: false,
|
||||
lastEnqueuedAt: 0,
|
||||
mode: settings.mode,
|
||||
debounceMs:
|
||||
typeof settings.debounceMs === "number"
|
||||
? Math.max(0, settings.debounceMs)
|
||||
: DEFAULT_QUEUE_DEBOUNCE_MS,
|
||||
cap:
|
||||
typeof settings.cap === "number" && settings.cap > 0
|
||||
? Math.floor(settings.cap)
|
||||
: DEFAULT_QUEUE_CAP,
|
||||
dropPolicy: settings.dropPolicy ?? DEFAULT_QUEUE_DROP,
|
||||
droppedCount: 0,
|
||||
summaryLines: [],
|
||||
};
|
||||
FOLLOWUP_QUEUES.set(key, created);
|
||||
return created;
|
||||
}
|
||||
export function enqueueFollowupRun(
|
||||
key: string,
|
||||
run: FollowupRun,
|
||||
settings: QueueSettings,
|
||||
): boolean {
|
||||
const queue = getFollowupQueue(key, settings);
|
||||
queue.lastEnqueuedAt = Date.now();
|
||||
queue.lastRun = run.run;
|
||||
const cap = queue.cap;
|
||||
if (cap > 0 && queue.items.length >= cap) {
|
||||
if (queue.dropPolicy === "new") {
|
||||
return false;
|
||||
}
|
||||
const dropCount = queue.items.length - cap + 1;
|
||||
const dropped = queue.items.splice(0, dropCount);
|
||||
if (queue.dropPolicy === "summarize") {
|
||||
for (const item of dropped) {
|
||||
queue.droppedCount += 1;
|
||||
queue.summaryLines.push(buildQueueSummaryLine(item));
|
||||
}
|
||||
while (queue.summaryLines.length > cap) queue.summaryLines.shift();
|
||||
}
|
||||
}
|
||||
queue.items.push(run);
|
||||
return true;
|
||||
}
|
||||
async function waitForQueueDebounce(queue: FollowupQueueState): Promise<void> {
|
||||
const debounceMs = Math.max(0, queue.debounceMs);
|
||||
if (debounceMs <= 0) return;
|
||||
while (true) {
|
||||
const since = Date.now() - queue.lastEnqueuedAt;
|
||||
if (since >= debounceMs) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, debounceMs - since));
|
||||
}
|
||||
}
|
||||
function buildSummaryPrompt(queue: FollowupQueueState): string | undefined {
|
||||
if (queue.dropPolicy !== "summarize" || queue.droppedCount <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const lines = [
|
||||
`[Queue overflow] Dropped ${queue.droppedCount} message${queue.droppedCount === 1 ? "" : "s"} due to cap.`,
|
||||
];
|
||||
if (queue.summaryLines.length > 0) {
|
||||
lines.push("Summary:");
|
||||
for (const line of queue.summaryLines) {
|
||||
lines.push(`- ${line}`);
|
||||
}
|
||||
}
|
||||
queue.droppedCount = 0;
|
||||
queue.summaryLines = [];
|
||||
return lines.join("\n");
|
||||
}
|
||||
function buildCollectPrompt(items: FollowupRun[], summary?: string): string {
|
||||
const blocks: string[] = ["[Queued messages while agent was busy]"];
|
||||
if (summary) {
|
||||
blocks.push(summary);
|
||||
}
|
||||
items.forEach((item, idx) => {
|
||||
blocks.push(`---\nQueued #${idx + 1}\n${item.prompt}`.trim());
|
||||
});
|
||||
return blocks.join("\n\n");
|
||||
}
|
||||
export function scheduleFollowupDrain(
|
||||
key: string,
|
||||
runFollowup: (run: FollowupRun) => Promise<void>,
|
||||
): void {
|
||||
const queue = FOLLOWUP_QUEUES.get(key);
|
||||
if (!queue || queue.draining) return;
|
||||
queue.draining = true;
|
||||
void (async () => {
|
||||
try {
|
||||
while (queue.items.length > 0 || queue.droppedCount > 0) {
|
||||
await waitForQueueDebounce(queue);
|
||||
if (queue.mode === "collect") {
|
||||
const items = queue.items.splice(0, queue.items.length);
|
||||
const summary = buildSummaryPrompt(queue);
|
||||
const run = items.at(-1)?.run ?? queue.lastRun;
|
||||
if (!run) break;
|
||||
const prompt = buildCollectPrompt(items, summary);
|
||||
await runFollowup({
|
||||
prompt,
|
||||
run,
|
||||
enqueuedAt: Date.now(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const summaryPrompt = buildSummaryPrompt(queue);
|
||||
if (summaryPrompt) {
|
||||
const run = queue.lastRun;
|
||||
if (!run) break;
|
||||
await runFollowup({
|
||||
prompt: summaryPrompt,
|
||||
run,
|
||||
enqueuedAt: Date.now(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const next = queue.items.shift();
|
||||
if (!next) break;
|
||||
await runFollowup(next);
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error?.(
|
||||
`followup queue drain failed for ${key}: ${String(err)}`,
|
||||
);
|
||||
} finally {
|
||||
queue.draining = false;
|
||||
if (queue.items.length === 0 && queue.droppedCount === 0) {
|
||||
FOLLOWUP_QUEUES.delete(key);
|
||||
} else {
|
||||
scheduleFollowupDrain(key, runFollowup);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
function defaultQueueModeForSurface(surface?: string): QueueMode {
|
||||
const normalized = surface?.trim().toLowerCase();
|
||||
if (normalized === "discord") return "collect";
|
||||
if (normalized === "webchat") return "collect";
|
||||
if (normalized === "whatsapp") return "collect";
|
||||
if (normalized === "telegram") return "collect";
|
||||
if (normalized === "imessage") return "collect";
|
||||
if (normalized === "signal") return "collect";
|
||||
return "collect";
|
||||
}
|
||||
export function resolveQueueSettings(params: {
|
||||
cfg: ClawdisConfig;
|
||||
surface?: string;
|
||||
sessionEntry?: SessionEntry;
|
||||
inlineMode?: QueueMode;
|
||||
inlineOptions?: Partial<QueueSettings>;
|
||||
}): QueueSettings {
|
||||
const surfaceKey = params.surface?.trim().toLowerCase();
|
||||
const queueCfg = params.cfg.routing?.queue;
|
||||
const surfaceModeRaw =
|
||||
surfaceKey && queueCfg?.bySurface
|
||||
? (queueCfg.bySurface as Record<string, string | undefined>)[surfaceKey]
|
||||
: undefined;
|
||||
const resolvedMode =
|
||||
params.inlineMode ??
|
||||
normalizeQueueMode(params.sessionEntry?.queueMode) ??
|
||||
normalizeQueueMode(surfaceModeRaw) ??
|
||||
normalizeQueueMode(queueCfg?.mode) ??
|
||||
defaultQueueModeForSurface(surfaceKey);
|
||||
const debounceRaw =
|
||||
params.inlineOptions?.debounceMs ??
|
||||
params.sessionEntry?.queueDebounceMs ??
|
||||
queueCfg?.debounceMs ??
|
||||
DEFAULT_QUEUE_DEBOUNCE_MS;
|
||||
const capRaw =
|
||||
params.inlineOptions?.cap ??
|
||||
params.sessionEntry?.queueCap ??
|
||||
queueCfg?.cap ??
|
||||
DEFAULT_QUEUE_CAP;
|
||||
const dropRaw =
|
||||
params.inlineOptions?.dropPolicy ??
|
||||
params.sessionEntry?.queueDrop ??
|
||||
normalizeQueueDropPolicy(queueCfg?.drop) ??
|
||||
DEFAULT_QUEUE_DROP;
|
||||
return {
|
||||
mode: resolvedMode,
|
||||
debounceMs:
|
||||
typeof debounceRaw === "number" ? Math.max(0, debounceRaw) : undefined,
|
||||
cap:
|
||||
typeof capRaw === "number" ? Math.max(1, Math.floor(capRaw)) : undefined,
|
||||
dropPolicy: dropRaw,
|
||||
};
|
||||
}
|
||||
35
src/auto-reply/reply/reply-tags.ts
Normal file
35
src/auto-reply/reply/reply-tags.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export function extractReplyToTag(
|
||||
text?: string,
|
||||
currentMessageId?: string,
|
||||
): {
|
||||
cleaned: string;
|
||||
replyToId?: string;
|
||||
hasTag: boolean;
|
||||
} {
|
||||
if (!text) return { cleaned: "", hasTag: false };
|
||||
let cleaned = text;
|
||||
let replyToId: string | undefined;
|
||||
let hasTag = false;
|
||||
|
||||
const currentMatch = cleaned.match(/\[\[reply_to_current\]\]/i);
|
||||
if (currentMatch) {
|
||||
cleaned = cleaned.replace(/\[\[reply_to_current\]\]/gi, " ");
|
||||
hasTag = true;
|
||||
if (currentMessageId?.trim()) {
|
||||
replyToId = currentMessageId.trim();
|
||||
}
|
||||
}
|
||||
|
||||
const idMatch = cleaned.match(/\[\[reply_to:([^\]\n]+)\]\]/i);
|
||||
if (idMatch?.[1]) {
|
||||
cleaned = cleaned.replace(/\[\[reply_to:[^\]\n]+\]\]/gi, " ");
|
||||
replyToId = idMatch[1].trim();
|
||||
hasTag = true;
|
||||
}
|
||||
|
||||
cleaned = cleaned
|
||||
.replace(/[ \t]+/g, " ")
|
||||
.replace(/[ \t]*\n[ \t]*/g, "\n")
|
||||
.trim();
|
||||
return { cleaned, replyToId, hasTag };
|
||||
}
|
||||
125
src/auto-reply/reply/session-updates.ts
Normal file
125
src/auto-reply/reply/session-updates.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
|
||||
import type { ClawdisConfig } from "../../config/config.js";
|
||||
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
|
||||
import { buildProviderSummary } from "../../infra/provider-summary.js";
|
||||
import { drainSystemEvents } from "../../infra/system-events.js";
|
||||
|
||||
export async function prependSystemEvents(params: {
|
||||
cfg: ClawdisConfig;
|
||||
isMainSession: boolean;
|
||||
isNewSession: boolean;
|
||||
prefixedBodyBase: string;
|
||||
}): Promise<string> {
|
||||
if (!params.isMainSession) return params.prefixedBodyBase;
|
||||
|
||||
const compactSystemEvent = (line: string): string | null => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return null;
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.includes("reason periodic")) return null;
|
||||
if (lower.includes("heartbeat")) return null;
|
||||
if (trimmed.startsWith("Node:")) {
|
||||
return trimmed.replace(/ · last input [^·]+/i, "").trim();
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const systemLines: string[] = [];
|
||||
const queued = drainSystemEvents();
|
||||
systemLines.push(
|
||||
...queued.map(compactSystemEvent).filter((v): v is string => Boolean(v)),
|
||||
);
|
||||
if (params.isNewSession) {
|
||||
const summary = await buildProviderSummary(params.cfg);
|
||||
if (summary.length > 0) systemLines.unshift(...summary);
|
||||
}
|
||||
if (systemLines.length === 0) return params.prefixedBodyBase;
|
||||
|
||||
const block = systemLines.map((l) => `System: ${l}`).join("\n");
|
||||
return `${block}\n\n${params.prefixedBodyBase}`;
|
||||
}
|
||||
|
||||
export async function ensureSkillSnapshot(params: {
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
sessionId?: string;
|
||||
isFirstTurnInSession: boolean;
|
||||
workspaceDir: string;
|
||||
cfg: ClawdisConfig;
|
||||
}): Promise<{
|
||||
sessionEntry?: SessionEntry;
|
||||
skillsSnapshot?: SessionEntry["skillsSnapshot"];
|
||||
systemSent: boolean;
|
||||
}> {
|
||||
const {
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionId,
|
||||
isFirstTurnInSession,
|
||||
workspaceDir,
|
||||
cfg,
|
||||
} = params;
|
||||
|
||||
let nextEntry = sessionEntry;
|
||||
let systemSent = sessionEntry?.systemSent ?? false;
|
||||
|
||||
if (isFirstTurnInSession && sessionStore && sessionKey) {
|
||||
const current = nextEntry ??
|
||||
sessionStore[sessionKey] ?? {
|
||||
sessionId: sessionId ?? crypto.randomUUID(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const skillSnapshot =
|
||||
isFirstTurnInSession || !current.skillsSnapshot
|
||||
? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg })
|
||||
: current.skillsSnapshot;
|
||||
nextEntry = {
|
||||
...current,
|
||||
sessionId: sessionId ?? current.sessionId ?? crypto.randomUUID(),
|
||||
updatedAt: Date.now(),
|
||||
systemSent: true,
|
||||
skillsSnapshot: skillSnapshot,
|
||||
};
|
||||
sessionStore[sessionKey] = nextEntry;
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
systemSent = true;
|
||||
}
|
||||
|
||||
const skillsSnapshot =
|
||||
nextEntry?.skillsSnapshot ??
|
||||
(isFirstTurnInSession
|
||||
? undefined
|
||||
: buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg }));
|
||||
if (
|
||||
skillsSnapshot &&
|
||||
sessionStore &&
|
||||
sessionKey &&
|
||||
!isFirstTurnInSession &&
|
||||
!nextEntry?.skillsSnapshot
|
||||
) {
|
||||
const current = nextEntry ?? {
|
||||
sessionId: sessionId ?? crypto.randomUUID(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
nextEntry = {
|
||||
...current,
|
||||
sessionId: sessionId ?? current.sessionId ?? crypto.randomUUID(),
|
||||
updatedAt: Date.now(),
|
||||
skillsSnapshot,
|
||||
};
|
||||
sessionStore[sessionKey] = nextEntry;
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
}
|
||||
|
||||
return { sessionEntry: nextEntry, skillsSnapshot, systemSent };
|
||||
}
|
||||
207
src/auto-reply/reply/session.ts
Normal file
207
src/auto-reply/reply/session.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import type { ClawdisConfig } from "../../config/config.js";
|
||||
import {
|
||||
buildGroupDisplayName,
|
||||
DEFAULT_IDLE_MINUTES,
|
||||
DEFAULT_RESET_TRIGGERS,
|
||||
type GroupKeyResolution,
|
||||
loadSessionStore,
|
||||
resolveGroupSessionKey,
|
||||
resolveSessionKey,
|
||||
resolveStorePath,
|
||||
type SessionEntry,
|
||||
saveSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import type { MsgContext, TemplateContext } from "../templating.js";
|
||||
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
||||
|
||||
export type SessionInitResult = {
|
||||
sessionCtx: TemplateContext;
|
||||
sessionEntry: SessionEntry;
|
||||
sessionStore: Record<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
sessionId: string;
|
||||
isNewSession: boolean;
|
||||
systemSent: boolean;
|
||||
abortedLastRun: boolean;
|
||||
storePath: string;
|
||||
sessionScope: string;
|
||||
groupResolution?: GroupKeyResolution;
|
||||
isGroup: boolean;
|
||||
bodyStripped?: string;
|
||||
triggerBodyNormalized: string;
|
||||
};
|
||||
|
||||
export async function initSessionState(params: {
|
||||
ctx: MsgContext;
|
||||
cfg: ClawdisConfig;
|
||||
}): Promise<SessionInitResult> {
|
||||
const { ctx, cfg } = params;
|
||||
const sessionCfg = cfg.session;
|
||||
const mainKey = sessionCfg?.mainKey ?? "main";
|
||||
const resetTriggers = sessionCfg?.resetTriggers?.length
|
||||
? sessionCfg.resetTriggers
|
||||
: DEFAULT_RESET_TRIGGERS;
|
||||
const idleMinutes = Math.max(
|
||||
sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES,
|
||||
1,
|
||||
);
|
||||
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
||||
const storePath = resolveStorePath(sessionCfg?.store);
|
||||
|
||||
const sessionStore: Record<string, SessionEntry> =
|
||||
loadSessionStore(storePath);
|
||||
let sessionKey: string | undefined;
|
||||
let sessionEntry: SessionEntry | undefined;
|
||||
|
||||
let sessionId: string | undefined;
|
||||
let isNewSession = false;
|
||||
let bodyStripped: string | undefined;
|
||||
let systemSent = false;
|
||||
let abortedLastRun = false;
|
||||
|
||||
let persistedThinking: string | undefined;
|
||||
let persistedVerbose: string | undefined;
|
||||
let persistedModelOverride: string | undefined;
|
||||
let persistedProviderOverride: string | undefined;
|
||||
|
||||
const groupResolution = resolveGroupSessionKey(ctx);
|
||||
const isGroup =
|
||||
ctx.ChatType?.trim().toLowerCase() === "group" || Boolean(groupResolution);
|
||||
const triggerBodyNormalized = stripStructuralPrefixes(ctx.Body ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
const rawBody = ctx.Body ?? "";
|
||||
const trimmedBody = rawBody.trim();
|
||||
// Timestamp/message prefixes (e.g. "[Dec 4 17:35] ") are added by the
|
||||
// web inbox before we get here. They prevented reset triggers like "/new"
|
||||
// from matching, so strip structural wrappers when checking for resets.
|
||||
const strippedForReset = isGroup
|
||||
? stripMentions(triggerBodyNormalized, ctx, cfg)
|
||||
: triggerBodyNormalized;
|
||||
for (const trigger of resetTriggers) {
|
||||
if (!trigger) continue;
|
||||
if (trimmedBody === trigger || strippedForReset === trigger) {
|
||||
isNewSession = true;
|
||||
bodyStripped = "";
|
||||
break;
|
||||
}
|
||||
const triggerPrefix = `${trigger} `;
|
||||
if (
|
||||
trimmedBody.startsWith(triggerPrefix) ||
|
||||
strippedForReset.startsWith(triggerPrefix)
|
||||
) {
|
||||
isNewSession = true;
|
||||
bodyStripped = strippedForReset.slice(trigger.length).trimStart();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
sessionKey = resolveSessionKey(sessionScope, ctx, mainKey);
|
||||
if (groupResolution?.legacyKey && groupResolution.legacyKey !== sessionKey) {
|
||||
const legacyEntry = sessionStore[groupResolution.legacyKey];
|
||||
if (legacyEntry && !sessionStore[sessionKey]) {
|
||||
sessionStore[sessionKey] = legacyEntry;
|
||||
delete sessionStore[groupResolution.legacyKey];
|
||||
}
|
||||
}
|
||||
const entry = sessionStore[sessionKey];
|
||||
const idleMs = idleMinutes * 60_000;
|
||||
const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs;
|
||||
|
||||
if (!isNewSession && freshEntry) {
|
||||
sessionId = entry.sessionId;
|
||||
systemSent = entry.systemSent ?? false;
|
||||
abortedLastRun = entry.abortedLastRun ?? false;
|
||||
persistedThinking = entry.thinkingLevel;
|
||||
persistedVerbose = entry.verboseLevel;
|
||||
persistedModelOverride = entry.modelOverride;
|
||||
persistedProviderOverride = entry.providerOverride;
|
||||
} else {
|
||||
sessionId = crypto.randomUUID();
|
||||
isNewSession = true;
|
||||
systemSent = false;
|
||||
abortedLastRun = false;
|
||||
}
|
||||
|
||||
const baseEntry = !isNewSession && freshEntry ? entry : undefined;
|
||||
sessionEntry = {
|
||||
...baseEntry,
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
systemSent,
|
||||
abortedLastRun,
|
||||
// Persist previously stored thinking/verbose levels when present.
|
||||
thinkingLevel: persistedThinking ?? baseEntry?.thinkingLevel,
|
||||
verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel,
|
||||
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
|
||||
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
|
||||
sendPolicy: baseEntry?.sendPolicy,
|
||||
queueMode: baseEntry?.queueMode,
|
||||
queueDebounceMs: baseEntry?.queueDebounceMs,
|
||||
queueCap: baseEntry?.queueCap,
|
||||
queueDrop: baseEntry?.queueDrop,
|
||||
displayName: baseEntry?.displayName,
|
||||
chatType: baseEntry?.chatType,
|
||||
surface: baseEntry?.surface,
|
||||
subject: baseEntry?.subject,
|
||||
room: baseEntry?.room,
|
||||
space: baseEntry?.space,
|
||||
};
|
||||
if (groupResolution?.surface) {
|
||||
const surface = groupResolution.surface;
|
||||
const subject = ctx.GroupSubject?.trim();
|
||||
const space = ctx.GroupSpace?.trim();
|
||||
const explicitRoom = ctx.GroupRoom?.trim();
|
||||
const isRoomSurface = surface === "discord" || surface === "slack";
|
||||
const nextRoom =
|
||||
explicitRoom ??
|
||||
(isRoomSurface && subject && subject.startsWith("#")
|
||||
? subject
|
||||
: undefined);
|
||||
const nextSubject = nextRoom ? undefined : subject;
|
||||
sessionEntry.chatType = groupResolution.chatType ?? "group";
|
||||
sessionEntry.surface = surface;
|
||||
if (nextSubject) sessionEntry.subject = nextSubject;
|
||||
if (nextRoom) sessionEntry.room = nextRoom;
|
||||
if (space) sessionEntry.space = space;
|
||||
sessionEntry.displayName = buildGroupDisplayName({
|
||||
surface: sessionEntry.surface,
|
||||
subject: sessionEntry.subject,
|
||||
room: sessionEntry.room,
|
||||
space: sessionEntry.space,
|
||||
id: groupResolution.id,
|
||||
key: sessionKey,
|
||||
});
|
||||
} else if (!sessionEntry.chatType) {
|
||||
sessionEntry.chatType = "direct";
|
||||
}
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
|
||||
const sessionCtx: TemplateContext = {
|
||||
...ctx,
|
||||
BodyStripped: bodyStripped ?? ctx.Body,
|
||||
SessionId: sessionId,
|
||||
IsNewSession: isNewSession ? "true" : "false",
|
||||
};
|
||||
|
||||
return {
|
||||
sessionCtx,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
sessionId: sessionId ?? crypto.randomUUID(),
|
||||
isNewSession,
|
||||
systemSent,
|
||||
abortedLastRun,
|
||||
storePath,
|
||||
sessionScope,
|
||||
groupResolution,
|
||||
isGroup,
|
||||
bodyStripped,
|
||||
triggerBodyNormalized,
|
||||
};
|
||||
}
|
||||
95
src/auto-reply/reply/typing.ts
Normal file
95
src/auto-reply/reply/typing.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
export type TypingController = {
|
||||
onReplyStart: () => Promise<void>;
|
||||
startTypingLoop: () => Promise<void>;
|
||||
startTypingOnText: (text?: string) => Promise<void>;
|
||||
refreshTypingTtl: () => void;
|
||||
cleanup: () => void;
|
||||
};
|
||||
|
||||
export function createTypingController(params: {
|
||||
onReplyStart?: () => Promise<void> | void;
|
||||
typingIntervalSeconds?: number;
|
||||
typingTtlMs?: number;
|
||||
silentToken?: string;
|
||||
log?: (message: string) => void;
|
||||
}): TypingController {
|
||||
const {
|
||||
onReplyStart,
|
||||
typingIntervalSeconds = 6,
|
||||
typingTtlMs = 2 * 60_000,
|
||||
silentToken,
|
||||
log,
|
||||
} = params;
|
||||
let started = false;
|
||||
let typingTimer: NodeJS.Timeout | undefined;
|
||||
let typingTtlTimer: NodeJS.Timeout | undefined;
|
||||
const typingIntervalMs = typingIntervalSeconds * 1000;
|
||||
|
||||
const formatTypingTtl = (ms: number) => {
|
||||
if (ms % 60_000 === 0) return `${ms / 60_000}m`;
|
||||
return `${Math.round(ms / 1000)}s`;
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (typingTtlTimer) {
|
||||
clearTimeout(typingTtlTimer);
|
||||
typingTtlTimer = undefined;
|
||||
}
|
||||
if (typingTimer) {
|
||||
clearInterval(typingTimer);
|
||||
typingTimer = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshTypingTtl = () => {
|
||||
if (!typingIntervalMs || typingIntervalMs <= 0) return;
|
||||
if (typingTtlMs <= 0) return;
|
||||
if (typingTtlTimer) {
|
||||
clearTimeout(typingTtlTimer);
|
||||
}
|
||||
typingTtlTimer = setTimeout(() => {
|
||||
if (!typingTimer) return;
|
||||
log?.(
|
||||
`typing TTL reached (${formatTypingTtl(typingTtlMs)}); stopping typing indicator`,
|
||||
);
|
||||
cleanup();
|
||||
}, typingTtlMs);
|
||||
};
|
||||
|
||||
const triggerTyping = async () => {
|
||||
await onReplyStart?.();
|
||||
};
|
||||
|
||||
const ensureStart = async () => {
|
||||
if (started) return;
|
||||
started = true;
|
||||
await triggerTyping();
|
||||
};
|
||||
|
||||
const startTypingLoop = async () => {
|
||||
if (!onReplyStart) return;
|
||||
if (typingIntervalMs <= 0) return;
|
||||
if (typingTimer) return;
|
||||
await ensureStart();
|
||||
refreshTypingTtl();
|
||||
typingTimer = setInterval(() => {
|
||||
void triggerTyping();
|
||||
}, typingIntervalMs);
|
||||
};
|
||||
|
||||
const startTypingOnText = async (text?: string) => {
|
||||
const trimmed = text?.trim();
|
||||
if (!trimmed) return;
|
||||
if (silentToken && trimmed === silentToken) return;
|
||||
refreshTypingTtl();
|
||||
await startTypingLoop();
|
||||
};
|
||||
|
||||
return {
|
||||
onReplyStart: ensureStart,
|
||||
startTypingLoop,
|
||||
startTypingOnText,
|
||||
refreshTypingTtl,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user