export type ReplyKind = "text-reply" | "voice-generation" | "voice-tool" | "sub-agent";
export interface ActiveReply { id: string; scopeKey: string; kind: ReplyKind; abortController: AbortController; startedAt: number; toolNames: string[]; }
let activeReplyCounter = 0; let lastRegistryTimestamp = 0;
function nextRegistryTimestamp() { const now = Date.now(); lastRegistryTimestamp = Math.max(lastRegistryTimestamp + 1, now); return lastRegistryTimestamp; }
function normalizeScopeKey(scopeKey: string) { const normalized = String(scopeKey || "").trim(); if (!normalized) { throw new Error("missing_active_reply_scope_key"); } return normalized; }
export function buildTextReplyScopeKey({
guildId,
channelId
}: {
guildId?: string | null;
channelId?: string | null;
}) {
const normalizedGuildId = String(guildId || "dm").trim() || "dm";
const normalizedChannelId = String(channelId || "dm").trim() || "dm";
return text:${normalizedGuildId}:${normalizedChannelId};
}
export function buildVoiceReplyScopeKey(sessionId: string | null | undefined) {
const normalizedSessionId = String(sessionId || "").trim();
if (!normalizedSessionId) {
throw new Error("missing_voice_reply_scope_key");
}
return voice:${normalizedSessionId};
}
export class ActiveReplyRegistry { private readonly repliesByScope = new Map<string, Set>(); private readonly abortCutoffs = new Map<string, number>();
reserveTimestamp() { return nextRegistryTimestamp(); }
begin(scopeKey: string, kind: ReplyKind, toolNames: string[] = []): ActiveReply {
const normalizedScopeKey = normalizeScopeKey(scopeKey);
const startedAt = this.reserveTimestamp();
activeReplyCounter += 1;
const reply: ActiveReply = {
id: ${normalizedScopeKey}:${startedAt}:${activeReplyCounter},
scopeKey: normalizedScopeKey,
kind,
abortController: new AbortController(),
startedAt,
toolNames: Array.isArray(toolNames)
? toolNames.map((entry) => String(entry || "").trim()).filter(Boolean)
: []
};
const existingReplies = this.repliesByScope.get(normalizedScopeKey) || new Set();
existingReplies.add(reply);
this.repliesByScope.set(normalizedScopeKey, existingReplies);
return reply;
}
abortAll(scopeKey: string, reason = "Reply cancelled by user") { const normalizedScopeKey = normalizeScopeKey(scopeKey); const replies = this.repliesByScope.get(normalizedScopeKey); if (!replies?.size) return 0;
let abortedCount = 0;
for (const reply of replies) {
abortedCount += 1;
try {
reply.abortController.abort(reason);
} catch {
// ignore
}
}
this.abortCutoffs.set(normalizedScopeKey, nextRegistryTimestamp());
this.repliesByScope.delete(normalizedScopeKey);
return abortedCount;
}
clear(reply: ActiveReply | null | undefined): void { if (!reply) return; const replies = this.repliesByScope.get(reply.scopeKey); if (!replies) return; replies.delete(reply); if (replies.size <= 0) { this.repliesByScope.delete(reply.scopeKey); } }
has(scopeKey: string) { const normalizedScopeKey = normalizeScopeKey(scopeKey); return Boolean(this.repliesByScope.get(normalizedScopeKey)?.size); }
isStale(scopeKey: string, startedAt: number) { const normalizedScopeKey = normalizeScopeKey(scopeKey); const cutoff = this.abortCutoffs.get(normalizedScopeKey); if (!cutoff) return false; const normalizedStartedAt = Math.max(0, Number(startedAt || 0)); if (!normalizedStartedAt) return false; return normalizedStartedAt < cutoff; }
/** Clear the abort cutoff so new work queued after an abortAll is not stale. */ clearAbortCutoff(scopeKey: string) { const normalizedScopeKey = normalizeScopeKey(scopeKey); this.abortCutoffs.delete(normalizedScopeKey); } }
