autopreso 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,137 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { readCodexCliAuthSync } from "./codex-auth.js";
5
+
6
+ export const DEFAULT_SETTINGS = Object.freeze({
7
+ agent: {
8
+ provider: "openai",
9
+ openai: { model: "gpt-5.5", reasoningEffort: "low" },
10
+ codex: { model: "gpt-5.5", baseURL: "https://chatgpt.com/backend-api/codex" },
11
+ ollama: { model: "", baseURL: "http://localhost:11434/v1" },
12
+ },
13
+ transcription: {
14
+ provider: "moonshine",
15
+ moonshine: { model: "medium" },
16
+ openai: { model: "gpt-realtime-whisper" },
17
+ },
18
+ apiKeys: {
19
+ openai: "",
20
+ },
21
+ });
22
+
23
+ export function createSettingsStore({ filePath, env = process.env, readCodexAuth = readCodexCliAuthSync }) {
24
+ let cached = null;
25
+
26
+ async function readFromDisk() {
27
+ try {
28
+ const raw = await fs.readFile(filePath, "utf8");
29
+ return deepMerge(cloneDefaults(), JSON.parse(raw));
30
+ } catch (error) {
31
+ if (error.code === "ENOENT") return null;
32
+ throw error;
33
+ }
34
+ }
35
+
36
+ async function writeToDisk(settings) {
37
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
38
+ await fs.writeFile(filePath, JSON.stringify(settings, null, 2), { mode: 0o600 });
39
+ try {
40
+ await fs.chmod(filePath, 0o600);
41
+ } catch {}
42
+ }
43
+
44
+ async function load() {
45
+ if (cached) return cached;
46
+ const fromDisk = await readFromDisk();
47
+ if (fromDisk) {
48
+ cached = fromDisk;
49
+ return cached;
50
+ }
51
+ const seeded = seedFromEnv(cloneDefaults(), env, readCodexAuth);
52
+ await writeToDisk(seeded);
53
+ cached = seeded;
54
+ return cached;
55
+ }
56
+
57
+ async function save(partial) {
58
+ if (!cached) await load();
59
+ cached = deepMerge(cached, partial);
60
+ await writeToDisk(cached);
61
+ return cached;
62
+ }
63
+
64
+ async function getSanitized() {
65
+ const settings = await load();
66
+ const { apiKeys, ...rest } = settings;
67
+ return {
68
+ ...rest,
69
+ hasOpenAIKey: Boolean(apiKeys?.openai),
70
+ };
71
+ }
72
+
73
+ return { load, save, getSanitized };
74
+ }
75
+
76
+ function cloneDefaults() {
77
+ return JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
78
+ }
79
+
80
+ function deepMerge(target, source) {
81
+ if (!source || typeof source !== "object") return target;
82
+ const result = Array.isArray(target) ? [...target] : { ...target };
83
+ for (const [key, value] of Object.entries(source)) {
84
+ if (value && typeof value === "object" && !Array.isArray(value)) {
85
+ result[key] = deepMerge(result[key] ?? {}, value);
86
+ } else if (value !== undefined) {
87
+ result[key] = value;
88
+ }
89
+ }
90
+ return result;
91
+ }
92
+
93
+ function seedFromEnv(settings, env, readCodexAuth) {
94
+ const next = settings;
95
+ const openaiKey = trimOrEmpty(env.OPENAI_API_KEY);
96
+ if (openaiKey) next.apiKeys.openai = openaiKey;
97
+
98
+ const openaiModel = trimOrEmpty(env.OPENAI_MODEL);
99
+ if (openaiModel) next.agent.openai.model = openaiModel;
100
+
101
+ const reasoningEffort = trimOrEmpty(env.OPENAI_REASONING_EFFORT);
102
+ if (reasoningEffort) next.agent.openai.reasoningEffort = reasoningEffort;
103
+
104
+ const codexModel = trimOrEmpty(env.CODEX_MODEL);
105
+ if (codexModel) next.agent.codex.model = codexModel;
106
+
107
+ const codexBaseURL = trimOrEmpty(env.CODEX_BASE_URL);
108
+ if (codexBaseURL) next.agent.codex.baseURL = codexBaseURL;
109
+
110
+ const ollamaModel = trimOrEmpty(env.OLLAMA_MODEL);
111
+ if (ollamaModel) next.agent.ollama.model = ollamaModel;
112
+
113
+ const ollamaBaseURL = trimOrEmpty(env.OLLAMA_BASE_URL);
114
+ if (ollamaBaseURL) next.agent.ollama.baseURL = ollamaBaseURL;
115
+
116
+ const codexAuth = safeReadCodexAuth(readCodexAuth, env);
117
+ if (codexAuth) next.agent.provider = "codex";
118
+ else if (ollamaModel) next.agent.provider = "ollama";
119
+ else next.agent.provider = "openai";
120
+
121
+ if (openaiKey) next.transcription.provider = "openai";
122
+
123
+ return next;
124
+ }
125
+
126
+ function safeReadCodexAuth(readCodexAuth, env) {
127
+ try {
128
+ return readCodexAuth(env);
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ function trimOrEmpty(value) {
135
+ if (typeof value !== "string") return "";
136
+ return value.trim();
137
+ }
@@ -0,0 +1,24 @@
1
+ import { DEFAULT_CODEX_BASE_URL } from "./codex-auth.js";
2
+ import { resolveAgentProviderFromSettings } from "./agent-provider.js";
3
+
4
+ export function resolveSimulatorAgentProvider(env = process.env) {
5
+ const requested = env.CODEX_MODEL?.trim() || env.OPENAI_MODEL?.trim() || "gpt-5.5";
6
+ const model = stripFastMode(requested);
7
+ return resolveAgentProviderFromSettings({
8
+ settings: {
9
+ agent: {
10
+ provider: "codex",
11
+ openai: { model: "gpt-5.5", reasoningEffort: "low" },
12
+ codex: { model, baseURL: DEFAULT_CODEX_BASE_URL },
13
+ ollama: { model: "", baseURL: "" },
14
+ },
15
+ apiKeys: { openai: "" },
16
+ },
17
+ env,
18
+ });
19
+ }
20
+
21
+ function stripFastMode(model) {
22
+ if (model.endsWith("-fast")) return model.slice(0, -"-fast".length);
23
+ return model;
24
+ }
@@ -0,0 +1,76 @@
1
+ const DEFAULT_CHROME_BIN = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
2
+
3
+ export function parseSimulatorArgs(args, env = process.env) {
4
+ const options = {
5
+ host: "127.0.0.1",
6
+ port: 0,
7
+ chunkIntervalMs: 500,
8
+ speakingWordsPerMinute: 160,
9
+ chromeBin: env.CHROME_BIN ?? DEFAULT_CHROME_BIN,
10
+ agentTimeoutMs: 90_000,
11
+ help: false,
12
+ };
13
+
14
+ for (let index = 0; index < args.length; index += 1) {
15
+ const arg = args[index];
16
+ if (arg === "--help" || arg === "-h") {
17
+ options.help = true;
18
+ return options;
19
+ }
20
+ if (arg === "--transcript") {
21
+ options.transcriptPath = readValue(args, ++index, arg);
22
+ continue;
23
+ }
24
+ if (arg === "--out") {
25
+ options.outDir = readValue(args, ++index, arg);
26
+ continue;
27
+ }
28
+ if (arg === "--chrome-bin") {
29
+ options.chromeBin = readValue(args, ++index, arg);
30
+ continue;
31
+ }
32
+ if (arg === "--chunk-interval-ms") {
33
+ options.chunkIntervalMs = parseNonNegativeInteger(readValue(args, ++index, arg), arg);
34
+ continue;
35
+ }
36
+ if (arg === "--speaking-words-per-minute") {
37
+ options.speakingWordsPerMinute = parsePositiveInteger(readValue(args, ++index, arg), arg);
38
+ continue;
39
+ }
40
+ if (arg === "--agent-timeout-ms") {
41
+ options.agentTimeoutMs = parsePositiveInteger(readValue(args, ++index, arg), arg);
42
+ continue;
43
+ }
44
+ if (arg === "--host") {
45
+ options.host = readValue(args, ++index, arg);
46
+ continue;
47
+ }
48
+ if (arg === "--port") {
49
+ options.port = parseNonNegativeInteger(readValue(args, ++index, arg), arg);
50
+ continue;
51
+ }
52
+ throw new Error(`Unknown option: ${arg}`);
53
+ }
54
+
55
+ if (!options.transcriptPath) throw new Error("--transcript is required");
56
+ if (!options.outDir) throw new Error("--out is required");
57
+ return options;
58
+ }
59
+
60
+ function readValue(args, index, flag) {
61
+ const value = args[index];
62
+ if (!value || value.startsWith("--")) throw new Error(`${flag} requires a value`);
63
+ return value;
64
+ }
65
+
66
+ function parseNonNegativeInteger(value, flag) {
67
+ const parsed = Number(value);
68
+ if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`${flag} must be a non-negative integer`);
69
+ return parsed;
70
+ }
71
+
72
+ function parsePositiveInteger(value, flag) {
73
+ const parsed = Number(value);
74
+ if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${flag} must be a positive integer`);
75
+ return parsed;
76
+ }
@@ -0,0 +1,22 @@
1
+ const DEFAULT_PUNCTUATION = new Set([".", "!", "?", ";", ":", ","]);
2
+
3
+ export function chunkTranscriptAtPunctuation(transcript, punctuation = DEFAULT_PUNCTUATION) {
4
+ const chunks = [];
5
+ let current = "";
6
+
7
+ for (const char of String(transcript ?? "")) {
8
+ current += char;
9
+ if (punctuation.has(char)) {
10
+ pushChunk(chunks, current);
11
+ current = "";
12
+ }
13
+ }
14
+
15
+ pushChunk(chunks, current);
16
+ return chunks;
17
+ }
18
+
19
+ function pushChunk(chunks, text) {
20
+ const trimmed = text.trim();
21
+ if (trimmed) chunks.push(trimmed);
22
+ }
@@ -0,0 +1,78 @@
1
+ export function createTranscriptTurnQueue({ runTurn, debounceMs = 150, isReady = () => true }) {
2
+ let running = false;
3
+ let buffered = [];
4
+ let current = Promise.resolve();
5
+ // Pending bucket holds chunks that arrived too recently to fire yet. Waiting
6
+ // a short window lets bursts of small transcript chunks coalesce into one
7
+ // turn. The isReady predicate gates whether the accumulated buffer has
8
+ // enough substantive content to actually fire - if not, we keep accumulating
9
+ // until the next chunk arrives.
10
+ let pending = [];
11
+ let debounceTimer = null;
12
+
13
+ function flushPending({ force = false } = {}) {
14
+ debounceTimer = null;
15
+ if (pending.length === 0) return;
16
+ const text = pending.join("\n");
17
+ if (!force && !isReady(text)) {
18
+ // Not enough content yet - keep pending, wait for more chunks. The next
19
+ // enqueue will restart the debounce timer and we'll re-check then.
20
+ return;
21
+ }
22
+ pending = [];
23
+ if (running) {
24
+ buffered.push(text);
25
+ } else {
26
+ current = drain(text);
27
+ }
28
+ }
29
+
30
+ async function drain(text) {
31
+ running = true;
32
+ try {
33
+ await runTurn(text);
34
+ } finally {
35
+ if (buffered.length > 0) {
36
+ const next = buffered.join("\n");
37
+ buffered = [];
38
+ current = drain(next);
39
+ } else {
40
+ running = false;
41
+ // If pending arrived during the turn and is now ready, flush it. If
42
+ // it's still not ready (only fillers), leave it accumulating.
43
+ if (pending.length > 0) {
44
+ if (debounceTimer) clearTimeout(debounceTimer);
45
+ flushPending();
46
+ }
47
+ }
48
+ }
49
+ }
50
+
51
+ function enqueue(text) {
52
+ const trimmed = text.trim();
53
+ if (!trimmed) return current;
54
+ pending.push(trimmed);
55
+ if (debounceMs > 0) {
56
+ if (debounceTimer) clearTimeout(debounceTimer);
57
+ debounceTimer = setTimeout(flushPending, debounceMs);
58
+ } else {
59
+ flushPending();
60
+ }
61
+ return current;
62
+ }
63
+
64
+ async function idle() {
65
+ // Force-flush any pending content (bypassing isReady) so idle() always
66
+ // terminates - tests and shutdown paths shouldn't hang on a buffer that
67
+ // happens to contain only fillers.
68
+ while (debounceTimer || running || buffered.length > 0 || pending.length > 0) {
69
+ if (debounceTimer || pending.length > 0) {
70
+ if (debounceTimer) clearTimeout(debounceTimer);
71
+ flushPending({ force: true });
72
+ }
73
+ await current;
74
+ }
75
+ }
76
+
77
+ return { enqueue, idle };
78
+ }
@@ -0,0 +1,74 @@
1
+ export function normalizeWhiteboardElements(elements) {
2
+ if (!Array.isArray(elements)) return [];
3
+
4
+ return elements;
5
+ }
6
+
7
+ const SHAPE_TYPES = new Set(["rectangle", "ellipse", "diamond"]);
8
+ const CHAR_WIDTH_RATIO = 0.6;
9
+ const PADDING_PER_SIDE = 24;
10
+
11
+ function rectanglesOverlap(a, b) {
12
+ return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
13
+ }
14
+
15
+ function estimateTextBox(text, fontSize) {
16
+ const fs = fontSize ?? 18;
17
+ const lines = String(text ?? "").split("\n");
18
+ const longest = lines.reduce((max, line) => Math.max(max, line.length), 0);
19
+ return {
20
+ width: Math.ceil(longest * fs * CHAR_WIDTH_RATIO),
21
+ height: Math.ceil(lines.length * fs * 1.25),
22
+ };
23
+ }
24
+
25
+ export function detectMalformedLayoutWarnings(elements) {
26
+ if (!Array.isArray(elements) || elements.length === 0) return [];
27
+ const warnings = [];
28
+ const shapes = elements.filter((el) => SHAPE_TYPES.has(el?.type));
29
+ const texts = elements.filter((el) => el?.type === "text");
30
+
31
+ // 1. Standalone text overlapping a shape -> should have been a label.
32
+ for (const text of texts) {
33
+ if (typeof text.x !== "number" || typeof text.y !== "number") continue;
34
+ const estimated = estimateTextBox(text.text, text.fontSize);
35
+ const textBox = {
36
+ x: text.x,
37
+ y: text.y,
38
+ width: typeof text.width === "number" ? text.width : estimated.width,
39
+ height: typeof text.height === "number" ? text.height : estimated.height,
40
+ };
41
+ for (const shape of shapes) {
42
+ if (typeof shape.width !== "number" || typeof shape.height !== "number") continue;
43
+ if (rectanglesOverlap(textBox, shape)) {
44
+ const preview = (text.text ?? "").slice(0, 40);
45
+ warnings.push(
46
+ `LAYOUT WARNING: standalone text "${preview}" (id "${text.id}") overlaps shape "${shape.id}". Excalidraw renders standalone text by your literal coordinates, so it bleeds outside the shape. Replace the text element with a label on the shape: { "type": "${shape.type}", "id": "${shape.id}", ..., "label": { "text": "${preview}", "fontSize": ${text.fontSize ?? 18} } }. Then Excalidraw will center it inside the shape and wrap correctly.`,
47
+ );
48
+ break;
49
+ }
50
+ }
51
+ }
52
+
53
+ // 2. Labeled shape too narrow / short for its label.
54
+ for (const shape of shapes) {
55
+ const labelText = shape?.label?.text;
56
+ if (typeof labelText !== "string" || labelText.length === 0) continue;
57
+ const fontSize = shape.label.fontSize ?? 18;
58
+ const estimated = estimateTextBox(labelText, fontSize);
59
+ const minWidth = estimated.width + PADDING_PER_SIDE * 2;
60
+ const minHeight = estimated.height + PADDING_PER_SIDE * 2;
61
+ if (typeof shape.width === "number" && shape.width < minWidth) {
62
+ warnings.push(
63
+ `LAYOUT WARNING: shape "${shape.id}" is ${shape.width}px wide but its label "${labelText.slice(0, 40)}" needs about ${minWidth}px (text + padding). Either widen the shape or shorten the label - otherwise the label text will overflow the shape's edges.`,
64
+ );
65
+ }
66
+ if (typeof shape.height === "number" && shape.height < minHeight) {
67
+ warnings.push(
68
+ `LAYOUT WARNING: shape "${shape.id}" is ${shape.height}px tall but its label "${labelText.slice(0, 40)}" needs about ${minHeight}px. Either grow the shape or shorten the label.`,
69
+ );
70
+ }
71
+ }
72
+
73
+ return warnings;
74
+ }
@@ -0,0 +1,235 @@
1
+ import { WebSocket } from "ws";
2
+
3
+ import { createTranscriptTurnQueue } from "./transcript-turn-queue.js";
4
+
5
+ const FILLER_WORDS = new Set([
6
+ "uh", "uhh", "uhhh", "um", "umm", "ummm", "ah", "ahh", "er", "erm",
7
+ "hmm", "hm", "huh", "mm", "mhm",
8
+ "yeah", "yep", "yup", "yes", "ok", "okay", "right", "alright",
9
+ "so", "well", "like",
10
+ ]);
11
+
12
+ export function isTrivialTranscript(text) {
13
+ if (typeof text !== "string") return true;
14
+ const trimmed = text.trim();
15
+ if (trimmed.length === 0) return true;
16
+ // Strip common punctuation and lowercase, then split into words.
17
+ const cleaned = trimmed.replace(/[.,!?;:'"()\-]/g, " ").toLowerCase();
18
+ const words = cleaned.split(/\s+/).filter((w) => w.length > 0);
19
+ if (words.length === 0) return true;
20
+ // Single word: skip if it's filler or very short (1-2 chars).
21
+ if (words.length === 1) {
22
+ return FILLER_WORDS.has(words[0]) || words[0].length <= 2;
23
+ }
24
+ // 2-3 words and ALL are filler: skip ("you know", "uh well").
25
+ if (words.length <= 3 && words.every((w) => FILLER_WORDS.has(w))) return true;
26
+ return false;
27
+ }
28
+
29
+ // Default backoff between warmup attempts (after attempt N completes, wait
30
+ // delays[N-1] ms before the next). Total budget with 8 attempts: ~120s.
31
+ const DEFAULT_WARMUP_DELAYS = [2000, 4000, 8000, 16000, 30000, 30000, 30000];
32
+ const DEFAULT_WARMUP_MAX_ATTEMPTS = 8;
33
+
34
+ export function createWhiteboardSession({ options, wss, runAgent }) {
35
+ const state = {
36
+ mode: "staging",
37
+ elements: seedElements(),
38
+ agentHistory: [],
39
+ agentStatus: "idle",
40
+ agentBusy: false,
41
+ warmupBusy: false,
42
+ latestScreenshot: undefined,
43
+ warmupPromise: Promise.resolve(),
44
+ // Snapshot of the warmup loop state, also broadcast to clients via WS.
45
+ warmupState: { state: "idle", attempt: 0, maxAttempts: DEFAULT_WARMUP_MAX_ATTEMPTS },
46
+ // True iff the canvas was edited since the last time a screenshot was
47
+ // sent to the agent. We only attach the live screenshot when this is true
48
+ // (saves ~7-10k tokens per turn on DONE-only turns when nothing changed).
49
+ canvasDirtyForAgent: false,
50
+ };
51
+
52
+ let warmupCancelled = false;
53
+ let warmupRunning = false;
54
+
55
+ function publishAgentStatus() {
56
+ const status = (state.agentBusy || state.warmupBusy) ? "thinking" : "idle";
57
+ if (state.agentStatus === status) return;
58
+ state.agentStatus = status;
59
+ broadcast(wss, { type: "agent:status", status });
60
+ }
61
+
62
+ const queue = createTranscriptTurnQueue({
63
+ // A turn is "ready" only when the accumulated buffer has at least one
64
+ // substantive (non-filler) word. Pure fillers ("uh", "uh um") keep
65
+ // accumulating until the speaker says something real, then fire as one
66
+ // combined turn ("uh\num\nOpenAI just released...").
67
+ isReady: (text) => !isTrivialTranscript(text),
68
+ runTurn: async (transcript) => {
69
+ if (state.mode !== "live") return;
70
+ // Wait for any in-flight prompt-cache warmup so the cache is primed
71
+ // before we send the first real transcript turn through.
72
+ try { await state.warmupPromise; } catch { /* warmup errors are logged elsewhere */ }
73
+ if (state.mode !== "live") return;
74
+ state.agentBusy = true;
75
+ publishAgentStatus();
76
+ options.onAgentEvent?.({ type: "turn:start", transcript, timestamp: new Date().toISOString() });
77
+ try {
78
+ await runAgent({ transcript, state, wss, options });
79
+ options.onAgentEvent?.({ type: "turn:end", transcript, timestamp: new Date().toISOString() });
80
+ } catch (error) {
81
+ console.error("Whiteboard agent failed:", error);
82
+ broadcast(wss, { type: "error", message: `Whiteboard agent failed: ${error.message}` });
83
+ options.onAgentEvent?.({ type: "turn:error", transcript, error: error.message, timestamp: new Date().toISOString() });
84
+ } finally {
85
+ state.agentBusy = false;
86
+ publishAgentStatus();
87
+ }
88
+ },
89
+ });
90
+
91
+ state.queueTranscript = (text) => queue.enqueue(text);
92
+ state.idle = () => queue.idle();
93
+ state.updateLatestScreenshot = (image) => {
94
+ state.latestScreenshot = image;
95
+ // A fresh screenshot means the canvas changed (either the agent just
96
+ // edited or the user drew something pre-listening). Mark dirty so the
97
+ // next agent turn includes it.
98
+ state.canvasDirtyForAgent = true;
99
+ };
100
+ state.reset = () => {
101
+ state.elements = seedElements();
102
+ state.agentHistory = [];
103
+ state.latestScreenshot = undefined;
104
+ };
105
+ state.startPreso = ({ primerMessage }) => {
106
+ state.mode = "live";
107
+ state.elements = seedElements();
108
+ state.latestScreenshot = undefined;
109
+ state.agentHistory = [primerMessage];
110
+ state.warmupPromise = Promise.resolve();
111
+ state.canvasDirtyForAgent = false;
112
+ // Reset warmup state for this preso. The startWarmupLoop call that follows
113
+ // will publish the first "running" broadcast.
114
+ state.warmupState = { state: "idle", attempt: 0, maxAttempts: DEFAULT_WARMUP_MAX_ATTEMPTS };
115
+ };
116
+ function publishWarmupState(next) {
117
+ state.warmupState = { ...state.warmupState, ...next };
118
+ broadcast(wss, { type: "warmup", ...state.warmupState });
119
+ }
120
+
121
+ state.startWarmupLoop = ({
122
+ runOnce,
123
+ delays = DEFAULT_WARMUP_DELAYS,
124
+ maxAttempts = DEFAULT_WARMUP_MAX_ATTEMPTS,
125
+ primingMessages = null,
126
+ }) => {
127
+ // Ignore overlapping calls. The previous loop must finish (or be cancelled)
128
+ // before a new one starts, otherwise multiple loops would race for cache
129
+ // confirmation on the same session.
130
+ if (warmupRunning) return state.warmupPromise;
131
+
132
+ warmupRunning = true;
133
+ warmupCancelled = false;
134
+ state.warmupBusy = true;
135
+ publishAgentStatus();
136
+
137
+ const promise = (async () => {
138
+ try {
139
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
140
+ if (warmupCancelled) {
141
+ publishWarmupState({ state: "cancelled", attempt: attempt - 1, maxAttempts });
142
+ return;
143
+ }
144
+ publishWarmupState({ state: "running", attempt, maxAttempts });
145
+ let cached = 0;
146
+ let input = 0;
147
+ try {
148
+ const result = await runOnce({ attempt, maxAttempts });
149
+ cached = Number(result?.usage?.cached) || 0;
150
+ input = Number(result?.usage?.input) || 0;
151
+ } catch (error) {
152
+ // Swallow per-attempt errors; loop should still progress to the
153
+ // next attempt. The actual error is logged by the caller.
154
+ cached = 0;
155
+ input = 0;
156
+ }
157
+ if (warmupCancelled) {
158
+ publishWarmupState({ state: "cancelled", attempt, maxAttempts });
159
+ return;
160
+ }
161
+ // Require >=50% of the prefix to be cached. A small `cached` value
162
+ // (e.g., the static system+tools chunk only) doesn't mean the primer
163
+ // and warmup_user prefix are primed, so keep retrying.
164
+ if (input > 0 && cached >= input * 0.5) {
165
+ publishWarmupState({ state: "confirmed", attempt, maxAttempts });
166
+ return;
167
+ }
168
+ if (attempt >= maxAttempts) {
169
+ publishWarmupState({ state: "exhausted", attempt, maxAttempts });
170
+ return;
171
+ }
172
+ // Wait before the next attempt, but bail early if cancelled mid-sleep.
173
+ const delay = delays[attempt - 1] ?? delays.at(-1) ?? 0;
174
+ await sleepCancellable(delay, () => warmupCancelled);
175
+ }
176
+ } finally {
177
+ // Append the priming pair AFTER all warmup attempts finish (regardless
178
+ // of confirmed/exhausted/cancelled). This makes every subsequent turn's
179
+ // request prefix start with the EXACT bytes warmup just wrote to cache:
180
+ // warmup wrote: [primer, warmup_user_msg]
181
+ // turn sends: [primer, warmup_user_msg, assistant("UNDERSTOOD"), transcript, currentBoard]
182
+ // Without this, turn 1 diverges from warmup at messages[1] and never
183
+ // hits the cache that warmup just primed.
184
+ if (Array.isArray(primingMessages) && primingMessages.length > 0) {
185
+ state.agentHistory = [...state.agentHistory, ...primingMessages];
186
+ }
187
+ warmupRunning = false;
188
+ state.warmupBusy = false;
189
+ publishAgentStatus();
190
+ }
191
+ })();
192
+
193
+ state.warmupPromise = promise;
194
+ return promise;
195
+ };
196
+
197
+ state.cancelWarmup = () => {
198
+ if (!warmupRunning) return;
199
+ warmupCancelled = true;
200
+ };
201
+
202
+ state.backToStaging = () => {
203
+ state.mode = "staging";
204
+ state.cancelWarmup();
205
+ };
206
+ return state;
207
+ }
208
+
209
+ function sleepCancellable(ms, isCancelled) {
210
+ if (ms <= 0) return Promise.resolve();
211
+ return new Promise((resolve) => {
212
+ const start = Date.now();
213
+ const tick = () => {
214
+ if (isCancelled() || Date.now() - start >= ms) return resolve();
215
+ setTimeout(tick, Math.min(50, ms));
216
+ };
217
+ tick();
218
+ });
219
+ }
220
+
221
+ export function broadcast(wss, message) {
222
+ const serialized = JSON.stringify(message);
223
+ for (const client of wss.clients) {
224
+ if (client.readyState === WebSocket.OPEN) {
225
+ client.send(serialized);
226
+ }
227
+ }
228
+ }
229
+
230
+ function seedElements() {
231
+ // Live canvas starts blank. The user may draw on it before clicking Start
232
+ // listening; those edits are pushed to the server via whiteboard:user-elements
233
+ // and will be in state.elements by the first transcript turn.
234
+ return [];
235
+ }