UNPKG

framework

Version:

The (AI) Framework: turnkey, zero-config AI orchestration that wraps a coding-agent CLI (Claude Code) as a black box and takes you from an idea to a running app. Vite for AI.

84 lines 3.8 kB
/** How often answers are checked for. A chat reply has to feel like a reply. */ const REPLY_POLL_MS = 3_000; /** * How many consecutive unresolvable polls release a binding (#941). Generous on purpose: a * run bound the instant it started has no live meta on disk until its child process boots and * writes one, and dropping the binding during that window would silently unmirror a live chat. * The cost of waiting longer is only how late a dead binding's poll IO stops. */ export const UNBIND_AFTER_MISSES = 10; export function startDiscordReplyMirror(opts) { const bound = new Map(); let stopped = false; let running = false; const bind = async (runId, channelId) => { if (stopped) return; // Adopt the transcript as it stands, so only what is said from here on is mirrored. Read at // bind time on purpose: deferring it to the first poll would swallow a reply that beat the tick. const existing = (await opts.readConversation(runId).catch(() => undefined)) ?? []; bound.set(runId, { channelId, next: existing.length, misses: 0 }); }; const poll = async () => { if (stopped || running) return; running = true; try { // Read once per poll rather than per run: it gates posting, not observing. const on = opts.enabled ? await opts.enabled().catch(() => false) : true; for (const [runId, state] of [...bound]) { if (stopped) break; // A throw is a transient read failure and costs one poll; `undefined` is the run itself // being gone, and enough of those in a row release the binding (#941). const messages = await opts.readConversation(runId).catch(() => []); if (messages === undefined) { state.misses++; if (state.misses >= UNBIND_AFTER_MISSES) { bound.delete(runId); opts.onLog?.(`stopped mirroring ${runId}: its run no longer resolves`); } continue; } state.misses = 0; if (messages.length <= state.next) continue; const fresh = messages.slice(state.next); // Advance first, and unconditionally: a turn we chose not to post (the bot is off, or it // is the user's own message coming back) must not be reconsidered next poll. state.next = messages.length; if (!on) continue; for (const message of fresh) { // Only the agent's side. Echoing the user's own message back at them is noise, and a // turn that arrived *from* Discord is already on their screen. if (message.role !== 'agent') continue; const text = message.text.trim(); if (!text) continue; const delivered = await opts.post(state.channelId, text).catch(() => false); if (!delivered) opts.onLog?.(`could not deliver a reply for ${runId}`); } } } finally { running = false; } }; const timer = setInterval(() => void poll(), opts.intervalMs ?? REPLY_POLL_MS); timer.unref?.(); return { bind, unbind: runId => void bound.delete(runId), isBound: runId => bound.has(runId), poll, stop: () => { stopped = true; bound.clear(); clearInterval(timer); }, }; } //# sourceMappingURL=reply-mirror.js.map