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
JavaScript
/** 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