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