@cairnvibe/sdk 0.2.13 → 0.3.0

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.
package/src/index.tsx CHANGED
@@ -16,12 +16,14 @@ import {
16
16
  VolumeX,
17
17
  X,
18
18
  } from "lucide-react";
19
- import { TERMINAL_VERBS, safeParseVerbResponse, type HistoryTurn as HistoryEntry, type TourStep } from "@cairnvibe/core";
19
+ import { classifyUiPattern, deriveStructureSignals, isTerminalVerb, safeParseVerbResponse, type CriticVerdict, type HistoryTurn as HistoryEntry, type Plan, type ProgressLedger, type Task, type TourStep, type VerbResponse } from "@cairnvibe/core";
20
+ import { driveAgentLoop, looksMultiStep } from "./agent-loop";
20
21
  import { collectVisible } from "./context-collector";
21
22
  import { findElement, highlightElement, logMiss, type MissContext } from "./element-ladder";
22
23
  import { createLiveElementRegistry } from "./runtime-scan";
23
24
  import { discoverWebMcpTools } from "./webmcp-client";
24
25
  import { executeToolStep, executeVerbResponse } from "./verb-executor";
26
+ import { createBargeInGate, createVadDetector } from "./vad";
25
27
 
26
28
  export interface CopilotProps {
27
29
  /** Reserved for a future client-side manifest fetch. Not required — the server handler owns the manifest. */
@@ -42,6 +44,45 @@ export interface CopilotProps {
42
44
  transcribeEndpoint?: string;
43
45
  /** If set, the widget speaks each explain/highlight answer aloud (Deepgram TTS via `@cairnvibe/sdk/speak-server`). */
44
46
  speakEndpoint?: string;
47
+ /**
48
+ * Phase 5 step 4 — whatever opaque id the CUSTOMER's own app already
49
+ * has for this end user (their own login id, or any other stable
50
+ * string they choose) — this SDK invents no identity of its own, same
51
+ * discipline as the realtime relay's own "context" message scopeId.
52
+ * Sent on every typed-transport request when set; the server only ever
53
+ * seeds/records real cross-session memory when BOTH this and a
54
+ * `memory` store are configured (`createCopilotHandler`'s own
55
+ * `memory` option) — omitting this keeps every request exactly as
56
+ * memory-less as before this existed, regardless of server config.
57
+ */
58
+ scopeId?: string;
59
+ /**
60
+ * Architecture Pillar 4 — if set (alongside `criticEndpoint`), the typed/
61
+ * HTTP loop gets the same real Planner the realtime relay already has:
62
+ * a task breakdown for a compound goal, and a genuinely separate Critic
63
+ * pass over each continuing step's real result (packages/sdk/src/
64
+ * server.ts's `createPlanHandler`/`createCriticHandler`). Omitting
65
+ * either endpoint keeps the typed loop exactly as it was — click/fill/
66
+ * read/call_tool executed and folded into history, ended by a terminal
67
+ * verb or the iteration cap — with zero Planner/Critic overhead, same
68
+ * opt-in discipline as speakEndpoint/transcribeEndpoint.
69
+ */
70
+ planEndpoint?: string;
71
+ /** See `planEndpoint` — both must be set for the typed loop's Planner/Critic wiring to activate. */
72
+ criticEndpoint?: string;
73
+ /**
74
+ * Architecture Pillar 3 (Skill half) — if set (alongside `planEndpoint`/
75
+ * `criticEndpoint`), the typed loop saves whatever real, Critic-
76
+ * verified facts a turn collects (`packages/sdk/src/server.ts`'s
77
+ * `createSkillSaveHandler`) once the turn concludes — the same
78
+ * Formulator mechanism the realtime relay already has. Retrieval (a
79
+ * matching Skill's full instructions surfacing to the Planner) needs no
80
+ * separate client wiring — it's already part of what `planEndpoint`'s
81
+ * own server-side handler does once a `SkillStore` is configured there.
82
+ * Omitting this keeps the typed loop exactly as it was — no Skills are
83
+ * ever saved, zero overhead.
84
+ */
85
+ skillsSaveEndpoint?: string;
45
86
  /**
46
87
  * If set, shows a "start conversation" control that opens a live
47
88
  * WebSocket to a `@cairnvibe/sdk/realtime-server` relay (run via
@@ -63,6 +104,10 @@ export function Copilot({
63
104
  reportMissesEndpoint,
64
105
  transcribeEndpoint,
65
106
  speakEndpoint,
107
+ scopeId,
108
+ planEndpoint,
109
+ criticEndpoint,
110
+ skillsSaveEndpoint,
66
111
  realtimeUrl,
67
112
  persona = "Cairn",
68
113
  }: CopilotProps) {
@@ -79,6 +124,13 @@ export function Copilot({
79
124
  // eslint-disable-next-line react-hooks/exhaustive-deps
80
125
  }, [pathname]);
81
126
  const router = useRouter();
127
+ // Starts at the same safe default on both server and client's first
128
+ // render — same hydration-mismatch reason `micSupported` below does this
129
+ // — then, once actually restored from sessionStorage post-mount (see the
130
+ // dedicated restore effect further down), flips to whatever a real page
131
+ // reload (a host app's own mutation handler, e.g. — see
132
+ // loadPersistedConversation's own doc comment) had showing a moment ago,
133
+ // so a real reload never again looks like the conversation simply ended.
82
134
  const [open, setOpen] = useState(false);
83
135
  // Collapsed by default so the panel only ever shows the current exchange
84
136
  // — the full archived transcript (built up over a long conversation)
@@ -87,6 +139,18 @@ export function Copilot({
87
139
  const [historyExpanded, setHistoryExpanded] = useState(false);
88
140
  const [question, setQuestion] = useState("");
89
141
  const [answer, setAnswer] = useState<string | null>(null);
142
+ // Real, live-reported gap this closes: once a continuing agent-loop step
143
+ // (click/fill/read/call_tool/batch) sets `answer` to its own progress
144
+ // text ("Typing earbuds into the search box"), that text just sat there
145
+ // unchanged for however long the NEXT resolveVerb call took — several
146
+ // real seconds, more under rate-limit retries — with nothing on screen
147
+ // telling the user the agent was still actually doing something. `answer`
148
+ // itself can't double as that signal (a terminal turn's own real,
149
+ // finished answer looks identical to unfinished progress text). This is
150
+ // a separate, explicit flag: true from the moment a continuing step's
151
+ // progress text is shown until the turn actually ends (a terminal verb,
152
+ // an error, or a give-up) — see its own setters below for exactly where.
153
+ const [loopWorking, setLoopWorking] = useState(false);
90
154
  const [status, setStatus] = useState<Status>("idle");
91
155
  const [caption, setCaption] = useState("");
92
156
  // The user's own last question, shown as its own floating caption bubble
@@ -161,6 +225,18 @@ export function Copilot({
161
225
  const rtMicMutedRef = useRef(false);
162
226
  const rtSpeakerMutedRef = useRef(false);
163
227
  const rtStartingRef = useRef(false); // closes the click-to-first-state-update gap so a rapid double-click can't open two sessions
228
+ // The generation of the most recent "final" this client has processed —
229
+ // see ServerMessage's own doc comment in realtime-server.ts for the full
230
+ // real, live-found bug this closes: the client's own local barge-in can
231
+ // start a NEW turn (a new "final") before an EARLIER turn's own verb/
232
+ // audio, already in flight on the wire when the server processed that
233
+ // barge-in, actually arrives. WebSocket delivers messages in order, but
234
+ // "in order" isn't "still current" — every verb/speaking_start/
235
+ // audio_chunk/speaking_end/turn_complete message carries the generation
236
+ // it was produced under, and the handler drops it outright if it's
237
+ // older than this ref's value instead of applying it to whatever
238
+ // caption happens to be showing now.
239
+ const rtLastFinalGenerationRef = useRef(0);
164
240
  // Progressive PCM playback for the buffered (non-realtime) speak endpoint
165
241
  // — the same gapless AudioBufferSourceNode scheduling the realtime path
166
242
  // uses for its audio_chunk messages (see rtPlaybackCtxRef below), just fed
@@ -173,6 +249,37 @@ export function Copilot({
173
249
  const typedPlaybackGainRef = useRef<GainNode | null>(null);
174
250
  const typedNextPlayTimeRef = useRef(0);
175
251
  const typedScheduledSourcesRef = useRef<AudioBufferSourceNode[]>([]);
252
+ // Bumped by stopTypedPlayback() every time it runs — playPcmStream's own
253
+ // async reader loop checks this before scheduling each chunk, so a
254
+ // superseded call (two speak()/ask() replies resolving close together)
255
+ // actually STOPS reading and scheduling more audio once a newer call has
256
+ // taken over, instead of continuing to push nodes into the shared
257
+ // playback graph behind the newer call's back. stopTypedPlayback() on
258
+ // its own only ever stopped nodes that already existed at the moment it
259
+ // ran — it never told an in-flight stream reader to stop producing MORE
260
+ // of them, which is exactly what let two replies' audio genuinely
261
+ // overlap (one starting, then a second call's audio starting on top of
262
+ // it moments later) — a real, live-found race, not a guess.
263
+ const typedPlaybackGenerationRef = useRef(0);
264
+ // A DIFFERENT real gap the generation counter above doesn't close: it
265
+ // only protects one typed reply's audio against ANOTHER typed reply's
266
+ // audio. A typed ask()/speak() call already in flight — its /api/
267
+ // copilot/speak fetch genuinely takes several seconds under real
268
+ // conditions (rate-limit retries make this worse, not better) — has no
269
+ // way to know a realtime call started WHILE it was still waiting. Its
270
+ // response arrives, and normally-innocent code plays it, seconds after
271
+ // startRealtime() already ran — genuinely overlapping with the live
272
+ // call's own audio, since nothing about the realtime session's own
273
+ // start/mute/barge-in controls have any way to reach a typed reply
274
+ // that hadn't even been scheduled yet when they ran. Found live: two
275
+ // full agent answers, sourced entirely from separate /api/copilot/
276
+ // speak calls, audibly overlapping about a second apart, while a
277
+ // realtime call was the only thing visibly active in the UI the whole
278
+ // time. startRealtime() sets this; endRealtime() clears it; speak()/
279
+ // speakAndWait() check it AFTER their fetch resolves and drop the
280
+ // reply's audio entirely (never call playPcmStream at all) if a
281
+ // realtime call has taken over since the request was made.
282
+ const typedPlaybackSuspendedRef = useRef(false);
176
283
  // Watchdog for the "rt-thinking" state: started on every "final" transcript,
177
284
  // cleared the moment the server responds with anything for that turn
178
285
  // (verb/speaking_start/speaking_end/turn_complete/error). If it ever
@@ -196,6 +303,36 @@ export function Copilot({
196
303
  // finished arriving, so the mic can't start sending while the agent is
197
304
  // still audibly speaking.
198
305
  const rtAudioDoneArrivingRef = useRef(true);
306
+ // Diagnostic only, not a functional guard — flips to true the first time
307
+ // a real mic packet is actually sent after transitioning to
308
+ // "rt-listening", logged once (not per-packet, which would flood the
309
+ // console). Added specifically so a "status says Listening… but nothing
310
+ // I say gets picked up" report can be told apart, from the log alone,
311
+ // between "the send gate never opened" (this never logs) and "the gate
312
+ // opened and sent real audio, so the problem is somewhere else entirely
313
+ // — Deepgram's own STT, or a real hardware/OS mic issue this app can't
314
+ // see or fix" (this logs once, then goes quiet as expected).
315
+ const micAudioSentSinceListeningRef = useRef(false);
316
+
317
+ // Safety net for a live realtime call outliving this component instance:
318
+ // without this, unmounting (a parent removing the widget, a route change
319
+ // that remounts it, or — the real, live-hit case — Next.js Fast Refresh
320
+ // remounting the component on every dev-mode source edit while a call is
321
+ // open) left the WebSocket, the open getUserMedia mic stream, and both
322
+ // AudioContexts running completely orphaned: nothing ever called
323
+ // rtSocketRef.current?.close() or stopped the mic tracks. The old,
324
+ // zombie connection then kept transcribing and replying in parallel with
325
+ // whatever the newly-mounted instance does next — the literal
326
+ // "two things running in parallel, the agent answering twice" bug found
327
+ // live. endRealtime() is safe to call from an unmount cleanup even
328
+ // though it also calls React state setters: it only reads stable refs
329
+ // (never a stale closure over props/state), and React 18 silently no-ops
330
+ // a setState call on an already-unmounted component.
331
+ useEffect(() => {
332
+ return () => {
333
+ if (rtSocketRef.current || rtCleanupRef.current) endRealtime();
334
+ };
335
+ }, []);
199
336
 
200
337
  // Starts false on both server and client's first render (avoids a
201
338
  // hydration mismatch — `navigator` doesn't exist during SSR), then
@@ -205,6 +342,36 @@ export function Copilot({
205
342
  setMicSupported(!!navigator.mediaDevices?.getUserMedia && typeof MediaRecorder !== "undefined");
206
343
  }, []);
207
344
 
345
+ // Restores a conversation that survived a real page reload — same
346
+ // hydration-safety reason as `micSupported` just above: `open`/`answer`/
347
+ // `lastQuestion`/`transcript` all start at their normal empty defaults on
348
+ // both server and client's first render (matching what SSR produced), and
349
+ // only flip to whatever sessionStorage actually holds here, post-mount.
350
+ // Real, live-verified bug this closes: a host app's own mutation handler
351
+ // calling `window.location.reload()` (this SDK's own demo app does this
352
+ // after several real actions, e.g. moving a kanban card) tears down the
353
+ // entire React tree, this widget included — every bit of visible
354
+ // conversation state reset to nothing, making an in-progress conversation
355
+ // look like it had simply ended the instant the host page happened to
356
+ // reload, even though nothing about the CONVERSATION itself was over.
357
+ useEffect(() => {
358
+ const persisted = loadPersistedConversation();
359
+ if (!persisted) return;
360
+ setOpen(persisted.open);
361
+ setAnswer(persisted.answer);
362
+ setLastQuestion(persisted.lastQuestion);
363
+ setTranscript(persisted.transcript);
364
+ historyRef.current = reconstructHistoryFromPersisted(persisted);
365
+ // Starts past the restored transcript's own highest id — otherwise the
366
+ // very next archived entry would reuse an id already on screen, a real
367
+ // duplicate-React-key bug (React silently confuses which DOM node is
368
+ // which when two list items share a key).
369
+ if (persisted.transcript.length > 0) {
370
+ transcriptIdRef.current = Math.max(...persisted.transcript.map((t) => t.id)) + 1;
371
+ }
372
+ // eslint-disable-next-line react-hooks/exhaustive-deps
373
+ }, []);
374
+
208
375
  const asking = status === "asking";
209
376
  const recording = status === "recording";
210
377
  const realtimeActive = status.startsWith("rt-");
@@ -227,6 +394,26 @@ export function Copilot({
227
394
  answerRef.current = answer;
228
395
  }, [answer]);
229
396
 
397
+ // Persists the visible conversation to sessionStorage on every change, so
398
+ // a host app's own `window.location.reload()` (e.g. after a board-card
399
+ // move — see loadPersistedConversation's own comment for the full story)
400
+ // doesn't make an in-progress conversation look like it simply ended.
401
+ // Skips its own very first (mount) invocation on purpose: that call is
402
+ // always tied to the initial render's plain defaults (open:false,
403
+ // transcript:[], ...) — captured before the restore effect above's
404
+ // setState calls have actually landed — so persisting it would clobber a
405
+ // real, just-restored conversation with empty state for one tick, right
406
+ // before the corrective post-restore render fixes it back. Skipping costs
407
+ // nothing on a genuinely fresh session (there's nothing to persist yet).
408
+ const skippedMountPersistRef = useRef(false);
409
+ useEffect(() => {
410
+ if (!skippedMountPersistRef.current) {
411
+ skippedMountPersistRef.current = true;
412
+ return;
413
+ }
414
+ savePersistedConversation({ transcript, lastQuestion, answer, open });
415
+ }, [transcript, lastQuestion, answer, open]);
416
+
230
417
  // Auto-scroll to the newest content whenever the transcript grows or the
231
418
  // live (not-yet-archived) bubble's text changes.
232
419
  useEffect(() => {
@@ -300,10 +487,37 @@ export function Copilot({
300
487
  }
301
488
 
302
489
  function handleVerb(raw: unknown) {
490
+ setLoopWorking(false); // only ever called for a genuinely terminal verb — the turn is over
303
491
  executeVerbResponse(raw, pathname, {
304
492
  onExplain: (text) => {
305
493
  setAnswer(text);
306
- if (!realtimeActive) void speak(text); // realtime mode gets audio over the socket instead
494
+ // A REAL, deep, long-standing bug found live not introduced by
495
+ // today's other fixes, just newly diagnosed: this function is a
496
+ // plain closure defined fresh every render, but when a "verb" WS
497
+ // message arrives it's invoked through ws.onmessage — a callback
498
+ // assigned ONCE, inside startRealtime(), and never reassigned for
499
+ // the rest of that connection's life. `realtimeActive` there is
500
+ // therefore frozen at whatever it was AT THE MOMENT startRealtime()
501
+ // was called — which is BEFORE the click handler's own state
502
+ // updates land, so it reads `false` for literally the entire
503
+ // lifetime of every realtime call. `!realtimeActive` was therefore
504
+ // ALWAYS true here, on every single realtime turn — this ran
505
+ // speak() (the typed HTTP path) in addition to the correct
506
+ // realtime audio_chunk playback, every time. This is the actual,
507
+ // original root cause of "two speakers" — the earlier
508
+ // typedPlaybackSuspendedRef fix only ever suppressed the resulting
509
+ // AUDIO once this was already firing, it never stopped the
510
+ // firing itself (or the wasted LLM/TTS call and quota burn
511
+ // underneath it). rtStateRef mirrors status specifically to avoid
512
+ // this class of bug in a callback like this one (see its own doc
513
+ // comment) — using it here instead of the stale const is the
514
+ // actual fix.
515
+ if (rtStateRef.current.startsWith("rt-")) {
516
+ rtLog("explain: realtime call active, letting the socket's own audio_chunk stream speak this");
517
+ } else {
518
+ rtLog("explain: no realtime call active, using the typed speak() HTTP path");
519
+ void speak(text);
520
+ }
307
521
  },
308
522
  onNavigate: (route) => router.push(route),
309
523
  onMiss: reportMiss,
@@ -332,7 +546,16 @@ export function Copilot({
332
546
  */
333
547
  async function runTour(steps: TourStep[]) {
334
548
  const myGeneration = ++tourGenerationRef.current;
335
- const wasRealtimeListening = realtimeActive;
549
+ // Same real stale-closure bug as onExplain's own fix above, and the
550
+ // reason a realtime-triggered tour spoke through the wrong pipeline
551
+ // (or, after that fix suppressed the wrong pipeline's audio, spoke
552
+ // through nothing at all — "tour did not speak anything") and could
553
+ // leave the mic never properly told to resume listening afterward
554
+ // (see the `setRtStatus("rt-listening")` call at the end of this
555
+ // function, fixed the same way). rtStateRef.current is always
556
+ // current, regardless of which render's closure this particular
557
+ // invocation runs inside.
558
+ const wasRealtimeListening = rtStateRef.current.startsWith("rt-");
336
559
  touringRef.current = true;
337
560
  if (wasRealtimeListening) setRtStatus("rt-speaking");
338
561
  // No archiveCurrentExchange() here: whatever triggered this tour (a typed
@@ -410,7 +633,29 @@ export function Copilot({
410
633
  if (tourGenerationRef.current !== myGeneration) return;
411
634
  setTourStep(null);
412
635
  setCaption("");
413
- if (wasRealtimeListening && realtimeActive) setRtStatus("rt-listening");
636
+ // The most damaging half of this stale-closure bug: this used to
637
+ // read the stale `realtimeActive` const, which meant this call was
638
+ // ALWAYS skipped for a tour reached via realtime — the mic was
639
+ // never explicitly told to resume listening once the tour ended.
640
+ // rtStateRef.current.startsWith("rt-") is what actually reflects
641
+ // whether the connection is still live right now.
642
+ if (wasRealtimeListening && rtStateRef.current.startsWith("rt-")) setRtStatus("rt-listening");
643
+ } catch (err) {
644
+ // Real, live-found gap: this function had NO catch at all — only
645
+ // try/finally. Any error thrown anywhere in the loop above (a
646
+ // rejected speakOverRealtime/speakAndWait call, a DOM exception
647
+ // from el.click(), a network failure) skipped the resume-listening
648
+ // line above ENTIRELY, leaving the mic stuck exactly where the
649
+ // tour left off — matching "after this it's not listening"
650
+ // reported live. Every other place in this file that can fail
651
+ // mid-turn (ask(), handleDeepgramMessage's own server-side
652
+ // equivalent) already guarantees SOME recovery path; this one
653
+ // didn't have one at all.
654
+ console.error("[cairn] tour failed partway through:", err);
655
+ rtLog("tour failed — forcing the mic back to listening instead of leaving it stuck", { error: String(err) });
656
+ setTourStep(null);
657
+ setCaption("");
658
+ if (wasRealtimeListening && rtStateRef.current.startsWith("rt-")) setRtStatus("rt-listening");
414
659
  } finally {
415
660
  if (tourGenerationRef.current === myGeneration) touringRef.current = false;
416
661
  }
@@ -429,13 +674,109 @@ export function Copilot({
429
674
  try {
430
675
  await runTypedAgentLoop(q);
431
676
  } catch {
432
- setAnswer("Something went wrong reaching the help service try again in a moment.");
677
+ // See typedPlaybackSuspendedRef's own doc commentthis whole
678
+ // fetch can still be in flight when a realtime call starts.
679
+ if (!typedPlaybackSuspendedRef.current) setAnswer("Something went wrong reaching the help service — try again in a moment.");
433
680
  } finally {
434
- setStatus("idle");
681
+ // The most damaging form of the same race: unconditionally forcing
682
+ // status back to "idle" here, after a realtime call has ALREADY
683
+ // taken over (status is some "rt-*" value), would silently kick the
684
+ // UI out of the live call — hiding its mic/speaker/hangup controls
685
+ // and showing the "start call" screen instead — while the actual
686
+ // WebSocket connection underneath is still fully alive and still
687
+ // talking, now with no visible way to manage it at all. Skipping
688
+ // this reset when suspended is what stops a slow, stale typed
689
+ // request from ever being able to do that.
690
+ if (!typedPlaybackSuspendedRef.current) setStatus("idle");
435
691
  }
436
692
  }
437
693
 
438
- const MAX_LOOP_ITERATIONS = 6; // a hard cap, not a target — see runTypedAgentLoop
694
+ /**
695
+ * Architecture Pillar 6 (the safety layer) — the real, working default
696
+ * confirmation UI: a native browser confirm dialog, naming the tool's
697
+ * OWN real name/description (never inventing wording), for a WebMCP
698
+ * tool whose registration declared `riskTier: "confirm"` (a payment, a
699
+ * delete, anything hard to undo). `window.confirm` blocks the calling
700
+ * microtask until the user actually answers — exactly the real,
701
+ * synchronous "get a genuine yes before this runs" behavior needed
702
+ * here, and simple enough to need no new UI component for this to be a
703
+ * real, functioning default rather than just plumbing with nothing on
704
+ * the other end. A host app wanting a nicer in-widget modal can still
705
+ * build one — this function is the only thing that would need
706
+ * replacing to do that.
707
+ */
708
+ function confirmToolCall(tool: { name: string; description: string }): Promise<boolean> {
709
+ if (typeof window === "undefined" || typeof window.confirm !== "function") return Promise.resolve(false);
710
+ const message = tool.description ? `${tool.name}: ${tool.description}\n\nAllow this action?` : `Allow "${tool.name}"?`;
711
+ return Promise.resolve(window.confirm(message));
712
+ }
713
+
714
+ /**
715
+ * Architecture Pillar 4 — the typed transport's own Planner call,
716
+ * mirroring resolvePlan's real network shape but reached over HTTP
717
+ * (planEndpoint's server-side handler — createPlanHandler in server.ts
718
+ * — is the only place that can hold the real LLM API key). Never
719
+ * throws: any network/parse failure degrades to the exact same single-
720
+ * task fallback plan resolvePlan itself falls back to on an LLM error,
721
+ * so a Planner hiccup never blocks the turn.
722
+ */
723
+ async function fetchPlan(goal: string, version = 1): Promise<Plan> {
724
+ try {
725
+ const res = await fetch(planEndpoint!, {
726
+ method: "POST",
727
+ headers: { "content-type": "application/json" },
728
+ body: JSON.stringify({ goal, version }),
729
+ });
730
+ const data = await res.json();
731
+ if (data && typeof data === "object" && Array.isArray((data as Plan).tasks) && (data as Plan).tasks.length > 0) return data as Plan;
732
+ } catch {
733
+ // Falls through to the same fallback plan shape below.
734
+ }
735
+ return { version, goal, facts: [], tasks: [{ id: "t1", description: goal, doneContract: "The stated goal has been achieved.", status: "in_progress" }] };
736
+ }
737
+
738
+ /** Same real-network shape as fetchPlan, for criticEndpoint's
739
+ * createCriticHandler — degrades to a safe "continue" verdict on any
740
+ * failure, same resilience discipline as resolveCritic itself. */
741
+ async function fetchCriticVerdict(task: Task, goal: string, verb: VerbResponse, observation: string | null | undefined): Promise<CriticVerdict> {
742
+ try {
743
+ const res = await fetch(criticEndpoint!, {
744
+ method: "POST",
745
+ headers: { "content-type": "application/json" },
746
+ body: JSON.stringify({ task, goal, verb, observation: observation ?? null }),
747
+ });
748
+ const data = await res.json();
749
+ if (data && typeof (data as CriticVerdict).verdict === "string") return data as CriticVerdict;
750
+ } catch {
751
+ // Falls through to the safe default below.
752
+ }
753
+ return { verdict: "continue", reasoning: "Critic call failed — defaulting to continue rather than blocking the turn." };
754
+ }
755
+
756
+ /**
757
+ * Architecture Pillar 3 (Skill half) — the typed transport's own
758
+ * Formulator save, mirroring realtime-server.ts's own post-turn
759
+ * `compileSkill`+`saveSkill` call. Fire-and-forget (never awaited by
760
+ * the caller, never allowed to affect what the user sees) since saving
761
+ * a Skill is bookkeeping for a FUTURE turn, not part of answering this
762
+ * one — matches the Formulator's own "cheap, runs once per turn, never
763
+ * blocks anything" framing. Classifies the current live page for a
764
+ * best-effort pattern tag the same way resolveVerb's own per-request
765
+ * classification does server-side.
766
+ */
767
+ function saveSkillIfLearned(goal: string, learnedFacts: string[]): void {
768
+ if (!skillsSaveEndpoint || learnedFacts.length === 0) return;
769
+ const matches = classifyUiPattern(deriveStructureSignals(liveRegistryRef.current.getSnapshot().elements));
770
+ void fetch(skillsSaveEndpoint, {
771
+ method: "POST",
772
+ headers: { "content-type": "application/json" },
773
+ body: JSON.stringify({ goal, learnedFacts, pattern: matches[0]?.pattern }),
774
+ }).catch(() => {
775
+ // A Skill that fails to save just means the next similar goal
776
+ // starts from scratch again, same as if nothing had been learned
777
+ // this turn — never worth surfacing as a user-visible error.
778
+ });
779
+ }
439
780
 
440
781
  /**
441
782
  * Drives the agent loop over the stateless HTTP path: ask the server,
@@ -449,62 +790,201 @@ export function Copilot({
449
790
  * still knows what it was actually asked. `historyRef` (the
450
791
  * conversation's real memory) is only ever committed once, at the end —
451
792
  * a turn that hits the cap mid-loop doesn't leave partial noise in it.
793
+ * The loop itself (the `for`/TERMINAL_VERBS/iteration-cap shape) lives
794
+ * in agent-loop.ts, shared with the realtime relay's own finalizeTurn —
795
+ * this function owns everything transport-specific: the actual fetch,
796
+ * the raw/untyped response handling a stateless HTTP call needs (unlike
797
+ * realtime's always-valid in-process resolveVerb call), and the real
798
+ * historyRef commit.
452
799
  */
453
800
  async function runTypedAgentLoop(q: string): Promise<void> {
454
- let loopHistory = historyRef.current;
455
801
  const webMcpTools = await discoverWebMcpTools();
802
+ let lastRawResponse: unknown = null;
803
+
804
+ // Architecture Pillar 4 — real Planner/Critic wiring for the typed
805
+ // transport, opt-in via planEndpoint/criticEndpoint (see their own
806
+ // doc comments on CopilotProps) — closes the gap the plan file names
807
+ // directly ("the typed/HTTP path has zero Planner/Critic wiring at
808
+ // all... today explicitly realtime-only by deferral, not by
809
+ // decision"). Mirrors realtime-server.ts's finalizeTurn: an eager
810
+ // Planner kickoff when looksMultiStep(q) already flags a probable
811
+ // compound goal, a lazy fallback kickoff on the first continuing step
812
+ // otherwise, and a genuinely separate Critic pass over each
813
+ // continuing step's real result. Neither endpoint set (the default)
814
+ // means plannerEnabled is false and this whole block is a no-op —
815
+ // the typed loop behaves exactly as it always has.
816
+ let planPromise: Promise<Plan> | null = null;
817
+ let plan: Plan | null = null;
818
+ let progress: ProgressLedger | null = null;
819
+ const STALL_THRESHOLD = 3; // same bounded budget realtime's own Critic wiring uses
820
+ const plannerEnabled = Boolean(planEndpoint && criticEndpoint);
821
+ if (plannerEnabled && looksMultiStep(q)) planPromise = fetchPlan(q);
822
+ // Architecture Pillar 3 (Skill half) — every real, Critic-verified
823
+ // learnedFact from this turn's steps; saved once the turn concludes,
824
+ // below (saveSkillIfLearned). Empty is the common case, not a gap.
825
+ const learnedFacts: string[] = [];
826
+
827
+ const result = await driveAgentLoop(historyRef.current, {
828
+ async getNextStep(loopHistory) {
829
+ const liveScan = liveRegistryRef.current.getSnapshot();
830
+ liveMapRef.current = liveScan.byId;
831
+ const res = await fetch(endpoint, {
832
+ method: "POST",
833
+ headers: { "content-type": "application/json" },
834
+ body: JSON.stringify({
835
+ // pathnameRef, not the closed-over `pathname` — a navigate
836
+ // step (now possibly continuing, see isTerminalVerb) can
837
+ // change the real route mid-loop; this whole async function's
838
+ // own `pathname` closure was captured once, at the render
839
+ // that started this turn, and never updates again on its own.
840
+ route: pathnameRef.current,
841
+ question: q,
842
+ visible: collectVisible(),
843
+ history: loopHistory,
844
+ liveElements: liveScan.elements,
845
+ webMcpTools,
846
+ scopeId,
847
+ }),
848
+ });
849
+ const data = await res.json().catch(() => null);
850
+ lastRawResponse = data;
851
+ return safeParseVerbResponse(data);
852
+ },
853
+ onStep({ verb, terminal }) {
854
+ // A continuing step — show it happening (execution itself
855
+ // happens in executeStep below). Terminal steps are handled once
856
+ // driveAgentLoop returns, via handleVerb — unchanged from before.
857
+ // Same stale-typed-reply guard as the terminal case below — a
858
+ // multi-step typed loop can still be mid-flight when a realtime
859
+ // call starts.
860
+ if (!terminal && !typedPlaybackSuspendedRef.current) {
861
+ setAnswer(summarizeVerbForHistory(verb));
862
+ setLoopWorking(true);
863
+ }
864
+ // The lazy fallback — only fires when looksMultiStep missed
865
+ // (planPromise is still null): a real Plan is still guaranteed
866
+ // before the Critic needs one, just one round trip later.
867
+ if (!terminal && plannerEnabled && !planPromise) planPromise = fetchPlan(q);
868
+ return false;
869
+ },
870
+ // Same real, live-found fix as the realtime WS "verb" handler's own
871
+ // executeToolStep call — a fresh scan per step, not the turn's
872
+ // frozen liveMapRef, so a step that reveals new DOM (a click that
873
+ // opens a modal) doesn't leave the NEXT step unable to find
874
+ // anything in it.
875
+ executeStep: (verb) => executeToolStep(verb, pathnameRef.current, liveRegistryRef.current.getSnapshot().byId, (route) => router.push(route), confirmToolCall).then((r) => r?.observation),
876
+ runCritic: plannerEnabled
877
+ ? async ({ verb, observation }) => {
878
+ // Real state, not the Executor's self-report — see
879
+ // resolveCritic's own doc comment (server.ts) for why this is
880
+ // a genuinely separate pass, same precedent realtime already
881
+ // established.
882
+ if (!plan) {
883
+ plan = planPromise ? await planPromise : await fetchPlan(q);
884
+ progress = { planVersion: plan.version, currentTaskIndex: 0, stallCount: 0 };
885
+ }
886
+ const currentProgress = progress!;
887
+ const currentTask = plan.tasks[currentProgress.currentTaskIndex];
888
+ const verdict = await fetchCriticVerdict(currentTask, q, verb, observation);
889
+ if (verdict.learnedFact) learnedFacts.push(verdict.learnedFact);
890
+
891
+ if (verdict.verdict === "task_complete") {
892
+ currentTask.status = "done";
893
+ if (currentProgress.currentTaskIndex < plan.tasks.length - 1) {
894
+ // More tasks remain — advance and keep looping instead of
895
+ // ending the turn here.
896
+ currentProgress.currentTaskIndex++;
897
+ plan.tasks[currentProgress.currentTaskIndex].status = "in_progress";
898
+ currentProgress.stallCount = 0;
899
+ return { ...verdict, verdict: "continue" };
900
+ }
901
+ // The last task is genuinely done — end the loop right here
902
+ // instead of asking the model again and hoping it notices.
903
+ return verdict;
904
+ }
456
905
 
457
- for (let i = 0; i < MAX_LOOP_ITERATIONS; i++) {
458
- const liveScan = liveRegistryRef.current.getSnapshot();
459
- liveMapRef.current = liveScan.byId;
460
- const res = await fetch(endpoint, {
461
- method: "POST",
462
- headers: { "content-type": "application/json" },
463
- body: JSON.stringify({
464
- route: pathname,
465
- question: q,
466
- visible: collectVisible(),
467
- history: loopHistory,
468
- liveElements: liveScan.elements,
469
- webMcpTools,
470
- }),
471
- });
472
- const data = await res.json().catch(() => null);
473
- const parsed = safeParseVerbResponse(data);
474
-
475
- if (!parsed || TERMINAL_VERBS.has(parsed.verb)) {
476
- handleVerb(data);
477
- // Unlike the realtime relay (one persistent connection, memory
478
- // lives server-side), each of these POSTs is stateless — the
479
- // widget itself is what remembers, and resends it above so the
480
- // model has context for "the first one" / "do that instead" on
481
- // the next question.
482
- historyRef.current = [
483
- ...loopHistory,
484
- { role: "user", text: q } satisfies HistoryEntry,
485
- { role: "assistant", text: summarizeVerbForHistory(data) } satisfies HistoryEntry,
486
- ].slice(-MAX_HISTORY_TURNS);
487
- return;
488
- }
906
+ if (verdict.verdict === "replan") {
907
+ plan = await fetchPlan(q, plan.version + 1);
908
+ progress = { planVersion: plan.version, currentTaskIndex: 0, stallCount: 0 };
909
+ return { ...verdict, verdict: "continue" };
910
+ }
911
+
912
+ if (verdict.verdict === "give_up") return verdict;
913
+
914
+ // "continue" — a harness-enforced fail-safe on top of the
915
+ // Critic's own judgment, same Magentic-One-shaped two-tier
916
+ // tolerance realtime already uses.
917
+ currentProgress.stallCount++;
918
+ if (currentProgress.stallCount >= STALL_THRESHOLD) {
919
+ return {
920
+ verdict: "give_up",
921
+ reasoning: `Stuck after ${currentProgress.stallCount} steps with no confirmed progress on "${currentTask.description}" ${verdict.reasoning}`,
922
+ };
923
+ }
924
+ return verdict;
925
+ }
926
+ : undefined,
927
+ });
489
928
 
490
- // A continuing stepshow it happening, execute it for real, and
491
- // go around again with the real result instead of ending the turn.
492
- setAnswer(summarizeVerbForHistory(data));
493
- const stepResult = await executeToolStep(data, pathname, liveMapRef.current);
494
- loopHistory = [
495
- ...loopHistory,
496
- {
497
- role: "assistant",
498
- text: `${summarizeVerbForHistory(data)}. Result: ${stepResult?.observation ?? "no result"}`,
499
- } satisfies HistoryEntry,
929
+ // Architecture Pillar 3 (Skill half) the Formulator, once per turn.
930
+ saveSkillIfLearned(q, learnedFacts);
931
+
932
+ if (result.outcome === "terminal" || result.outcome === "unparseable" || result.outcome === "critic-complete") {
933
+ // A realtime call can start WHILE this whole typed loop (potentially
934
+ // several real fetches deep) was still in flight — applying this
935
+ // reply now would overwrite the live call's own answer with a
936
+ // stale, orphaned bubble that doesn't correspond to anything the
937
+ // realtime conversation actually said. speak()/speakAndWait() guard
938
+ // the AUDIO half of this same real, live-found race (see
939
+ // typedPlaybackSuspendedRef's own doc comment) — this is the
940
+ // matching guard for the TEXT half, which would otherwise still
941
+ // leak through even with the audio silenced.
942
+ // The Critic independently confirmed the last task's doneContract
943
+ // is satisfied even though the model's own verb never got there —
944
+ // real fix for the diagnosed bug (a batch succeeded and the model
945
+ // kept looping instead of recognizing it). No raw server response
946
+ // exists for this synthesized verb (it never came from `endpoint`
947
+ // at all), so it's built directly from the verdict's own reasoning
948
+ // — same shape realtime-server.ts synthesizes for the same outcome.
949
+ const raw: unknown = result.outcome === "critic-complete" ? { verb: "explain", text: result.verdict.reasoning } : lastRawResponse;
950
+ if (typedPlaybackSuspendedRef.current) {
951
+ rtLog("dropping stale typed reply's text — a realtime call started while it was still in flight");
952
+ } else {
953
+ handleVerb(raw);
954
+ }
955
+ // Unlike the realtime relay (one persistent connection, memory
956
+ // lives server-side), each of these POSTs is stateless — the
957
+ // widget itself is what remembers, and resends it above so the
958
+ // model has context for "the first one" / "do that instead" on
959
+ // the next question. Summarized from the RAW response (not
960
+ // driveAgentLoop's typed finalVerb) via this file's own untyped
961
+ // summarizeVerbForHistory — deliberately, since a response that
962
+ // failed schema validation (outcome "unparseable") can still carry
963
+ // real, usable fields (e.g. a stray extra property tripped
964
+ // .strict() while `text` itself was fine) that only the untyped,
965
+ // duck-typed summarizer sees; there is no typed finalVerb at all
966
+ // for that outcome.
967
+ historyRef.current = [
968
+ ...result.workingHistory,
969
+ { role: "user", text: q } satisfies HistoryEntry,
970
+ { role: "assistant", text: summarizeVerbForHistory(raw) } satisfies HistoryEntry,
500
971
  ].slice(-MAX_HISTORY_TURNS);
972
+ return;
501
973
  }
502
974
 
503
- setAnswer("I wasn't able to finish that try asking again or breaking it into smaller steps.");
975
+ // "gave-up" (iteration cap hit with no terminal verb) OR "critic-give-up"
976
+ // (the Critic/stall fail-safe decided continuing wouldn't help — its
977
+ // own reasoning is a genuinely better message than the generic
978
+ // fallback, same as realtime-server.ts's own finalizeTurn).
979
+ const giveUpText =
980
+ result.outcome === "critic-give-up" ? result.verdict.reasoning : "I wasn't able to finish that — try asking again or breaking it into smaller steps.";
981
+ const gaveUpSummary = result.outcome === "critic-give-up" ? giveUpText : "(gave up after too many steps)";
982
+ setLoopWorking(false);
983
+ setAnswer(giveUpText);
504
984
  historyRef.current = [
505
- ...loopHistory,
985
+ ...result.workingHistory,
506
986
  { role: "user", text: q } satisfies HistoryEntry,
507
- { role: "assistant", text: "(gave up after too many steps)" } satisfies HistoryEntry,
987
+ { role: "assistant", text: gaveUpSummary } satisfies HistoryEntry,
508
988
  ].slice(-MAX_HISTORY_TURNS);
509
989
  }
510
990
 
@@ -521,8 +1001,12 @@ export function Copilot({
521
1001
 
522
1002
  /** Stops whatever's currently playing on the typed/mic path's playback
523
1003
  * graph, so two responses (e.g. a rapid double-click, or two answers
524
- * resolved close together) can never be heard overlapping. */
1004
+ * resolved close together) can never be heard overlapping. Also bumps
1005
+ * typedPlaybackGenerationRef — see its own doc comment for why that's
1006
+ * required for this to actually hold when a NEW reply's audio is still
1007
+ * arriving as a stream, not just already fully scheduled. */
525
1008
  function stopTypedPlayback() {
1009
+ typedPlaybackGenerationRef.current++;
526
1010
  for (const source of typedScheduledSourcesRef.current) {
527
1011
  source.onended = null;
528
1012
  try {
@@ -552,6 +1036,7 @@ export function Copilot({
552
1036
  */
553
1037
  function playPcmStream(stream: ReadableStream<Uint8Array>): Promise<void> {
554
1038
  stopTypedPlayback();
1039
+ const myGeneration = typedPlaybackGenerationRef.current;
555
1040
  const { ctx, gain } = ensureTypedPlaybackGraph();
556
1041
  void ctx.resume().catch(() => {});
557
1042
 
@@ -592,7 +1077,14 @@ export function Copilot({
592
1077
  const reader = stream.getReader();
593
1078
  try {
594
1079
  for (;;) {
1080
+ // A newer call already ran stopTypedPlayback() (bumping the
1081
+ // generation) while we were mid-read — stop here instead of
1082
+ // scheduling more chunks behind its back. Checked both before
1083
+ // AND after the await: a supersede can land at any point while
1084
+ // this loop is blocked waiting on the next chunk.
1085
+ if (typedPlaybackGenerationRef.current !== myGeneration) break;
595
1086
  const { done, value } = await reader.read();
1087
+ if (typedPlaybackGenerationRef.current !== myGeneration) break;
596
1088
  if (done) break;
597
1089
  if (!value || value.length === 0) continue;
598
1090
  // PCM16 samples are 2 bytes each — a chunk boundary can split a
@@ -622,6 +1114,13 @@ export function Copilot({
622
1114
  body: JSON.stringify({ text }),
623
1115
  });
624
1116
  if (!res.ok || !res.body) return;
1117
+ // A realtime call can start WHILE this fetch was in flight — see
1118
+ // typedPlaybackSuspendedRef's own doc comment for why that's a real,
1119
+ // live-found overlapping-audio case, not a hypothetical one.
1120
+ if (typedPlaybackSuspendedRef.current) {
1121
+ rtLog("dropping stale typed reply's audio — a realtime call started while it was still being fetched");
1122
+ return;
1123
+ }
625
1124
  void playPcmStream(res.body);
626
1125
  } catch {
627
1126
  // Best-effort — never let speech playback break the widget.
@@ -640,6 +1139,13 @@ export function Copilot({
640
1139
  body: JSON.stringify({ text }),
641
1140
  });
642
1141
  if (!res.ok || !res.body) return;
1142
+ // See speak()'s own identical check and typedPlaybackSuspendedRef's
1143
+ // doc comment — a realtime call can start while this fetch was in
1144
+ // flight, same real risk here.
1145
+ if (typedPlaybackSuspendedRef.current) {
1146
+ rtLog("dropping stale typed reply's audio — a realtime call started while it was still being fetched");
1147
+ return;
1148
+ }
643
1149
  await playPcmStream(res.body);
644
1150
  } catch {
645
1151
  // Best-effort — never let a synthesis failure hang the tour forever.
@@ -674,7 +1180,7 @@ export function Copilot({
674
1180
  // closed connection mid-turn), don't let the tour hang on this step
675
1181
  // forever with the mic never resuming — move on instead.
676
1182
  setTimeout(() => {
677
- if (!settled) console.warn("[cairn] tour step audio confirmation timed out — continuing");
1183
+ if (!settled) rtLog("tour step audio confirmation timed out after 15s — continuing anyway");
678
1184
  finish();
679
1185
  }, 15000);
680
1186
  ws.send(JSON.stringify({ type: "speak", text }));
@@ -742,7 +1248,24 @@ export function Copilot({
742
1248
  // race past the `realtimeActive` check twice and open two sessions,
743
1249
  // which is exactly what "hearing the agent twice, in parallel" was.
744
1250
  if (!realtimeUrl || !micSupported || realtimeActive || rtStartingRef.current) return;
1251
+ rtLog("starting realtime call", { url: realtimeUrl });
745
1252
  rtStartingRef.current = true;
1253
+ // A typed/mic-recorded reply's audio can still be mid-playback on its
1254
+ // own separate graph (typedPlaybackGainRef, only ever touched by
1255
+ // stopTypedPlayback/playPcmStream) when the user switches straight into
1256
+ // a live call — endRealtime() already stops it on the way OUT of a
1257
+ // call, but nothing stopped it on the way IN, so it kept playing
1258
+ // completely unaffected by the realtime session's own mute-speaker
1259
+ // button (which only ever touches rtPlaybackGainRef) or by barge-in —
1260
+ // a real, live-found "two independent speakers" bug: muting or saying
1261
+ // "stop" only ever reached the realtime pipeline, while this leftover
1262
+ // typed audio played on regardless until it finished on its own.
1263
+ stopTypedPlayback();
1264
+ // Also blocks any typed reply that's still mid-fetch RIGHT NOW (not
1265
+ // yet playing anything, so stopTypedPlayback() above has nothing to
1266
+ // stop) from playing its audio once it finally arrives, seconds from
1267
+ // now — see typedPlaybackSuspendedRef's own doc comment.
1268
+ typedPlaybackSuspendedRef.current = true;
746
1269
  archiveCurrentExchange(); // preserve whatever typed/mic exchange preceded switching into a live call
747
1270
  setAnswer(null);
748
1271
  setCaption("");
@@ -777,6 +1300,21 @@ export function Copilot({
777
1300
  const processor = audioCtx.createScriptProcessor(4096, 1, 1);
778
1301
  const silence = audioCtx.createGain();
779
1302
  silence.gain.value = 0;
1303
+ const bargeInVad = createVadDetector();
1304
+ // Real, live-reported bug this closes: firing triggerBargeIn() off a
1305
+ // SINGLE ~85-100ms VAD frame meant one cough or door-slam frame that
1306
+ // happened to pass the energy+ZCR gate cut the agent off, permanently
1307
+ // (no server-side "was this real" recovery exists anymore — see
1308
+ // vad.ts's own doc comment for why that was removed instead of kept).
1309
+ // Real research into how production voice-agent platforms solve this
1310
+ // (Pipecat, LiveKit Agents, Vapi, Deepgram's Voice Agent API — see
1311
+ // DEVELOPMENT.md) converges on gating the LOCAL trigger on SUSTAINED
1312
+ // speech across a minimum duration instead — Pipecat's own production
1313
+ // spec cites 250ms, Vapi's stopSpeakingPlan defaults to 0.2s. This
1314
+ // gate does exactly that, entirely client-side (no network round trip
1315
+ // or STT-transcript timing involved, so it can't reintroduce the
1316
+ // removed server-side race).
1317
+ const bargeInGate = createBargeInGate();
780
1318
 
781
1319
  processor.onaudioprocess = (e) => {
782
1320
  if (ws.readyState !== WebSocket.OPEN) return;
@@ -785,23 +1323,30 @@ export function Copilot({
785
1323
  // Barge-in: while the agent is speaking a real conversational reply,
786
1324
  // still thinking about one, OR mid-tour, keep listening to the mic
787
1325
  // locally even though it isn't being sent yet, and cut the agent
788
- // off the instant the user starts talking again instead of making
789
- // them wait — including during a guided tour, which now cancels the
790
- // rest of the walkthrough on interruption (see triggerBargeIn)
791
- // instead of being talked-over-proof by design, the way a real
792
- // person giving a tour stops when you have a question. The
793
- // "rt-thinking" half matters just as much as "rt-speaking": an LLM
794
- // turn can easily take a couple of seconds with nothing playing
795
- // yet, and without this the mic was completely deaf during that
796
- // whole window — found live as "not listening while speaking... no
797
- // interrupting system", not just a missed nice-to-have.
1326
+ // off once the user has been sustainedly talking again (bargeInGate,
1327
+ // above) instead of making them wait — including during a guided
1328
+ // tour, which now cancels the rest of the walkthrough on
1329
+ // interruption (see triggerBargeIn) instead of being talked-over-
1330
+ // proof by design, the way a real person giving a tour stops when
1331
+ // you have a question. The "rt-thinking" half matters just as much
1332
+ // as "rt-speaking": an LLM turn can easily take a couple of seconds
1333
+ // with nothing playing yet, and without this the mic was completely
1334
+ // deaf during that whole window — found live as "not listening
1335
+ // while speaking... no interrupting system", not just a missed
1336
+ // nice-to-have.
798
1337
  if (rtStateRef.current === "rt-speaking" || rtStateRef.current === "rt-thinking") {
799
- const rms = computeRms(e.inputBuffer.getChannelData(0));
800
- if (rms > BARGE_IN_RMS_THRESHOLD) triggerBargeIn();
1338
+ const frame = bargeInVad.process(e.inputBuffer.getChannelData(0));
1339
+ const frameDurationMs = (e.inputBuffer.length / audioCtx.sampleRate) * 1000;
1340
+ if (bargeInGate.update(frame, frameDurationMs)) triggerBargeIn();
801
1341
  return;
802
1342
  }
1343
+ bargeInGate.reset(); // not currently interruptible — don't let stale progress from a moment ago carry into the next speaking/thinking phase
803
1344
 
804
1345
  if (rtStateRef.current !== "rt-listening") return; // don't send our own mic while the agent is thinking/speaking
1346
+ if (!micAudioSentSinceListeningRef.current) {
1347
+ micAudioSentSinceListeningRef.current = true;
1348
+ rtLog("mic audio actually being sent (send gate is open)");
1349
+ }
805
1350
  const pcm = floatTo16BitPCM(downsampleTo16k(e.inputBuffer.getChannelData(0), audioCtx.sampleRate));
806
1351
  ws.send(pcm);
807
1352
  };
@@ -809,7 +1354,48 @@ export function Copilot({
809
1354
  processor.connect(silence);
810
1355
  silence.connect(audioCtx.destination);
811
1356
 
1357
+ // Real, live-reported bug this closes: "status says Listening but
1358
+ // nothing gets picked up" — traced to browsers deliberately
1359
+ // suspending an AudioContext that has no active OUTPUT (a real,
1360
+ // documented power-saving policy, not backgrounded-tab-only). This
1361
+ // capture context has no real output at all by design (silence's
1362
+ // gain is 0), making it exactly the shape most likely to get
1363
+ // silently suspended — and once suspended, onaudioprocess simply
1364
+ // stops firing, so nothing inside it can detect or recover from its
1365
+ // own silence. A periodic external health check is the only
1366
+ // reliable way to catch this: resume the context if the browser
1367
+ // suspended it, and — a real, separate failure mode — detect the
1368
+ // mic's OWN MediaStreamTrack actually ending or going muted (device
1369
+ // unplugged, OS-level permission revoked mid-call, another app
1370
+ // taking exclusive access) and surface a real, honest error instead
1371
+ // of silently going deaf with the UI still claiming to listen.
1372
+ const micHealthCheck = setInterval(() => {
1373
+ if (audioCtx.state !== "running") {
1374
+ rtLog("capture AudioContext was suspended — resuming", { state: audioCtx.state });
1375
+ void audioCtx.resume().catch((err) => rtLog("failed to resume capture AudioContext", { error: String(err) }));
1376
+ }
1377
+ const track = stream.getAudioTracks()[0];
1378
+ if (track && (track.readyState === "ended" || track.muted)) {
1379
+ rtLog("mic track is no longer live — ending the call", { readyState: track.readyState, muted: track.muted });
1380
+ setAnswer("The microphone connection was lost — try starting the call again.");
1381
+ endRealtime();
1382
+ }
1383
+ }, 2000);
1384
+
1385
+ // The track's own "ended" event is the immediate signal (fires the
1386
+ // instant the OS/browser actually kills the track) — the poll above
1387
+ // is the safety net for anything that doesn't fire it reliably
1388
+ // (muted-without-ended has no dedicated event in the spec).
1389
+ const handleMicTrackEnded = () => {
1390
+ rtLog("mic track ended unexpectedly — ending the call");
1391
+ setAnswer("The microphone connection was lost — try starting the call again.");
1392
+ endRealtime();
1393
+ };
1394
+ stream.getAudioTracks().forEach((t) => t.addEventListener("ended", handleMicTrackEnded));
1395
+
812
1396
  rtCleanupRef.current = () => {
1397
+ clearInterval(micHealthCheck);
1398
+ stream.getAudioTracks().forEach((t) => t.removeEventListener("ended", handleMicTrackEnded));
813
1399
  processor.disconnect();
814
1400
  source.disconnect();
815
1401
  stream.getTracks().forEach((t) => t.stop());
@@ -839,6 +1425,9 @@ export function Copilot({
839
1425
  rtTourAudioDoneRef.current = null;
840
1426
  return;
841
1427
  }
1428
+ rtLog("resumed listening");
1429
+ void audioCtx.resume().catch(() => {}); // don't wait up to 2s for the periodic health check if the browser already suspended capture
1430
+ micAudioSentSinceListeningRef.current = false;
842
1431
  setRtStatus("rt-listening");
843
1432
  setCaption("");
844
1433
  void sendFreshContext(); // refresh before the user starts talking again, not after
@@ -855,10 +1444,28 @@ export function Copilot({
855
1444
  disarmThinkingWatchdog();
856
1445
  rtThinkingWatchdogRef.current = setTimeout(() => {
857
1446
  rtThinkingWatchdogRef.current = null;
858
- console.warn("[cairn] realtime turn timed out waiting on the server resuming listening");
859
- rtAudioDoneArrivingRef.current = true;
860
- setRtStatus("rt-listening");
861
- setCaption("");
1447
+ rtLog("thinking watchdog fired server took over 20s, resuming listening and abandoning that turn");
1448
+ // Real, live-found gap: this used to only reset LOCAL state,
1449
+ // never telling the server anything — so a turn that was simply
1450
+ // SLOW (not actually stuck; e.g. retrying a rate-limited call
1451
+ // across every configured key, which can genuinely take longer
1452
+ // than this 20s watchdog) kept running server-side, and its
1453
+ // reply arrived LATE, after the user had already moved on and
1454
+ // started a new turn locally — landing on whatever was now
1455
+ // showing instead of being recognized as stale. triggerBargeIn()
1456
+ // is exactly the fix: it sends the same real barge_in signal a
1457
+ // genuine interruption does, bumping the server's own generation
1458
+ // so that late reply — whenever it finally arrives — carries an
1459
+ // old generation number and gets correctly dropped by the
1460
+ // isStaleRtMessage check above instead of confusingly resuming.
1461
+ triggerBargeIn();
1462
+ setLoopWorking(false);
1463
+ // triggerBargeIn() clears the caption but never touched `answer`
1464
+ // — without this, a timed-out turn gave the user literally
1465
+ // nothing: no reply, no error, just a silent reset back to
1466
+ // "Listening…" that reads as "it heard me and did nothing." A
1467
+ // real, live-found gap, not just a console.warn nobody sees.
1468
+ setAnswer("That's taking longer than expected — try asking again.");
862
1469
  }, 20000);
863
1470
  }
864
1471
 
@@ -880,6 +1487,7 @@ export function Copilot({
880
1487
  // now-stale audio_chunk/speaking_end that was already in flight, so a
881
1488
  // few straggling chunks can't sneak back in and resume playback.
882
1489
  function triggerBargeIn() {
1490
+ rtLog("barge-in triggered", { wasTouring: touringRef.current, discardedAudioChunks: rtScheduledSourcesRef.current.length });
883
1491
  disarmThinkingWatchdog();
884
1492
  stopScheduledRtAudio();
885
1493
  rtAudioDoneArrivingRef.current = true;
@@ -898,28 +1506,83 @@ export function Copilot({
898
1506
  }
899
1507
  if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "barge_in" }));
900
1508
  setRtStatus("rt-listening");
901
- setCaption("");
1509
+ // Real, live-found bug this fix removes: this used to also clear
1510
+ // the caption here (setCaption("")). That's correct-looking for a
1511
+ // REAL, VAD-triggered barge-in (the user's about to say something
1512
+ // new) — the very next "final" already does archiveCurrentExchange()
1513
+ // then overwrites caption with the new utterance, so clearing it
1514
+ // here was always redundant for that path. But this function is
1515
+ // ALSO called by the thinking watchdog on a timeout, where there is
1516
+ // no new utterance coming — clearing the caption there wiped out
1517
+ // the very question that just timed out, an instant before
1518
+ // setAnswer(the timeout message) ran, leaving the live pair as
1519
+ // {caption: "", answer: "That's taking longer..."} — a reply
1520
+ // visibly floating with no question above it, and nothing for
1521
+ // archiveCurrentExchange() to pair it with on the next turn either
1522
+ // (archiveText skips empty text). Simply never clearing it here is
1523
+ // correct for both callers: the barge-in path already gets a fresh
1524
+ // caption from the next "final", and the watchdog path now keeps
1525
+ // the timed-out question correctly paired with its own answer.
902
1526
  }
903
1527
 
904
1528
  ws.onopen = () => {
1529
+ rtLog("connection open");
905
1530
  sendFreshContext();
906
1531
  setRtStatus("rt-listening");
907
1532
  rtStartingRef.current = false;
908
1533
  };
909
1534
 
1535
+ // True for a verb/speaking_start/audio_chunk/speaking_end/
1536
+ // turn_complete message that belongs to an EARLIER turn than the
1537
+ // most recent "final" this client has seen — see
1538
+ // rtLastFinalGenerationRef's own doc comment for the real race this
1539
+ // closes. A message with no generation field at all (shouldn't
1540
+ // happen against a server running this fix, but a mismatched client/
1541
+ // server version pair during a rolling deploy could) is treated as
1542
+ // current rather than dropped — additive/backward-compatible, same
1543
+ // discipline every other wire-protocol addition in this codebase
1544
+ // follows.
1545
+ function isStaleRtMessage(msg: { generation?: unknown }): boolean {
1546
+ const stale = typeof msg.generation === "number" && msg.generation < rtLastFinalGenerationRef.current;
1547
+ if (stale) rtLog("dropped stale message", { type: (msg as { type?: unknown }).type, messageGeneration: msg.generation, currentGeneration: rtLastFinalGenerationRef.current });
1548
+ return stale;
1549
+ }
1550
+
1551
+ let audioChunkCount = 0;
1552
+
910
1553
  ws.onmessage = (event) => {
911
1554
  if (typeof event.data !== "string") return; // audio now arrives as base64 inside audio_chunk, not raw binary frames
912
1555
  const msg = JSON.parse(event.data);
913
1556
  if (msg.type === "interim") {
914
1557
  setCaption(msg.text);
915
1558
  } else if (msg.type === "final") {
1559
+ rtLog("final transcript", { text: msg.text, generation: msg.generation });
1560
+ rtLastFinalGenerationRef.current = typeof msg.generation === "number" ? msg.generation : 0;
916
1561
  archiveCurrentExchange(); // the previous turn's pair is complete — move it into history before this one starts overwriting caption/answer
917
1562
  setCaption(msg.text);
1563
+ // Without this, `answer` still held the PREVIOUS turn's reply
1564
+ // text at the moment this new turn's own reply — if it ever
1565
+ // arrives — would overwrite it. Usually invisible (the previous
1566
+ // reply lands well before the next "final"), but a barge-in can
1567
+ // supersede an in-flight turn before its reply ever arrives (see
1568
+ // realtime-server.ts's onStep generation check) — with no reply
1569
+ // ever coming for THIS caption, the stale previous-turn answer
1570
+ // sat there and got archived alongside the wrong question on the
1571
+ // NEXT final, showing as a mismatched or duplicated-looking
1572
+ // reply. Found live: two turns' worth of the same fallback error
1573
+ // text ("Something went wrong on my end") appearing back to back
1574
+ // with only one visible question between them. Clearing to null
1575
+ // here means an abandoned turn now correctly archives with NO
1576
+ // reply bubble (archiveText skips empty text) instead of someone
1577
+ // else's.
1578
+ setAnswer(null);
918
1579
  setRtStatus("rt-thinking");
919
1580
  armThinkingWatchdog();
920
1581
  } else if (msg.type === "verb") {
1582
+ if (isStaleRtMessage(msg)) return; // belongs to a turn a later "final" already superseded
1583
+ rtLog("verb received", { verb: msg.verb?.verb, generation: msg.generation });
921
1584
  const parsedStep = safeParseVerbResponse(msg.verb);
922
- if (parsedStep && !TERMINAL_VERBS.has(parsedStep.verb)) {
1585
+ if (parsedStep && !isTerminalVerb(parsedStep)) {
923
1586
  // A continuing agent-loop step (click/fill/read/call_tool) —
924
1587
  // the turn isn't over: execute it for real and report the
925
1588
  // result back so the server can decide the next step, instead
@@ -930,7 +1593,39 @@ export function Copilot({
930
1593
  // — the server's loop stays quiet between steps on purpose,
931
1594
  // to keep it fast.
932
1595
  setAnswer(summarizeVerbForHistory(msg.verb));
933
- void executeToolStep(msg.verb, pathnameRef.current, liveMapRef.current).then((result) => {
1596
+ setLoopWorking(true);
1597
+ // Real, live-found bug: armThinkingWatchdog() only ever fired
1598
+ // once, on the turn's own "final" message, giving the WHOLE
1599
+ // multi-step turn one shared 20s budget — Executor + Planner +
1600
+ // Critic for step 1, then the same again for step 2, and so
1601
+ // on. Directly measured live: one single non-terminal step's
1602
+ // own Executor+Planner+Critic chain alone took ~14s (11.5s +
1603
+ // 1.5s + 1.1s) — a real, multi-step goal needing two or three
1604
+ // such steps blows straight through 20s even though each
1605
+ // individual step is proof of genuine progress, not a stall.
1606
+ // Re-arming here — once per continuing step, not once per
1607
+ // turn — gives every step its own fresh budget, so the
1608
+ // watchdog only ever fires on a step that's ACTUALLY stuck
1609
+ // (no verb/final/speaking_start arriving at all), matching
1610
+ // what its own fallback message ("taking longer than
1611
+ // expected") is supposed to mean.
1612
+ armThinkingWatchdog();
1613
+ // A FRESH scan, not the turn's starting liveMapRef snapshot —
1614
+ // real, live-found bug: a step in THIS SAME multi-step turn
1615
+ // (a "click New Agent" that opens a modal) can reveal DOM a
1616
+ // later step (a "fill" targeting the modal's own input) needs
1617
+ // to find, and liveMapRef is deliberately frozen once per
1618
+ // turn (see its own doc comment — that freeze exists to stop
1619
+ // a background rescan from shifting an id mid-flight during
1620
+ // ONE step's own round trip, not to survive across several
1621
+ // sequential steps that genuinely changed the page).
1622
+ // runTour() already does exactly this for its own steps, for
1623
+ // the identical reason. Without it, "click New Agent, then
1624
+ // type the name" reliably failed every time with "Could not
1625
+ // find that element on the page" — confirmed live, repeated
1626
+ // 5+ times in a row without ever recovering.
1627
+ const freshLiveMap = liveRegistryRef.current.getSnapshot().byId;
1628
+ void executeToolStep(msg.verb, pathnameRef.current, freshLiveMap, (route) => router.push(route), confirmToolCall).then((result) => {
934
1629
  if (ws.readyState === WebSocket.OPEN) {
935
1630
  ws.send(JSON.stringify({ type: "tool_result", observation: result?.observation ?? "no result" }));
936
1631
  }
@@ -940,10 +1635,15 @@ export function Copilot({
940
1635
  disarmThinkingWatchdog();
941
1636
  handleVerb(msg.verb);
942
1637
  } else if (msg.type === "speaking_start") {
1638
+ if (isStaleRtMessage(msg)) return;
1639
+ rtLog("speaking start", { generation: msg.generation });
1640
+ audioChunkCount = 0;
943
1641
  disarmThinkingWatchdog();
944
1642
  rtAudioDoneArrivingRef.current = false;
945
1643
  setRtStatus("rt-speaking");
946
1644
  } else if (msg.type === "audio_chunk") {
1645
+ if (isStaleRtMessage(msg)) return; // the literal "two speakers" case — a chunk from an abandoned turn, already in flight when the barge-in landed
1646
+ audioChunkCount++;
947
1647
  const ctx = rtPlaybackCtxRef.current;
948
1648
  const gain = rtPlaybackGainRef.current;
949
1649
  if (!ctx || !gain) return;
@@ -978,6 +1678,8 @@ export function Copilot({
978
1678
  maybeResumeListening();
979
1679
  };
980
1680
  } else if (msg.type === "speaking_end" || msg.type === "turn_complete") {
1681
+ if (isStaleRtMessage(msg)) return; // a newer turn's own speaking_end/turn_complete will arrive and resume listening correctly on its own
1682
+ rtLog(msg.type, { audioChunks: audioChunkCount, generation: msg.generation });
981
1683
  // turn_complete covers a verb with nothing spoken (a plain
982
1684
  // highlight/navigate/do often has no text) — no audio_chunk ever
983
1685
  // arrives for it, so rtScheduledSourcesRef is already empty and
@@ -986,10 +1688,12 @@ export function Copilot({
986
1688
  rtAudioDoneArrivingRef.current = true;
987
1689
  maybeResumeListening();
988
1690
  } else if (msg.type === "error") {
1691
+ rtLog("server error", { message: msg.message });
989
1692
  // Must actually unstick the turn, not just show the message —
990
1693
  // otherwise the mic never resumes and the session is stuck
991
1694
  // exactly the way a silently-dropped response used to leave it.
992
1695
  disarmThinkingWatchdog();
1696
+ setLoopWorking(false);
993
1697
  setAnswer(msg.message ?? "Something went wrong.");
994
1698
  if (touringRef.current) {
995
1699
  // A tour step's own speakStreamed() failed server-side (see
@@ -1009,26 +1713,31 @@ export function Copilot({
1009
1713
  };
1010
1714
 
1011
1715
  ws.onerror = () => {
1716
+ rtLog("connection error");
1012
1717
  setAnswer("Couldn't connect to the realtime voice service.");
1013
1718
  endRealtime();
1014
1719
  };
1015
- ws.onclose = () => {
1720
+ ws.onclose = (closeEvent) => {
1721
+ rtLog("connection closed", { code: closeEvent.code, reason: closeEvent.reason, wasIdle: rtStateRef.current === "idle" });
1016
1722
  if (rtStateRef.current !== "idle") endRealtime();
1017
1723
  };
1018
1724
  } catch {
1019
1725
  setAnswer("Couldn't access the microphone — check your browser's permission for this site.");
1020
1726
  setRtStatus("idle");
1021
1727
  rtStartingRef.current = false;
1728
+ typedPlaybackSuspendedRef.current = false; // the call never actually started — don't leave typed replies permanently silenced
1022
1729
  }
1023
1730
  }
1024
1731
 
1025
1732
  function endRealtime() {
1733
+ rtLog("ending realtime call", { statusAtEnd: rtStateRef.current });
1026
1734
  if (rtThinkingWatchdogRef.current) {
1027
1735
  clearTimeout(rtThinkingWatchdogRef.current);
1028
1736
  rtThinkingWatchdogRef.current = null;
1029
1737
  }
1030
1738
  rtStartingRef.current = false;
1031
1739
  stopTypedPlayback();
1740
+ typedPlaybackSuspendedRef.current = false; // typed replies work normally again once no live call can race them
1032
1741
  rtSocketRef.current?.close();
1033
1742
  rtSocketRef.current = null;
1034
1743
  rtCleanupRef.current?.();
@@ -1037,6 +1746,7 @@ export function Copilot({
1037
1746
  setRtSpeakerMuted(false);
1038
1747
  setCaption("");
1039
1748
  setRtStatus("idle");
1749
+ setLoopWorking(false); // defensive — a connection dropping mid-loop must never leave the "still working" indicator stuck on
1040
1750
  tourGenerationRef.current++; // cancel an in-progress tour rather than leaving it stuck waiting to resume rt-listening
1041
1751
  touringRef.current = false;
1042
1752
  setTourStep(null);
@@ -1118,7 +1828,25 @@ export function Copilot({
1118
1828
  <div className="cairn-bubble cairn-bubble-agent" key={`a-${answer ?? status}`}>
1119
1829
  {tourChip && <span className="cairn-chip">{tourChip}</span>}
1120
1830
  {answer ? (
1121
- <span className="cairn-bubble-text">{renderCaptionWords(answer)}</span>
1831
+ <span className="cairn-bubble-text">
1832
+ {renderCaptionWords(answer)}
1833
+ {loopWorking && (
1834
+ // Real, live-reported gap this closes: this bubble
1835
+ // used to go static the instant a continuing step's
1836
+ // own progress text was shown ("Typing earbuds into
1837
+ // the search box"), with nothing telling the user
1838
+ // the agent was still actively working for however
1839
+ // long the next real LLM call took. Appended inline
1840
+ // (not swapped in place of the text, which the
1841
+ // no-answer-yet case below does) so the progress
1842
+ // text stays legible while still showing motion.
1843
+ <span className="cairn-thinking cairn-thinking-inline" aria-label="Still working">
1844
+ <span className="cairn-thinking-dot" />
1845
+ <span className="cairn-thinking-dot" />
1846
+ <span className="cairn-thinking-dot" />
1847
+ </span>
1848
+ )}
1849
+ </span>
1122
1850
  ) : (
1123
1851
  <span className="cairn-thinking" aria-label="Thinking">
1124
1852
  <span className="cairn-thinking-dot" />
@@ -1219,6 +1947,82 @@ export function Copilot({
1219
1947
 
1220
1948
  const MAX_HISTORY_TURNS = 8; // 4 exchanges — matches the same cap the realtime relay uses server-side
1221
1949
 
1950
+ export const CONVERSATION_STORAGE_KEY = "cairn:conversation:v1";
1951
+
1952
+ export interface PersistedConversation {
1953
+ transcript: { id: number; role: "user" | "agent"; text: string }[];
1954
+ lastQuestion: string | null;
1955
+ answer: string | null;
1956
+ open: boolean;
1957
+ }
1958
+
1959
+ /**
1960
+ * Real, live-reported bug this closes: a host app's own mutation handler
1961
+ * calling a real `window.location.reload()` — a common, entirely valid
1962
+ * pattern; this SDK's own demo app uses it after several real actions,
1963
+ * e.g. moving a kanban card — tears down the ENTIRE React tree, this
1964
+ * widget included. Every bit of conversation state (the visible
1965
+ * transcript, the current exchange, even whether the panel was open)
1966
+ * reset to nothing, making a real, in-progress conversation look like it
1967
+ * had simply ended the instant a host page happened to reload — even
1968
+ * though nothing about the CONVERSATION itself was actually over.
1969
+ * `sessionStorage`, not `localStorage`, is deliberate: it survives
1970
+ * exactly a reload/navigation within the same tab — the real scope of
1971
+ * "this conversation" — and clears itself once the tab/window actually
1972
+ * closes, never lingering into an unrelated later visit the way
1973
+ * `localStorage` would.
1974
+ */
1975
+ export function loadPersistedConversation(): PersistedConversation | null {
1976
+ if (typeof window === "undefined") return null;
1977
+ try {
1978
+ const raw = window.sessionStorage.getItem(CONVERSATION_STORAGE_KEY);
1979
+ if (!raw) return null;
1980
+ const parsed = JSON.parse(raw);
1981
+ if (!parsed || typeof parsed !== "object") return null;
1982
+ const transcript = Array.isArray(parsed.transcript)
1983
+ ? parsed.transcript.filter(
1984
+ (t: unknown): t is { id: number; role: "user" | "agent"; text: string } =>
1985
+ !!t && typeof t === "object" && typeof (t as { id?: unknown }).id === "number" && typeof (t as { text?: unknown }).text === "string" && ((t as { role?: unknown }).role === "user" || (t as { role?: unknown }).role === "agent"),
1986
+ )
1987
+ : [];
1988
+ return {
1989
+ transcript,
1990
+ lastQuestion: typeof parsed.lastQuestion === "string" ? parsed.lastQuestion : null,
1991
+ answer: typeof parsed.answer === "string" ? parsed.answer : null,
1992
+ open: Boolean(parsed.open),
1993
+ };
1994
+ } catch {
1995
+ return null; // private browsing, quota, or a genuinely corrupt value — never crash the widget over this
1996
+ }
1997
+ }
1998
+
1999
+ export function savePersistedConversation(data: PersistedConversation): void {
2000
+ if (typeof window === "undefined") return;
2001
+ try {
2002
+ window.sessionStorage.setItem(CONVERSATION_STORAGE_KEY, JSON.stringify(data));
2003
+ } catch {
2004
+ // Storage unavailable/full — the conversation just won't survive a reload this time, never worth crashing the widget over.
2005
+ }
2006
+ }
2007
+
2008
+ /**
2009
+ * Rebuilds a real seed for `historyRef` (what gets sent to the model on
2010
+ * the NEXT typed turn) from a restored transcript — deliberately derived
2011
+ * from the same, already-persisted `transcript` rather than separately
2012
+ * persisting `historyRef`'s own shape: one real source of truth for "what
2013
+ * was actually said," not two that could quietly drift apart. Capped the
2014
+ * same way every other history array in this file already is.
2015
+ */
2016
+ export function reconstructHistoryFromPersisted(persisted: PersistedConversation | null): HistoryEntry[] {
2017
+ if (!persisted) return [];
2018
+ const fromTranscript: HistoryEntry[] = persisted.transcript.map((t) => ({ role: t.role === "agent" ? "assistant" : "user", text: t.text }));
2019
+ const live: HistoryEntry[] = [
2020
+ ...(persisted.lastQuestion ? [{ role: "user" as const, text: persisted.lastQuestion }] : []),
2021
+ ...(persisted.answer ? [{ role: "assistant" as const, text: persisted.answer }] : []),
2022
+ ];
2023
+ return [...fromTranscript, ...live].slice(-MAX_HISTORY_TURNS);
2024
+ }
2025
+
1222
2026
  /** Best-effort text form of a raw (unvalidated) verb response for the
1223
2027
  * conversation-history log — not shown to the user, just fed back to the
1224
2028
  * model on later turns. Deliberately loose/defensive rather than a full
@@ -1247,6 +2051,12 @@ function summarizeVerbForHistory(raw: unknown): string {
1247
2051
  return `(read ${String(v.target)})`;
1248
2052
  case "call_tool":
1249
2053
  return `(called ${String(v.name)})`;
2054
+ case "drag":
2055
+ return `(dragged ${String(v.target)} to ${String(v.to)})`;
2056
+ case "select":
2057
+ return `(selected "${String(v.value)}" in ${String(v.target)})`;
2058
+ case "key":
2059
+ return `(pressed ${String(v.key)}${v.target ? ` on ${String(v.target)}` : ""})`;
1250
2060
  case "batch":
1251
2061
  return Array.isArray(v.actions) ? `(${v.actions.length} steps: ${v.actions.map((a: { verb?: string }) => a.verb).join(", ")})` : "(batch)";
1252
2062
  default:
@@ -1273,34 +2083,41 @@ function renderCaptionWords(text: string) {
1273
2083
  ));
1274
2084
  }
1275
2085
 
2086
+ // "Waybalance" — three real, irregular stones (an ellipse plus a smaller
2087
+ // bump, not a rectangle), stacked slightly off-center the way a hiker
2088
+ // actually balances a trail cairn, instead of the perfectly centered flat
2089
+ // bars this replaced. Same mark as docs/images/logo.svg and site/index.html's
2090
+ // nav badge, just currentColor here so it inherits the button's own color.
1276
2091
  function CairnMark() {
1277
2092
  return (
1278
2093
  <svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
1279
- <rect x="7" y="12.5" width="6" height="2.6" rx="0.5" fill="currentColor" />
1280
- <rect x="4.5" y="8.5" width="11" height="2.6" rx="0.5" fill="currentColor" opacity="0.75" />
1281
- <rect x="8.2" y="4.5" width="3.6" height="2.6" rx="0.5" fill="currentColor" opacity="0.5" />
2094
+ <ellipse cx="10" cy="14.6" rx="6.1" ry="2.3" fill="currentColor" />
2095
+ <ellipse cx="6.3" cy="14.1" rx="2.5" ry="1.6" fill="currentColor" />
2096
+ <ellipse cx="9.1" cy="10.4" rx="4.3" ry="2" fill="currentColor" opacity="0.82" />
2097
+ <ellipse cx="6.2" cy="10" rx="1.7" ry="1.2" fill="currentColor" opacity="0.82" />
2098
+ <ellipse cx="11.7" cy="6.4" rx="2.6" ry="1.6" fill="currentColor" opacity="0.6" />
1282
2099
  </svg>
1283
2100
  );
1284
2101
  }
1285
2102
 
2103
+ // Real-time lifecycle logging — every message received, every decision made
2104
+ // about it (played, spoken, dropped, and why), every state transition. Added
2105
+ // specifically so a live session's actual behavior is visible in the browser
2106
+ // console instead of only inferable from symptoms after the fact — every bug
2107
+ // found and fixed in this file today was diagnosed from screenshots and
2108
+ // terminal output because nothing like this existed before. `[cairn rt]` is
2109
+ // the tag to filter on. Deliberately excludes per-audio_chunk noise (dozens
2110
+ // of chunks per turn would flood the console) — chunk activity shows up as
2111
+ // a one-line count at speaking_end/turn_complete instead.
2112
+ function rtLog(event: string, details?: Record<string, unknown>): void {
2113
+ if (details) console.log("[cairn rt]", event, details);
2114
+ else console.log("[cairn rt]", event);
2115
+ }
2116
+
1286
2117
  // ---------------------------------------------------------------------------
1287
2118
  // Audio helpers (real-time PCM16 capture — standard Web Audio API patterns)
1288
2119
  // ---------------------------------------------------------------------------
1289
2120
 
1290
- // Heuristic energy gate for barge-in: real speech into a laptop/phone mic
1291
- // typically sits well above this; normal room noise and the mic's own
1292
- // noise floor typically sit below it. Not calibrated against real hardware
1293
- // in this environment (no live mic here) — reasonable starting point, may
1294
- // need tuning against a real device if it proves too trigger-happy or too
1295
- // insensitive in practice.
1296
- const BARGE_IN_RMS_THRESHOLD = 0.02;
1297
-
1298
- function computeRms(channelData: Float32Array): number {
1299
- let sumSquares = 0;
1300
- for (let i = 0; i < channelData.length; i++) sumSquares += channelData[i] * channelData[i];
1301
- return Math.sqrt(sumSquares / channelData.length);
1302
- }
1303
-
1304
2121
  function downsampleTo16k(input: Float32Array, inputSampleRate: number): Float32Array {
1305
2122
  const targetRate = 16000;
1306
2123
  if (inputSampleRate === targetRate) return input;
@@ -1513,6 +2330,10 @@ const COPILOT_STYLES = `
1513
2330
  gap: 4px;
1514
2331
  padding: 2px 0;
1515
2332
  }
2333
+ .cairn-thinking-inline {
2334
+ margin-left: 6px;
2335
+ vertical-align: middle;
2336
+ }
1516
2337
  .cairn-thinking-dot {
1517
2338
  width: 5px;
1518
2339
  height: 5px;