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.

176 lines 7.86 kB
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