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.
176 lines • 7.86 kB
JavaScript
import { randomUUID } from 'node:crypto';
/** Most transcript entries kept per session, oldest dropped first. */
const MAX_SESSION_EVENTS = 300;
export class BridgeQuestions {
bySession = new Map();
contact;
/**
* Note that something reached the bridge, whatever the outcome.
*
* Failures are recorded too, and that is the point: an extension that is misconfigured looks
* exactly like one that is not installed, because both leave no question behind. A refused
* request at least proves something is trying.
*/
recordContact(route, status) {
this.contact = { at: new Date().toISOString(), route, status };
}
versionState;
/**
* What version the extension last claimed and whether it was refused for it (#1519).
*
* The accepted claims are recorded too, which is what clears a blocked banner: the moment an
* updated extension gets through, the last word is no longer a refusal.
*/
recordVersion(got, expected, blocked) {
this.versionState = { got, expected, blocked, at: new Date().toISOString() };
}
/** The last version claim, or undefined while nothing has stated one. */
version() {
return this.versionState;
}
helloState;
/** What the injected page script last said about itself. */
recordHello(hello) {
this.helloState = hello;
}
/** The page script's last report, or undefined if none has ever arrived. */
hello() {
return this.helloState;
}
/** The last contact, or undefined if nothing has ever reached the bridge. */
lastContact() {
return this.contact;
}
eventsBySession = new Map();
/**
* Record one transcript entry, keyed by its position.
*
* Keyed rather than appended because the page is re-read on every DOM change, so the same
* message arrives repeatedly and a growing list would be mostly duplicates. Position also lets
* a later read replace an earlier one, which is what a message still being streamed needs.
*/
recordEvent(event) {
let bySeq = this.eventsBySession.get(event.sessionId);
if (!bySeq) {
bySeq = new Map();
this.eventsBySession.set(event.sessionId, bySeq);
}
bySeq.set(event.seq, event);
// Bound it: a long session would otherwise grow without limit in a daemon that never restarts.
if (bySeq.size > MAX_SESSION_EVENTS) {
for (const seq of [...bySeq.keys()].sort((a, b) => a - b).slice(0, bySeq.size - MAX_SESSION_EVENTS))
bySeq.delete(seq);
}
}
/** That session's transcript so far, in order. */
events(sessionId) {
return [...(this.eventsBySession.get(sessionId)?.values() ?? [])].sort((a, b) => a.seq - b.seq);
}
answersBySession = new Map();
/** The fingerprint of the question each session's `sent` answer resolved. */
answeredBySession = new Map();
/**
* Record the question a session is parked on, replacing any earlier one for that session.
*
* A question identical to one an answer was already delivered for is dropped: the extension's
* worker forgets what it sent when it restarts, and the answered block stays in the page's DOM,
* so the same question would otherwise resurface as parked right after being answered.
*/
record(question) {
if (this.answeredBySession.get(question.sessionId) === fingerprint(question))
return;
const previous = this.bySession.get(question.sessionId);
// A genuinely new question means the session moved on: an undelivered pick for the old one
// must not be typed into it, and a resolved one no longer says anything about this question.
// Only a re-report of the question currently parked keeps its queued answer alive.
if (!previous || fingerprint(previous) !== fingerprint(question)) {
this.answersBySession.delete(question.sessionId);
this.answeredBySession.delete(question.sessionId);
}
this.bySession.set(question.sessionId, question);
}
/**
* Queue an answer picked in the dashboard (#1237). Refuses anything but a label of the
* question currently parked, so the only text this can ever put in a composer is one the
* session itself offered.
*/
queueAnswer(sessionId, label) {
const question = this.bySession.get(sessionId);
if (!question)
return 'that session has no parked question';
if (!question.options.some(option => option.label === label))
return 'that label is not one of the question options';
const answer = { id: randomUUID(), sessionId, label, queuedAt: new Date().toISOString(), state: 'queued' };
this.answersBySession.set(sessionId, answer);
return answer;
}
/** Withdraw a queued answer. Too late once the extension has delivered it. */
cancelAnswer(sessionId) {
const answer = this.answersBySession.get(sessionId);
if (!answer || answer.state !== 'queued')
return false;
this.answersBySession.delete(sessionId);
return true;
}
/** The answer waiting for the extension to deliver, if any. */
pendingAnswer(sessionId) {
const answer = this.answersBySession.get(sessionId);
return answer?.state === 'queued' ? answer : undefined;
}
/** The session's answer in whatever state, for the dashboard to render. */
answer(sessionId) {
return this.answersBySession.get(sessionId);
}
/**
* The extension's word on what happened to a delivery. On success the question is resolved:
* it is dropped, and re-reports of the same block are ignored (see {@link record}).
*/
resolveAnswer(sessionId, id, ok, note) {
const answer = this.answersBySession.get(sessionId);
if (!answer || answer.id !== id || answer.state !== 'queued')
return;
this.answersBySession.set(sessionId, { ...answer, state: ok ? 'sent' : 'failed', ...(note ? { note } : {}) });
if (!ok)
return;
const question = this.bySession.get(sessionId);
if (question)
this.answeredBySession.set(sessionId, fingerprint(question));
this.bySession.delete(sessionId);
}
/** The question that session is parked on, if the bridge has reported one. */
get(sessionId) {
return this.bySession.get(sessionId);
}
/** Every parked question, newest first. */
list() {
return [...this.bySession.values()].sort((a, b) => b.receivedAt.localeCompare(a.receivedAt));
}
/** Drop a session's question, once it is answered or its agent is gone. */
clear(sessionId) {
this.bySession.delete(sessionId);
this.eventsBySession.delete(sessionId);
this.answersBySession.delete(sessionId);
this.answeredBySession.delete(sessionId);
}
}
/** What makes two reports the same question: the text shown, not when it arrived. */
function fingerprint(question) {
return JSON.stringify([question.title, question.options, question.recommended ?? null]);
}
/**
* The daemon's one store. A module singleton rather than a {@link DashboardContext} field
* because both ends live in the same process but reach it by different routes: the bridge
* endpoint writes from the raw HTTP handler, and the dashboard reads from an RPC, which is
* handed only what the wired context carries.
*/
let instance;
export function bridgeQuestions() {
if (!instance)
instance = new BridgeQuestions();
return instance;
}
/** Replace the store. Tests only: a module singleton would otherwise leak between them. */
export function resetBridgeQuestions() {
instance = undefined;
}
//# sourceMappingURL=bridge-store.js.map