@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
@@ -29,13 +29,18 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
29
29
  return (mod && mod.__esModule) ? mod : { "default": mod };
30
30
  };
31
31
  Object.defineProperty(exports, "__esModule", { value: true });
32
+ exports.formatRememberedFacts = exports.seedHistoryFromMemory = void 0;
32
33
  exports.createRealtimeServer = createRealtimeServer;
33
34
  exports.handleDeepgramMessage = handleDeepgramMessage;
34
35
  const node_http_1 = __importDefault(require("node:http"));
35
36
  const ws_1 = require("ws");
36
37
  const core_1 = require("@cairnvibe/core");
38
+ const agent_loop_1 = require("./agent-loop");
37
39
  const server_1 = require("./server");
38
40
  const tts_stream_1 = require("./tts-stream");
41
+ const memory_sqlite_1 = require("./memory-sqlite");
42
+ Object.defineProperty(exports, "formatRememberedFacts", { enumerable: true, get: function () { return memory_sqlite_1.formatRememberedFacts; } });
43
+ Object.defineProperty(exports, "seedHistoryFromMemory", { enumerable: true, get: function () { return memory_sqlite_1.seedHistoryFromMemory; } });
39
44
  const DEEPGRAM_LIVE_URL = "wss://api.deepgram.com/v1/listen";
40
45
  const DEFAULT_STT_MODEL = "nova-2";
41
46
  const DEFAULT_TTS_VOICE = "aura-2-thalia-en";
@@ -44,15 +49,78 @@ const DEFAULT_TTS_VOICE = "aura-2-thalia-en";
44
49
  // something within about a second instead of dead air while the real
45
50
  // multi-step work runs. A short rotating set, not one fixed line, so it
46
51
  // doesn't read as a canned bot phrase on every multi-step question.
47
- const ACK_PHRASES = ["Let me check that for you.", "One moment, let me look into that.", "Give me a second to check.", "Let me take a look."];
52
+ //
53
+ // Phase 2 step 3 — rewritten from the original set (kept below in spirit
54
+ // but not verbatim: "Let me check that for you." / "One moment, let me
55
+ // look into that." / "Give me a second to check." / "Let me take a
56
+ // look.") for two real, testable reasons, not a vibe change: (1) the
57
+ // plan's own bar is "feels like a person coordinating a team ('give me
58
+ // a sec, sorting that out'), never generic-corporate" — the original
59
+ // set's formal, service-desk phrasing ("One moment, let me look into
60
+ // that") is closer to a phone-tree script than a coworker; (2) "kept
61
+ // short on purpose (a long ack costs real latency budget)" — the
62
+ // original set averaged 6 words; this one averages under 4, a real,
63
+ // measurable reduction in synthesis time before the ack is even
64
+ // audible, on top of sounding more like a person. Graded on this now,
65
+ // not eyeballed — see judge.ts's new `persona` dimension and
66
+ // realtime-server.ts's own "ack" message, which exposes the actual
67
+ // spoken text to packages/evals' trace capture for the first time.
68
+ const ACK_PHRASES = ["Give me a sec.", "One sec, checking.", "Hang on, let me look.", "On it, one sec.", "Just a sec here.", "Let me check real quick."];
48
69
  // Not constrained by any telephony 8kHz requirement — this is just "what
49
70
  // quality does Deepgram render at" for browser playback, and the Web Audio
50
71
  // API resamples an AudioBuffer at any declared rate transparently.
51
72
  const TTS_SAMPLE_RATE = 24000;
