@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/dist/agent-loop.d.ts +113 -0
- package/dist/agent-loop.js +128 -0
- package/dist/cairn-widget.js +2 -2
- package/dist/element-ladder.d.ts +71 -0
- package/dist/element-ladder.js +168 -0
- package/dist/index.d.ts +79 -1
- package/dist/index.js +864 -89
- package/dist/key-rotator.d.ts +28 -0
- package/dist/key-rotator.js +57 -3
- package/dist/memory-sqlite.d.ts +86 -0
- package/dist/memory-sqlite.js +230 -0
- package/dist/realtime-cli.js +22 -1
- package/dist/realtime-server.d.ts +83 -2
- package/dist/realtime-server.js +549 -120
- package/dist/server.d.ts +249 -5
- package/dist/server.js +984 -77
- package/dist/skill-store.d.ts +17 -0
- package/dist/skill-store.js +78 -0
- package/dist/tts-stream.d.ts +25 -0
- package/dist/tts-stream.js +32 -0
- package/dist/vad.d.ts +27 -0
- package/dist/vad.js +128 -0
- package/dist/verb-executor.d.ts +32 -11
- package/dist/verb-executor.js +224 -16
- package/dist/webmcp-client.d.ts +14 -1
- package/dist/webmcp-client.js +22 -1
- package/package.json +3 -1
- package/src/agent-loop.ts +222 -0
- package/src/element-ladder.ts +170 -0
- package/src/index.tsx +914 -93
- package/src/key-rotator.ts +57 -2
- package/src/memory-sqlite.ts +283 -0
- package/src/realtime-cli.ts +24 -1
- package/src/realtime-server.ts +655 -122
- package/src/server.ts +1077 -77
- package/src/skill-store.ts +88 -0
- package/src/tts-stream.ts +30 -0
- package/src/vad.ts +153 -0
- package/src/verb-executor.ts +243 -22
- package/src/web-component.ts +82 -17
- package/src/webmcp-client.ts +30 -2
package/src/index.tsx
CHANGED
|
@@ -16,12 +16,14 @@ import {
|
|
|
16
16
|
VolumeX,
|
|
17
17
|
X,
|
|
18
18
|
} from "lucide-react";
|
|
19
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
677
|
+
// See typedPlaybackSuspendedRef's own doc comment — this 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
985
|
+
...result.workingHistory,
|
|
506
986
|
{ role: "user", text: q } satisfies HistoryEntry,
|
|
507
|
-
{ role: "assistant", text:
|
|
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)
|
|
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
|
|
789
|
-
// them wait — including during a guided
|
|
790
|
-
// rest of the walkthrough on
|
|
791
|
-
// instead of being talked-over-
|
|
792
|
-
// person giving a tour stops when
|
|
793
|
-
// "rt-thinking" half matters just as much
|
|
794
|
-
// turn can easily take a couple of seconds
|
|
795
|
-
// yet, and without this the mic was completely
|
|
796
|
-
// whole window — found live as "not listening
|
|
797
|
-
// interrupting system", not just a missed
|
|
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
|
|
800
|
-
|
|
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
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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
|
-
|
|
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 && !
|
|
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
|
-
|
|
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">
|
|
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
|
-
<
|
|
1280
|
-
<
|
|
1281
|
-
<
|
|
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;
|