73
+ /**
74
+ * Phase 5 step 2 — explicit fact-remembering (Track B's own "remember is
75
+ * an explicit act, never automatic" pattern — step 1 built the automatic
76
+ * turn-recording half; this is the deliberate half). Modeled as a
77
+ * SYNTHETIC WebMCP tool the model can call_tool, not a new verb — reuses
78
+ * the existing call_tool grammar/validation the model already knows
79
+ * ("a tool name from this turn's webMcpTools list") instead of inventing
80
+ * a new one. Handled entirely SERVER-SIDE (see executeStep below) —
81
+ * the client never learns this step happened at all (see onStep below
82
+ * for why that's not just an optimization: it's what keeps this safe).
83
+ */
84
+ const REMEMBER_FACT_TOOL_NAME = "remember_fact";
85
+ const REMEMBER_FACT_TOOL = {
86
+ name: REMEMBER_FACT_TOOL_NAME,
87
+ description: "Remember something worth recalling in a FUTURE conversation with this same user — a stated preference, a known pitfall, anything that would help next time. Not for facts only relevant to answering right now. Call this AT MOST ONCE per turn, for one real fact. Once it returns, the fact is already saved — immediately give your final spoken answer (e.g. explain) confirming that to the user; do not call this again in the same turn.",
88
+ inputSchema: {
89
+ type: "object",
90
+ properties: {
91
+ key: { type: "string", description: "A short, stable name for this fact, e.g. \"preferredUnits\" or \"flakySelectorNote\"." },
92
+ value: { type: "string", description: "The real fact to remember, in plain language." },
93
+ },
94
+ required: ["key", "value"],
95
+ },
96
+ };
97
+ async function handleRememberFactTool(memory, scopeId, args) {
98
+ const key = typeof args?.key === "string" ? args.key.trim() : "";
99
+ const value = typeof args?.value === "string" ? args.value.trim() : "";
100
+ if (!key || !value)
101
+ return "Could not remember that — a key and a value are both required.";
102
+ memory.rememberFact(scopeId, key, value);
103
+ return `Remembered: ${key} = ${value}`;
104
+ }
52
105
  function createRealtimeServer(options) {
53
106
  const registeredActions = options.registeredActions ?? [];
54
107
  const capability = options.capability ?? "act";
55
- const llm = (0, server_1.createVerbLLM)(options);
108
+ // One shared rotator across all three LLM roles — see
109
+ // CreateCopilotHandlerOptions.keyRotator's own doc comment for the real
110
+ // gap this closes (a key one role confirmed dead used to stay invisible
111
+ // to the other two, which kept rediscovering it fresh on every call).
112
+ // Only built for groq — anthropic's createXLLM calls ignore keyRotator
113
+ // entirely, so building one for it would be dead work. Respects a
114
+ // caller-supplied options.keyRotator (e.g. shared with the typed/HTTP
115
+ // transport in the same process) instead of always building a fresh one.
116
+ const sharedOptions = options.provider === "groq" && !options.keyRotator
117
+ ? { ...options, keyRotator: options.apiKeys ? new server_1.KeyRotator(options.apiKeys) : options.apiKey ? new server_1.KeyRotator([options.apiKey]) : server_1.KeyRotator.fromEnvList(process.env.GROQ_API_KEYS) ?? undefined }
118
+ : options;
119
+ const llm = (0, server_1.createVerbLLM)(sharedOptions);
120
+ // Phase 3 steps 2-3 — real, separately-configured Planner/Critic LLMs.
121
+ // See finalizeTurn's own doc comment for how they're actually used.
122
+ const planLLM = (0, server_1.createPlanLLM)(sharedOptions);
123
+ const criticLLM = (0, server_1.createCriticLLM)(sharedOptions);
56
124
  // "text" is optional on highlight/open/navigate/do in the base prompt —
57
125
  // fine for the typed/HTTP path, which always has a visible answer area,
58
126
  // but silence reads as broken in a live voice conversation (the client
@@ -60,7 +128,8 @@ function createRealtimeServer(options) {
60
128
  // instruction asks for a confirmation grounded in what was actually
61
129
  // done, not filler — generic phrasing here is what made replies feel
62
130
  // "unrelated" to the question that was just asked.
63
- const systemPrompt = (0, server_1.buildSystemPrompt)(options.manifest, registeredActions, options.persona) +
131
+ const actionDescriptions = options.actionDescriptions ?? {};
132
+ const systemPrompt = (0, server_1.buildSystemPrompt)(options.manifest, registeredActions, options.persona, actionDescriptions) +
64
133
  `\n\nYou are in a live voice conversation right now — the user is speaking out loud and may not be looking at the screen. For highlight/open/navigate/do, include a short spoken "text" that names the specific thing you're pointing at or the specific place you're sending them (e.g. "Highlighting the New Invoice button" or "Taking you to Invoices"), not a generic filler phrase — so they hear a confirmation that's actually about their question.`;
65
134
  const sttModel = options.sttModel ?? process.env.DEEPGRAM_MODEL ?? DEFAULT_STT_MODEL;
66
135
  const ttsVoice = options.ttsVoice ?? process.env.DEEPGRAM_VOICE ?? DEFAULT_TTS_VOICE;
@@ -70,17 +139,50 @@ function createRealtimeServer(options) {
70
139
  res.end("cairn realtime relay\n");
71
140
  });
72
141
  const wss = new ws_1.WebSocketServer({ server: httpServer });
142
+ // Real, server-side visibility into how many browser tabs/connections
143
+ // are actually live at once — added specifically to answer, with real
144
+ // data instead of a guess, a live-raised concern: could a page reload
145
+ // (or several in quick succession) leave more than one realtime
146
+ // connection open at the same time, each independently running its own
147
+ // Deepgram STT/TTS and LLM calls for the same user? Every connection
148
+ // gets a short id, logged on open and close, alongside a live count —
149
+ // if that count is ever more than 1 during normal single-tab use, THAT
150
+ // is the real, direct evidence of a genuine duplicate-connection bug;
151
+ // if it always reads 1, duplication server-side is ruled out with real
152
+ // proof, not assumed away.
153
+ let nextConnectionId = 1;
154
+ let activeConnections = 0;
73
155
  wss.on("connection", (client) => {
74
- handleConnection(client, { deepgramApiKey, sttModel, ttsVoice, llm, systemPrompt, manifest: options.manifest, registeredActions, capability }).catch((err) => {
75
- console.error("[cairn realtime] connection error:", err);
156
+ const connectionId = nextConnectionId++;
157
+ activeConnections++;
158
+ console.log(`[cairn realtime] connection ${connectionId} opened — ${activeConnections} active`);
159
+ client.on("close", () => {
160
+ activeConnections--;
161
+ console.log(`[cairn realtime] connection ${connectionId} closed — ${activeConnections} active`);
162
+ });
163
+ handleConnection(client, {
164
+ deepgramApiKey,
165
+ sttModel,
166
+ ttsVoice,
167
+ llm,
168
+ planLLM,
169
+ criticLLM,
170
+ systemPrompt,
171
+ manifest: options.manifest,
172
+ registeredActions,
173
+ actionDescriptions,
174
+ capability,
175
+ memory: options.memory,
176
+ skills: options.skills,
177
+ skillsScopeId: options.skillsScopeId,
178
+ }).catch((err) => {
179
+ console.error(`[cairn realtime] connection ${connectionId} error:`, err);
76
180
  safeSend(client, { type: "error", message: "internal error" });
77
181
  client.close();
78
182
  });
79
183
  });
80
184
  return httpServer;
81
185
  }
82
- const MAX_HISTORY_TURNS = 8; // 4 exchanges — enough for "the first one"/"do that instead" without growing the prompt unbounded over a long call
83
- const MAX_LOOP_ITERATIONS = 6; // a hard cap on one turn's agent-loop steps, not a target — see finalizeTurn
84
186
  async function handleConnection(client, deps) {
85
187
  // liveElements/webMcpTools refresh on every "context" resend (the client
86
188
  // sends one on route changes and each time it's about to start listening
@@ -97,6 +199,22 @@ async function handleConnection(client, deps) {
97
199
  // one WebSocket per call — so this is accumulated here directly rather
98
200
  // than round-tripped through the client.
99
201
  const history = [];
202
+ // Phase 5 — real cross-session memory. `scopeId` is set from the FIRST
203
+ // "context" message that carries one (see the "context" handler below)
204
+ // and never changed again for the life of this connection — a real,
205
+ // deliberate v1 simplification (no attempt to handle a scopeId that
206
+ // legitimately changes mid-connection, e.g. a mid-session login) rather
207
+ // than guessed-at complexity. `historySeededFromMemory` guards the
208
+ // ONE-TIME load of this scope's prior turns into `history` — a later
209
+ // "context" resend (route changes send fresh ones routinely) must never
210
+ // re-seed and duplicate them.
211
+ let scopeId = null;
212
+ let historySeededFromMemory = false;
213
+ function recordMemoryTurn(role, text) {
214
+ if (!deps.memory || !scopeId)
215
+ return;
216
+ deps.memory.recordTurn(scopeId, role, text);
217
+ }
100
218
  // Resolves the agent loop's in-flight waitForToolResult() call once the
101
219
  // client reports back what a click/fill/read/call_tool step actually
102
220
  // did — same "a mutable pending-callback slot, resolved when the right
@@ -104,14 +222,70 @@ async function handleConnection(client, deps) {
104
222
  let pendingToolResultResolve = null;
105
223
  const dgUrl = `${DEEPGRAM_LIVE_URL}?model=${encodeURIComponent(deps.sttModel)}` +
106
224
  `&encoding=linear16&sample_rate=16000&channels=1&interim_results=true&endpointing=300&utterance_end_ms=1000`;
107
- const dg = new ws_1.WebSocket(dgUrl, { headers: { Authorization: `Token ${deps.deepgramApiKey}` } });
225
+ // Real, live-reported bug this closes: "status says Listening but nothing
226
+ // happens" — the client keeps looking and sounding fine (mic still
227
+ // capturing, WS still open, no error ever shown), because the REAL
228
+ // failure is silent and one layer deeper: Deepgram's own STT connection
229
+ // can close mid-session (an idle timeout, a network blip, Deepgram's own
230
+ // connection lifetime limit) and this code never noticed — there was no
231
+ // `dg.on("close", ...)` handler at all, `dgOpen` never got reset to
232
+ // false, and every subsequent mic frame kept calling `dg.send(buf)` on an
233
+ // already-CLOSED socket with no callback to catch the failure. The client
234
+ // never heard about any of this, because nothing here ever sent it an
235
+ // "error" message — from the outside it looks exactly like "listening,
236
+ // but the mic just isn't picking anything up."
237
+ //
238
+ // Fixed by making the STT connection self-healing instead of a single
239
+ // fire-and-forget WebSocket: `dg` is now reassignable, and a real close
240
+ // triggers a bounded number of automatic reconnects (fresh handshake,
241
+ // same handlers) before finally giving up and telling the client — so a
242
+ // transient Deepgram-side drop recovers on its own instead of silently
243
+ // bricking the rest of the call.
244
+ let dg;
108
245
  let dgOpen = false;
246
+ let dgReconnectAttempts = 0;
247
+ const MAX_DG_RECONNECT_ATTEMPTS = 3;
109
248
  const pendingAudio = [];
110
- dg.on("open", () => {
111
- dgOpen = true;
112
- for (const chunk of pendingAudio.splice(0))
113
- dg.send(chunk);
114
- });
249
+ // Accumulates Deepgram "Results" transcript segments across one utterance
250
+ // see handleDeepgramMessage for why this can't just react to every
251
+ // is_final. Declared before connectDeepgramStt so its own "message"
252
+ // handler closes over an already-initialized binding, not just a
253
+ // same-scope one that happens to be safe only because WS events are
254
+ // always async.
255
+ const turnState = { buffer: "" };
256
+ function connectDeepgramStt() {
257
+ const socket = new ws_1.WebSocket(dgUrl, { headers: { Authorization: `Token ${deps.deepgramApiKey}` } });
258
+ dg = socket;
259
+ socket.on("open", () => {
260
+ dgOpen = true;
261
+ dgReconnectAttempts = 0;
262
+ for (const chunk of pendingAudio.splice(0))
263
+ socket.send(chunk);
264
+ });
265
+ socket.on("message", (data) => {
266
+ void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history, turnState, () => generation, waitForToolResult, recordMemoryTurn, () => scopeId, () => {
267
+ generation++;
268
+ });
269
+ });
270
+ socket.on("error", (err) => {
271
+ console.error("[cairn realtime] Deepgram STT connection error:", err);
272
+ });
273
+ socket.on("close", (code, reason) => {
274
+ dgOpen = false;
275
+ console.log(`[cairn realtime] Deepgram STT connection closed (code ${code}${reason ? `, ${reason}` : ""})`);
276
+ if (client.readyState !== ws_1.WebSocket.OPEN)
277
+ return; // the whole call already ended — nothing to reconnect for
278
+ if (dgReconnectAttempts >= MAX_DG_RECONNECT_ATTEMPTS) {
279
+ console.error(`[cairn realtime] Deepgram STT gave up reconnecting after ${MAX_DG_RECONNECT_ATTEMPTS} attempts`);
280
+ safeSend(client, { type: "error", message: "Speech recognition connection was lost and couldn't be restored — try starting the call again." });
281
+ return;
282
+ }
283
+ dgReconnectAttempts++;
284
+ console.log(`[cairn realtime] reconnecting to Deepgram STT (attempt ${dgReconnectAttempts}/${MAX_DG_RECONNECT_ATTEMPTS})`);
285
+ connectDeepgramStt();
286
+ });
287
+ }
288
+ connectDeepgramStt();
115
289
  // ONE Speak connection reused for every turn in this session — a fresh
116
290
  // handshake per turn is a real, measurable chunk of the latency this
117
291
  // rewrite exists to remove. Recreated on demand if it ever drops.
@@ -141,10 +315,22 @@ async function handleConnection(client, deps) {
141
315
  }
142
316
  return { stream: speakStream, ready: speakStreamReady };
143
317
  }
144
- // Discards whatever the current turn is still synthesizing/sending, and
145
- // unsticks a pending speakStreamed() call if one is in flight — Deepgram's
146
- // "Clear" isn't guaranteed to itself trigger a "Flushed" confirmation, so
147
- // without this the interrupted call's promise would hang forever.
318
+ // A real, live-reported bug in what used to live here: a "confirm-or-
319
+ // reverse" grace window that, on ANY barge-in with no confirming STT
320
+ // transcript arriving within 600ms, concluded it was a false positive
321
+ // and RE-SPOKE THE SAME TEXT FROM THE TOP. Live symptom, reported
322
+ // directly: saying "stop" cut the agent off, paused for about a
323
+ // second, then the exact same answer started playing again from the
324
+ // beginning — because Deepgram's own transcript for "stop" routinely
325
+ // arrived a little later than the 600ms window, so every clean,
326
+ // deliberate interruption looked exactly like an unconfirmed false
327
+ // alarm and got "resumed." Direct user instruction: there should be no
328
+ // such system at all — a barge-in should behave like it does in any
329
+ // normal voice assistant, an immediate, permanent stop, never a guess
330
+ // at whether to talk over the user again. `triggerServerBargeIn` is
331
+ // now exactly that: bump generation (drops any audio/verb already in
332
+ // flight), clear the TTS stream, unstick a pending speakStreamed()
333
+ // call — and nothing else.
148
334
  function triggerServerBargeIn() {
149
335
  generation++;
150
336
  speakStream?.clear();
@@ -156,24 +342,24 @@ async function handleConnection(client, deps) {
156
342
  stream.setAudioHandler((chunk) => {
157
343
  if (myGeneration !== generation)
158
344
  return; // stale — dropped by barge-in
159
- safeSend(client, { type: "audio_chunk", audio: chunk.toString("base64"), sampleRate: TTS_SAMPLE_RATE });
345
+ safeSend(client, { type: "audio_chunk", audio: chunk.toString("base64"), sampleRate: TTS_SAMPLE_RATE, generation: myGeneration });
160
346
  });
161
347
  await ready;
162
348
  if (!speakStream || myGeneration !== generation) {
163
349
  // Reconnect failed, or barge-in happened before the stream connected —
164
350
  // either way, only degrade to turn_complete if this is still current.
165
351
  if (myGeneration === generation)
166
- safeSend(client, { type: "turn_complete" });
352
+ safeSend(client, { type: "turn_complete", generation: myGeneration });
167
353
  return;
168
354
  }
169
355
  await new Promise((resolve) => {
170
356
  onCurrentTurnFlushed = () => {
171
357
  onCurrentTurnFlushed = null;
172
358
  if (myGeneration === generation)
173
- safeSend(client, { type: "speaking_end" });
359
+ safeSend(client, { type: "speaking_end", generation: myGeneration });
174
360
  resolve();
175
361
  };
176
- safeSend(client, { type: "speaking_start" });
362
+ safeSend(client, { type: "speaking_start", generation: myGeneration });
177
363
  stream.sendText(text);
178
364
  stream.flush();
179
365
  });
@@ -197,21 +383,16 @@ async function handleConnection(client, deps) {
197
383
  }, 15000);
198
384
  });
199
385
  }
200
- // Accumulates Deepgram "Results" transcript segments across one utterance
201
- // — see handleDeepgramMessage for why this can't just react to every
202
- // is_final.
203
- const turnState = { buffer: "" };
204
- dg.on("message", (data) => {
205
- void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history, turnState, () => generation, waitForToolResult);
206
- });
207
- dg.on("error", (err) => {
208
- console.error("[cairn realtime] Deepgram STT connection error:", err);
209
- safeSend(client, { type: "error", message: "speech recognition unavailable" });
210
- });
211
386
  client.on("message", (data, isBinary) => {
212
387
  if (isBinary) {
213
388
  const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
214
- if (dgOpen)
389
+ // The readyState check (not just dgOpen) is real, defensive belt-and-
390
+ // suspenders: dgOpen is reset to false the instant "close" fires, but
391
+ // a mic frame arriving in the same tick as a not-yet-processed close
392
+ // event should never risk calling .send() on a socket that's already
393
+ // gone — that used to be exactly how a dead connection kept silently
394
+ // swallowing audio with no error ever surfacing.
395
+ if (dgOpen && dg.readyState === ws_1.WebSocket.OPEN)
215
396
  dg.send(buf);
216
397
  else
217
398
  pendingAudio.push(buf);
@@ -226,6 +407,32 @@ async function handleConnection(client, deps) {
226
407
  liveElements: parseLiveElements(msg.liveElements),
227
408
  webMcpTools: parseWebMcpTools(msg.webMcpTools),
228
409
  };
410
+ // Phase 5 — `scopeId` is whatever opaque id the CUSTOMER's own
411
+ // client code chooses to send (their own end-user id if they have
412
+ // login, anything else stable otherwise) — this SDK never invents
413
+ // one. Only the first real scopeId this connection ever sees is
414
+ // used; a later "context" resend's scopeId (route changes send
415
+ // these routinely) is ignored, and the one-time prior-turn load
416
+ // below never repeats.
417
+ if (!scopeId && typeof msg.scopeId === "string" && msg.scopeId) {
418
+ const newScopeId = msg.scopeId;
419
+ scopeId = newScopeId;
420
+ if (deps.memory && !historySeededFromMemory) {
421
+ historySeededFromMemory = true;
422
+ const priorTurns = deps.memory.recentTurns(newScopeId);
423
+ const seeded = (0, memory_sqlite_1.seedHistoryFromMemory)(history, priorTurns, agent_loop_1.MAX_HISTORY_TURNS);
424
+ history.length = 0;
425
+ history.push(...seeded);
426
+ // Prepended AFTER the cap above, deliberately exempt from
427
+ // it — a remembered fact ("prefers metric units") should
428
+ // stay in context for the WHOLE connection, not age out the
429
+ // same way an ordinary conversation turn does once enough
430
+ // new turns accumulate.
431
+ const factsSummary = (0, memory_sqlite_1.formatRememberedFacts)(deps.memory.recallFacts(newScopeId));
432
+ if (factsSummary)
433
+ history.unshift({ role: "assistant", text: factsSummary });
434
+ }
435
+ }
229
436
  }
230
437
  else if (msg.type === "tool_result" && typeof msg.observation === "string") {
231
438
  // The client finished executing a click/fill/read/call_tool step
@@ -277,7 +484,37 @@ async function handleConnection(client, deps) {
277
484
  speakStream?.close();
278
485
  });
279
486
  }
280
- async function handleDeepgramMessage(raw, client, deps, getContext, speakStreamed, history, turnState, getGeneration, waitForToolResult) {
487
+ async function handleDeepgramMessage(raw, client, deps, getContext, speakStreamed, history, turnState, getGeneration, waitForToolResult,
488
+ /** Phase 5 — called with each real (role, text) turn as it's finalized,
489
+ * right alongside the same-shaped `history.push`. Optional and a no-op
490
+ * by default so every existing call site keeps working unchanged. The
491
+ * realtime connection's own recordMemoryTurn writes it to durable
492
+ * storage when memory + a scopeId are both configured for this
493
+ * connection — see ConnectionDeps.memory's own doc comment. */
494
+ recordMemoryTurn,
495
+ /** Phase 5 step 2 — see finalizeTurn's own doc comment. Threaded
496
+ * through here purely to reach finalizeTurn's two call sites below. */
497
+ getScopeId,
498
+ /** Real, live-found gap this closes: `generation` (getGeneration/
499
+ * triggerServerBargeIn) previously only ever bumped on an EXPLICIT
500
+ * barge-in — two ordinary, sequential turns with no interruption
501
+ * between them shared the exact same generation number. That was
502
+ * fine for what `generation` was originally built for (dropping
503
+ * audio/verbs abandoned mid-turn by a real interruption), but it
504
+ * left the CLIENT's own generation-based staleness check (added for
505
+ * that same reason, in index.tsx) with no way to tell a merely SLOW
506
+ * turn's late-arriving reply apart from the current one — nothing
507
+ * had bumped, so the late reply's generation still matched. Found
508
+ * live: a "hello" reply that took long enough to arrive AFTER the
509
+ * next question's own "final" had already fired, landing on the
510
+ * wrong caption because both were tagged the same generation.
511
+ * Called once per genuinely NEW turn (both call sites below), so
512
+ * every real "final" gets its own fresh generation — a turn is now
513
+ * "superseded" the instant a newer one starts, not only when an
514
+ * explicit interruption says so. Optional and a no-op by default so
515
+ * every existing call site (own or a test's) that doesn't pass this
516
+ * keeps behaving exactly as before. */
517
+ bumpGeneration) {
281
518
  let msg;
282
519
  try {
283
520
  msg = JSON.parse(raw);
@@ -290,8 +527,10 @@ async function handleDeepgramMessage(raw, client, deps, getContext, speakStreame
290
527
  // after utterance_end_ms of silence — a safety net for the rare case a
291
528
  // Results message never carries speech_final:true, so a turn can't get
292
529
  // permanently stuck with real transcript sitting in the buffer forever.
293
- if (turnState.buffer)
294
- await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration, waitForToolResult);
530
+ if (turnState.buffer) {
531
+ bumpGeneration?.();
532
+ await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration, waitForToolResult, recordMemoryTurn, getScopeId);
533
+ }
295
534
  return;
296
535
  }
297
536
  if (msg.type !== "Results")
@@ -318,7 +557,8 @@ async function handleDeepgramMessage(raw, client, deps, getContext, speakStreame
318
557
  safeSend(client, { type: "interim", text: turnState.buffer });
319
558
  return;
320
559
  }
321
- await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration, waitForToolResult);
560
+ bumpGeneration?.();
561
+ await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration, waitForToolResult, recordMemoryTurn, getScopeId);
322
562
  }
323
563
  /**
324
564
  * Everything from here on (the LLM call, TTS streaming) can fail in ways
@@ -341,47 +581,159 @@ async function handleDeepgramMessage(raw, client, deps, getContext, speakStreame
341
581
  * ones aren't) doesn't end the turn here: the server can't execute a DOM
342
582
  * action itself, so it sends the step to the client, awaits its real
343
583
  * result over waitForToolResult(), folds that into a *local* working copy
344
- * of history, and calls resolveVerb again — repeat up to
345
- * MAX_LOOP_ITERATIONS. The connection's real `history` only gets the
346
- * user's real question plus the turn's final answer, committed once at
347
- * the end a turn that hits the cap mid-loop doesn't leave partial tool
348
- * noise in the conversation's real memory, same discipline the HTTP
349
- * path's runTypedAgentLoop (index.tsx) follows.
584
+ * of history, and calls resolveVerb again — repeat up to the iteration cap
585
+ * (driveAgentLoop's default of 6, in agent-loop.ts — the shared skeleton
586
+ * this function and the HTTP path's runTypedAgentLoop (index.tsx) both
587
+ * drive). The connection's real `history` only gets the user's real
588
+ * question plus the turn's final answer, committed once at the end here —
589
+ * a turn that hits the cap mid-loop doesn't leave partial tool noise in
590
+ * the conversation's real memory.
350
591
  */
351
- async function finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration, waitForToolResult) {
592
+ async function finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration, waitForToolResult, recordMemoryTurn,
593
+ /** Phase 5 step 2 — this connection's real scopeId, if it has one yet
594
+ * (see the "context" message handler). A getter, same pattern as
595
+ * getContext/getGeneration, since it can be set AFTER this turn
596
+ * already started (a scopeId only ever arrives via a "context"
597
+ * message, and a turn can begin before one has). Optional; absent or
598
+ * returning null both mean "no memory-backed tools offered." */
599
+ getScopeId) {
352
600
  const transcript = turnState.buffer;
353
601
  turnState.buffer = "";
354
602
  const myGeneration = getGeneration();
355
- safeSend(client, { type: "final", text: transcript });
356
- let loopHistory = history;
603
+ safeSend(client, { type: "final", text: transcript, generation: myGeneration });
357
604
  // The Talker: set once, the first time a turn turns out to need more
358
- // than one step (see the loop below) — a real, in-flight speakStreamed()
605
+ // than one step (see onStep below) — a real, in-flight speakStreamed()
359
606
  // call, never awaited until we're actually ready to speak the real
360
607
  // answer. Deliberately not re-triggered per step: the Speak connection
361
608
  // (speakStreamed) only ever handles one utterance at a time, so a second
362
609
  // ack mid-loop would race the first one's own audio_chunk/Flushed
363
610
  // handling instead of queuing cleanly.
364
611
  let ackPromise = null;
612
+ // Phase 3 step 5 — the Talker's real event stream ("Revisable by
613
+ // Design"'s pattern): a pure, fire-and-forget consumer, never awaited
614
+ // by driveAgentLoop, never able to affect its control flow. This
615
+ // realtime transport's own Talker projection is intentionally small —
616
+ // the only event type it currently DOES anything with is "inj" (the
617
+ // ack phrase), which it turns into the same real speakStreamed() call
618
+ // as before, just reached through a real event instead of an inline
619
+ // side effect inside onStep. "act"/"obs"/"thk" events flow through the
620
+ // same stream (driveAgentLoop already emits act/obs on its own; the
621
+ // Critic below emits a real "thk" with its own reasoning) but aren't
622
+ // consumed for anything yet — logged, not narrated, a real seam for a
623
+ // future richer Talker to attach to without touching the loop again.
624
+ function emitEvent(event) {
625
+ switch (event.type) {
626
+ case "inj":
627
+ // Phase 2 step 3 — the ack phrase's audio was already the only
628
+ // thing the user hears; this text-bearing sibling message makes
629
+ // WHAT was said visible in the wire protocol too — today the
630
+ // only way to know (packages/evals' voiceFrames capture full
631
+ // frames, but an "inj" event never otherwise reaches the client
632
+ // as readable text, only as synthesized audio). Purely
633
+ // informational — a client that ignores unknown message types
634
+ // loses nothing.
635
+ safeSend(client, { type: "ack", text: event.text });
636
+ ackPromise = speakStreamed(event.text);
637
+ return;
638
+ case "act":
639
+ console.log("[cairn talker] act:", (0, agent_loop_1.summarizeVerbForHistory)(event.verb));
640
+ return;
641
+ case "obs":
642
+ console.log("[cairn talker] obs:", event.observation);
643
+ return;
644
+ case "thk":
645
+ console.log("[cairn talker] thk:", event.text);
646
+ return;
647
+ }
648
+ }
649
+ // Architecture Pillar 4 — real Plan/Progress state the Critic (below)
650
+ // actually acts on, not just observability. Started EAGERLY, before the
651
+ // first real step even runs, when looksMultiStep(transcript) already
652
+ // flags this as a probable compound goal — replacing the old lazy gate
653
+ // (kicked off only once a turn had already revealed a non-terminal
654
+ // first step, one full model round trip later than it needed to be).
655
+ // A false-negative heuristic miss still falls back to that same lazy
656
+ // path below (`if (planLLM && !planPromise)`), so nothing regresses —
657
+ // this only ever makes planning START EARLIER, never skips it. Only
658
+ // the realtime transport had this Plan/Progress wiring until now — see
659
+ // index.tsx's runTypedAgentLoop for the typed/HTTP transport's own
660
+ // version, added in the same pass.
661
+ let planPromise = null;
662
+ let plan = null;
663
+ let progress = null;
664
+ const STALL_THRESHOLD = 3; // Magentic-One-sized bounded budget before the harness itself escalates to give_up, rather than trusting the Critic alone to notice it's stalling
665
+ // Architecture Pillar 3 (Skill half) — real, per-deployment Skills
666
+ // (skill-store.ts), a DIFFERENT scope axis than `memory` (per-user).
667
+ // Computed once, up front, since both the Planner (retrieval) and the
668
+ // Critic (accumulating what gets saved after this turn) need it.
669
+ const skillsScopeId = deps.skillsScopeId ?? "default";
670
+ const skillSummaries = deps.skills ? deps.skills.listSkillSummaries(skillsScopeId) : [];
671
+ const matchedSkillSummary = skillSummaries.length ? (0, server_1.matchSkillByGoal)(skillSummaries, transcript) : null;
672
+ const skillsPayload = skillSummaries.length
673
+ ? {
674
+ summariesText: (0, server_1.renderSkillSummaries)(skillSummaries) || undefined,
675
+ suggestedInstructions: matchedSkillSummary ? (deps.skills.getSkill(skillsScopeId, matchedSkillSummary.id)?.instructions ?? undefined) : undefined,
676
+ }
677
+ : undefined;
678
+ // Every real, Critic-verified learnedFact from this turn's steps — the
679
+ // Formulator (compileSkill) compiles whatever's here into a real Skill
680
+ // once the turn concludes, below. Empty is the common case, not a gap.
681
+ const learnedFacts = [];
682
+ // Architecture Pillar 5 — the Archive tier, checked once per turn
683
+ // (never always-injected the way Core facts are — those are seeded
684
+ // once per CONNECTION, in the "context" message handler above). Added
685
+ // only to THIS turn's own ephemeral working history, never persisted
686
+ // into the connection's real `history` array below — a fact resurfaced
687
+ // because it happened to relate to this one question shouldn't linger
688
+ // in context for the rest of the conversation the way a Core fact
689
+ // deliberately does.
690
+ const archiveScopeId = getScopeId?.() ?? null;
691
+ const archivedSummary = deps.memory && archiveScopeId ? (0, memory_sqlite_1.formatArchivedFacts)(deps.memory.recallArchivedFacts(archiveScopeId, transcript)) : null;
692
+ const historyForThisTurn = archivedSummary ? [...history, { role: "assistant", text: archivedSummary }] : history;
365
693
  try {
366
- for (let i = 0; i < MAX_LOOP_ITERATIONS; i++) {
367
- const { route, visible, liveElements, webMcpTools } = getContext();
368
- const verb = await (0, server_1.resolveVerb)(deps.llm, deps.systemPrompt, deps.manifest, deps.registeredActions, deps.capability, {
369
- route,
370
- question: transcript,
371
- visible,
372
- liveElements,
373
- webMcpTools,
374
- history: loopHistory,
375
- });
376
- if (myGeneration !== getGeneration())
377
- return; // superseded by a barge-in while this turn was resolving
378
- // Sent immediately before speech synthesis even starts — so
379
- // highlight/navigate/do execute in the browser right away instead of
380
- // waiting on audio. The agent visibly acts while it's still about to
381
- // speak, not after.
382
- safeSend(client, { type: "verb", verb });
383
- if (!core_1.TERMINAL_VERBS.has(verb.verb)) {
384
- if (i === 0) {
694
+ const planLLM = deps.planLLM;
695
+ const criticLLM = deps.criticLLM;
696
+ if (planLLM && (0, agent_loop_1.looksMultiStep)(transcript)) {
697
+ planPromise = (0, server_1.resolvePlan)(planLLM, transcript, 1, deps.manifest, (0, server_1.renderRegisteredActions)(deps.registeredActions, deps.actionDescriptions), skillsPayload);
698
+ }
699
+ const result = await (0, agent_loop_1.driveAgentLoop)(historyForThisTurn, {
700
+ async getNextStep(loopHistory) {
701
+ const { route, visible, liveElements, webMcpTools } = getContext();
702
+ // Phase 5 step 2 — offered only when there's somewhere real to
703
+ // write it (memory configured AND this connection has a real
704
+ // scopeId) never a tool the model can call into a void.
705
+ const availableTools = deps.memory && getScopeId?.() ? [...webMcpTools, REMEMBER_FACT_TOOL] : webMcpTools;
706
+ return (0, server_1.resolveVerb)(deps.llm, deps.systemPrompt, deps.manifest, deps.registeredActions, deps.capability, {
707
+ route,
708
+ question: transcript,
709
+ visible,
710
+ liveElements,
711
+ webMcpTools: availableTools,
712
+ history: loopHistory,
713
+ });
714
+ },
715
+ onStep({ verb, iteration, terminal }) {
716
+ if (myGeneration !== getGeneration())
717
+ return true; // superseded by a barge-in while this turn was resolving
718
+ // Phase 5 step 2 — a remember_fact call is handled entirely
719
+ // server-side (see executeStep below) and must NEVER be sent to
720
+ // the client: the client would try to look it up in its own
721
+ // real WebMCP tool registry, fail to find it (it's synthetic,
722
+ // server-only), and report an error tool_result back — landing
723
+ // on whatever's THEN occupying the single-slot
724
+ // pendingToolResultResolve, which by then could easily belong
725
+ // to a genuinely later, unrelated step. Suppressing this send
726
+ // is not an optimization, it's what keeps that real race from
727
+ // ever being possible.
728
+ const isRememberFactCall = verb.verb === "call_tool" && verb.name === REMEMBER_FACT_TOOL_NAME;
729
+ if (!isRememberFactCall) {
730
+ // Sent immediately — before speech synthesis even starts — so
731
+ // highlight/navigate/do execute in the browser right away instead
732
+ // of waiting on audio. The agent visibly acts while it's still
733
+ // about to speak, not after.
734
+ safeSend(client, { type: "verb", verb, generation: myGeneration });
735
+ }
736
+ if (!terminal && iteration === 0) {
385
737
  // This turn just revealed it needs more than one step — speak a
386
738
  // quick, cheap acknowledgment *now*, in parallel with the rest
387
739
  // of the loop's own real work below (not awaited here), so the
@@ -389,22 +741,132 @@ async function finalizeTurn(turnState, client, deps, getContext, speakStreamed,
389
741
  // air for however long the real multi-step answer takes.
390
742
  // Single-step turns (the common case) never reach this branch
391
743
  // at all, so they keep today's latency exactly as it is.
392
- ackPromise = speakStreamed(ACK_PHRASES[Math.floor(Math.random() * ACK_PHRASES.length)]);
744
+ // Emitted as a real "inj" event now (step 5), consumed by
745
+ // emitEvent above — same real speakStreamed() call, reached
746
+ // through the event stream instead of an inline side effect.
747
+ emitEvent({ type: "inj", text: ACK_PHRASES[Math.floor(Math.random() * ACK_PHRASES.length)], at: Date.now() });
748
+ // The lazy fallback — only fires when looksMultiStep missed
749
+ // (planPromise is still null): a real Plan is still guaranteed
750
+ // before the Critic needs one, just one round trip later than
751
+ // the eager path above.
752
+ if (planLLM && !planPromise)
753
+ planPromise = (0, server_1.resolvePlan)(planLLM, transcript, 1, deps.manifest, (0, server_1.renderRegisteredActions)(deps.registeredActions, deps.actionDescriptions), skillsPayload);
393
754
  }
394
- // A continuing step itself stays silent (keeps the loop fast; the
395
- // client still shows it visually) — wait for its real result and
396
- // go around again instead of ending the turn.
397
- const observation = await waitForToolResult();
398
- if (myGeneration !== getGeneration())
399
- return;
400
- loopHistory = [
401
- ...loopHistory,
402
- { role: "assistant", text: `${summarizeVerbForHistory(verb)}. Result: ${observation}` },
403
- ].slice(-MAX_HISTORY_TURNS);
404
- continue;
405
- }
406
- history.push({ role: "user", text: transcript }, { role: "assistant", text: summarizeVerbForHistory(verb) });
407
- history.splice(0, Math.max(0, history.length - MAX_HISTORY_TURNS));
755
+ return false;
756
+ },
757
+ // A continuing step itself stays silent (keeps the loop fast; the
758
+ // client still shows it visually) — wait for its real result and go
759
+ // around again instead of ending the turn.
760
+ executeStep: (verb) => {
761
+ // Phase 5 step 2 — resolved entirely in-process, never routed
762
+ // through the client's real tool-execution round trip (see
763
+ // onStep's own doc comment for why the client is never even
764
+ // told this step happened).
765
+ const currentScopeId = getScopeId?.() ?? null;
766
+ if (verb.verb === "call_tool" && verb.name === REMEMBER_FACT_TOOL_NAME && deps.memory && currentScopeId) {
767
+ return handleRememberFactTool(deps.memory, currentScopeId, verb.args);
768
+ }
769
+ return waitForToolResult();
770
+ },
771
+ onStepResult: () => myGeneration !== getGeneration(),
772
+ onEvent: emitEvent,
773
+ runCritic: planLLM && criticLLM
774
+ ? async ({ verb, observation }) => {
775
+ // Real state, not the Executor's self-report — see
776
+ // resolveCritic's own doc comment for why this is a
777
+ // genuinely separate pass, mirroring judge.ts's own
778
+ // precedent. Awaited here (not just logged) on its FIRST
779
+ // use — by now at least one real tool round trip has
780
+ // already happened, so the Planner call kicked off above
781
+ // has likely already resolved in parallel; this is not a
782
+ // NEW blocking wait so much as picking up work already in
783
+ // flight.
784
+ if (!plan) {
785
+ plan = planPromise ? await planPromise : (0, server_1.fallbackPlan)(transcript, 1);
786
+ progress = { planVersion: plan.version, currentTaskIndex: 0, stallCount: 0 };
787
+ }
788
+ const currentProgress = progress;
789
+ const currentTask = plan.tasks[currentProgress.currentTaskIndex];
790
+ const verdict = await (0, server_1.resolveCritic)(criticLLM, currentTask, transcript, verb, observation);
791
+ // A real "thk" event — the Critic's own reasoning, narrated
792
+ // onto the same event stream the ack/act/obs events already
793
+ // flow through (not spoken today, just carried — see
794
+ // emitEvent's own doc comment on why that's a deliberate,
795
+ // small v1 scope).
796
+ emitEvent({ type: "thk", text: verdict.reasoning, at: Date.now() });
797
+ // Architecture Pillar 3 (Skill half) — accumulate whatever
798
+ // this step's real, Critic-verified fact was; the
799
+ // Formulator compiles whatever's here into a real Skill
800
+ // once the turn concludes, below. The common case adds
801
+ // nothing here at all.
802
+ if (verdict.learnedFact)
803
+ learnedFacts.push(verdict.learnedFact);
804
+ if (verdict.verdict === "task_complete") {
805
+ currentTask.status = "done";
806
+ if (currentProgress.currentTaskIndex < plan.tasks.length - 1) {
807
+ // More tasks remain — advance and keep looping instead
808
+ // of ending the turn here.
809
+ currentProgress.currentTaskIndex++;
810
+ plan.tasks[currentProgress.currentTaskIndex].status = "in_progress";
811
+ currentProgress.stallCount = 0;
812
+ return { ...verdict, verdict: "continue" };
813
+ }
814
+ // The real bug fix: the LAST task is genuinely done —
815
+ // end the loop right here instead of asking the model
816
+ // again and hoping it notices its own success.
817
+ return verdict;
818
+ }
819
+ if (verdict.verdict === "replan") {
820
+ // A fresh Planner call, a real new version — never a
821
+ // silent patch to the existing plan.
822
+ plan = await (0, server_1.resolvePlan)(planLLM, transcript, plan.version + 1, deps.manifest, (0, server_1.renderRegisteredActions)(deps.registeredActions, deps.actionDescriptions), skillsPayload);
823
+ progress = { planVersion: plan.version, currentTaskIndex: 0, stallCount: 0 };
824
+ return { ...verdict, verdict: "continue" };
825
+ }
826
+ if (verdict.verdict === "give_up")
827
+ return verdict;
828
+ // "continue" — a harness-enforced fail-safe on top of the
829
+ // Critic's own judgment: crossing a bounded stall budget
830
+ // escalates to give_up itself, rather than trusting the
831
+ // Critic alone to eventually notice it's stuck (Magentic-One's
832
+ // own two-tier tolerance pattern).
833
+ currentProgress.stallCount++;
834
+ if (currentProgress.stallCount >= STALL_THRESHOLD) {
835
+ return {
836
+ verdict: "give_up",
837
+ reasoning: `Stuck after ${currentProgress.stallCount} steps with no confirmed progress on "${currentTask.description}" — ${verdict.reasoning}`,
838
+ };
839
+ }
840
+ return verdict;
841
+ }
842
+ : undefined,
843
+ });
844
+ if (result.outcome === "aborted")
845
+ return;
846
+ // Architecture Pillar 3 (Skill half) — the Formulator, run once per
847
+ // turn (not per-step — cheap on purpose). Saves nothing when nothing
848
+ // was learned (the common case) or no SkillStore is configured (zero
849
+ // overhead, today's exact behavior). Classified from whatever this
850
+ // exact moment's live context reports — a best-effort snapshot, not
851
+ // necessarily the exact page a given fact was learned on, which is
852
+ // an acceptable trade for a Skill meant to be a general per-platform
853
+ // note rather than a per-page one.
854
+ if (deps.skills && learnedFacts.length > 0) {
855
+ const patternMatches = (0, core_1.classifyUiPattern)((0, core_1.deriveStructureSignals)(getContext().liveElements));
856
+ const skill = (0, server_1.compileSkill)(transcript, learnedFacts, patternMatches[0]?.pattern);
857
+ if (skill)
858
+ deps.skills.saveSkill(skillsScopeId, skill);
859
+ }
860
+ if (result.outcome === "terminal" || result.outcome === "unparseable" || result.outcome === "critic-complete") {
861
+ const verb = result.outcome === "terminal"
862
+ ? result.finalVerb
863
+ : result.outcome === "critic-complete"
864
+ ? { verb: "explain", text: result.verdict.reasoning }
865
+ : { verb: "explain", text: "I'm not sure how to help with that." };
866
+ history.push({ role: "user", text: transcript }, { role: "assistant", text: (0, agent_loop_1.summarizeVerbForHistory)(verb) });
867
+ history.splice(0, Math.max(0, history.length - agent_loop_1.MAX_HISTORY_TURNS));
868
+ recordMemoryTurn?.("user", transcript);
869
+ recordMemoryTurn?.("assistant", (0, agent_loop_1.summarizeVerbForHistory)(verb));
408
870
  if (ackPromise) {
409
871
  // Never start a second speakStreamed call before the first (the
410
872
  // ack) has actually finished — same single Speak connection, one
@@ -419,31 +881,39 @@ async function finalizeTurn(turnState, client, deps, getContext, speakStreamed,
419
881
  // A verb with no spoken text (highlight/navigate/do often have none)
420
882
  // still needs to unstick the client's "thinking" state and let the mic
421
883
  // resume — turn_complete covers that with no audio path involved.
422
- if ("text" in verb && verb.text) {
423
- await speakStreamed(verb.text);
884
+ const textToSpeak = "text" in verb ? (verb.text ?? undefined) : undefined;
885
+ if (textToSpeak) {
886
+ await speakStreamed(textToSpeak);
424
887
  }
425
888
  else {
426
- safeSend(client, { type: "turn_complete" });
889
+ safeSend(client, { type: "turn_complete", generation: myGeneration });
427
890
  }
428
891
  return;
429
892
  }
430
- // Iteration cap hit with no terminal verb degrade honestly instead
431
- // of leaving the client waiting forever.
432
- history.push({ role: "user", text: transcript }, { role: "assistant", text: "(gave up after too many steps)" });
433
- history.splice(0, Math.max(0, history.length - MAX_HISTORY_TURNS));
434
- safeSend(client, { type: "verb", verb: { verb: "explain", text: "I wasn't able to finish that try asking again or breaking it into smaller steps." } });
893
+ // Iteration cap hit with no terminal verb, OR the Critic/stall
894
+ // fail-safe gave up — degrade honestly instead of leaving the client
895
+ // waiting forever. A real Critic give-up carries its own specific
896
+ // reasoning, which is a genuinely better message than the generic
897
+ // fallback belowuse it when there is one.
898
+ const giveUpText = result.outcome === "critic-give-up" ? result.verdict.reasoning : "I wasn't able to finish that — try asking again or breaking it into smaller steps.";
899
+ const gaveUpSummary = result.outcome === "critic-give-up" ? giveUpText : "(gave up after too many steps)";
900
+ history.push({ role: "user", text: transcript }, { role: "assistant", text: gaveUpSummary });
901
+ history.splice(0, Math.max(0, history.length - agent_loop_1.MAX_HISTORY_TURNS));
902
+ recordMemoryTurn?.("user", transcript);
903
+ recordMemoryTurn?.("assistant", gaveUpSummary);
904
+ safeSend(client, { type: "verb", verb: { verb: "explain", text: giveUpText }, generation: myGeneration });
435
905
  if (ackPromise) {
436
906
  await ackPromise;
437
907
  if (myGeneration !== getGeneration())
438
908
  return;
439
909
  }
440
- await speakStreamed("I wasn't able to finish that — try asking again or breaking it into smaller steps.");
910
+ await speakStreamed(giveUpText);
441
911
  }
442
912
  catch (err) {
443
913
  console.error("[cairn realtime] failed to resolve/speak this turn:", err);
444
914
  if (myGeneration === getGeneration()) {
445
915
  safeSend(client, { type: "error", message: "Something went wrong answering that — try again." });
446
- safeSend(client, { type: "turn_complete" });
916
+ safeSend(client, { type: "turn_complete", generation: myGeneration });
447
917
  }
448
918
  }
449
919
  }
@@ -488,33 +958,3 @@ function parseWebMcpTools(raw) {
488
958
  }
489
959
  return tools;
490
960
  }
491
- /** A short text form of any verb for the history log — not shown to the
492
- * user, just fed back to the model on later turns so it knows what it
493
- * already did/said. */
494
- function summarizeVerbForHistory(verb) {
495
- if ("text" in verb && verb.text)
496
- return verb.text;
497
- switch (verb.verb) {
498
- case "highlight":
499
- case "open":
500
- return `(highlighted ${verb.target})`;
501
- case "navigate":
502
- return `(navigated to ${verb.route})`;
503
- case "do":
504
- return `(ran ${verb.action}${verb.target ? ` on ${verb.target}` : ""})`;
505
- case "tour":
506
- return verb.steps.map((s) => s.text).join(" ");
507
- case "click":
508
- return `(clicked ${verb.target})`;
509
- case "fill":
510
- return `(typed "${verb.value}" into ${verb.target})`;
511
- case "read":
512
- return `(read ${verb.target})`;
513
- case "call_tool":
514
- return `(called ${verb.name})`;
515
- case "batch":
516
- return `(${verb.actions.length} steps: ${verb.actions.map((a) => a.verb).join(", ")})`;
517
- default:
518
- return "(no response)";
519
- }
520
- }