@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/realtime-server.ts
CHANGED
|
@@ -27,9 +27,27 @@
|
|
|
27
27
|
|
|
28
28
|
import http from "node:http";
|
|
29
29
|
import { WebSocket, WebSocketServer } from "ws";
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
30
|
+
import { classifyUiPattern, deriveStructureSignals, type AgentEvent, type HistoryTurn, type LiveElement, type Manifest, type Plan, type ProgressLedger, type VerbResponse, type WebMcpTool } from "@cairnvibe/core";
|
|
31
|
+
import { driveAgentLoop, looksMultiStep, MAX_HISTORY_TURNS, summarizeVerbForHistory } from "./agent-loop";
|
|
32
|
+
import {
|
|
33
|
+
buildSystemPrompt,
|
|
34
|
+
compileSkill,
|
|
35
|
+
createCriticLLM,
|
|
36
|
+
createPlanLLM,
|
|
37
|
+
createVerbLLM,
|
|
38
|
+
fallbackPlan,
|
|
39
|
+
matchSkillByGoal,
|
|
40
|
+
renderRegisteredActions,
|
|
41
|
+
renderSkillSummaries,
|
|
42
|
+
resolveCritic,
|
|
43
|
+
resolvePlan,
|
|
44
|
+
resolveVerb,
|
|
45
|
+
type CapabilityTier,
|
|
46
|
+
type CreateCopilotHandlerOptions,
|
|
47
|
+
} from "./server";
|
|
32
48
|
import { DeepgramSpeakStream } from "./tts-stream";
|
|
49
|
+
import { formatArchivedFacts, formatRememberedFacts, seedHistoryFromMemory, type MemoryStore } from "./memory-sqlite";
|
|
50
|
+
import type { SkillStore } from "./skill-store";
|
|
33
51
|
|
|
34
52
|
const DEEPGRAM_LIVE_URL = "wss://api.deepgram.com/v1/listen";
|
|
35
53
|
const DEFAULT_STT_MODEL = "nova-2";
|
|
@@ -39,35 +57,129 @@ const DEFAULT_TTS_VOICE = "aura-2-thalia-en";
|
|
|
39
57
|
// something within about a second instead of dead air while the real
|
|
40
58
|
// multi-step work runs. A short rotating set, not one fixed line, so it
|
|
41
59
|
// doesn't read as a canned bot phrase on every multi-step question.
|
|
42
|
-
|
|
60
|
+
//
|
|
61
|
+
// Phase 2 step 3 — rewritten from the original set (kept below in spirit
|
|
62
|
+
// but not verbatim: "Let me check that for you." / "One moment, let me
|
|
63
|
+
// look into that." / "Give me a second to check." / "Let me take a
|
|
64
|
+
// look.") for two real, testable reasons, not a vibe change: (1) the
|
|
65
|
+
// plan's own bar is "feels like a person coordinating a team ('give me
|
|
66
|
+
// a sec, sorting that out'), never generic-corporate" — the original
|
|
67
|
+
// set's formal, service-desk phrasing ("One moment, let me look into
|
|
68
|
+
// that") is closer to a phone-tree script than a coworker; (2) "kept
|
|
69
|
+
// short on purpose (a long ack costs real latency budget)" — the
|
|
70
|
+
// original set averaged 6 words; this one averages under 4, a real,
|
|
71
|
+
// measurable reduction in synthesis time before the ack is even
|
|
72
|
+
// audible, on top of sounding more like a person. Graded on this now,
|
|
73
|
+
// not eyeballed — see judge.ts's new `persona` dimension and
|
|
74
|
+
// realtime-server.ts's own "ack" message, which exposes the actual
|
|
75
|
+
// spoken text to packages/evals' trace capture for the first time.
|
|
76
|
+
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."];
|
|
43
77
|
// Not constrained by any telephony 8kHz requirement — this is just "what
|
|
44
78
|
// quality does Deepgram render at" for browser playback, and the Web Audio
|
|
45
79
|
// API resamples an AudioBuffer at any declared rate transparently.
|
|
46
80
|
const TTS_SAMPLE_RATE = 24000;
|
|
47
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Phase 5 step 2 — explicit fact-remembering (Track B's own "remember is
|
|
84
|
+
* an explicit act, never automatic" pattern — step 1 built the automatic
|
|
85
|
+
* turn-recording half; this is the deliberate half). Modeled as a
|
|
86
|
+
* SYNTHETIC WebMCP tool the model can call_tool, not a new verb — reuses
|
|
87
|
+
* the existing call_tool grammar/validation the model already knows
|
|
88
|
+
* ("a tool name from this turn's webMcpTools list") instead of inventing
|
|
89
|
+
* a new one. Handled entirely SERVER-SIDE (see executeStep below) —
|
|
90
|
+
* the client never learns this step happened at all (see onStep below
|
|
91
|
+
* for why that's not just an optimization: it's what keeps this safe).
|
|
92
|
+
*/
|
|
93
|
+
const REMEMBER_FACT_TOOL_NAME = "remember_fact";
|
|
94
|
+
const REMEMBER_FACT_TOOL: WebMcpTool = {
|
|
95
|
+
name: REMEMBER_FACT_TOOL_NAME,
|
|
96
|
+
description:
|
|
97
|
+
"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.",
|
|
98
|
+
inputSchema: {
|
|
99
|
+
type: "object",
|
|
100
|
+
properties: {
|
|
101
|
+
key: { type: "string", description: "A short, stable name for this fact, e.g. \"preferredUnits\" or \"flakySelectorNote\"." },
|
|
102
|
+
value: { type: "string", description: "The real fact to remember, in plain language." },
|
|
103
|
+
},
|
|
104
|
+
required: ["key", "value"],
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
async function handleRememberFactTool(memory: MemoryStore, scopeId: string, args: Record<string, unknown> | undefined): Promise<string> {
|
|
109
|
+
const key = typeof args?.key === "string" ? args.key.trim() : "";
|
|
110
|
+
const value = typeof args?.value === "string" ? args.value.trim() : "";
|
|
111
|
+
if (!key || !value) return "Could not remember that — a key and a value are both required.";
|
|
112
|
+
memory.rememberFact(scopeId, key, value);
|
|
113
|
+
return `Remembered: ${key} = ${value}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
48
116
|
export interface CreateRealtimeServerOptions extends CreateCopilotHandlerOptions {
|
|
49
117
|
manifest: Manifest;
|
|
50
118
|
deepgramApiKey: string;
|
|
51
119
|
sttModel?: string;
|
|
52
120
|
ttsVoice?: string;
|
|
121
|
+
/** Phase 5 — real cross-session memory (packages/sdk/src/memory-sqlite.ts,
|
|
122
|
+
* or any store implementing the same interface). Optional — omitting it
|
|
123
|
+
* keeps every connection exactly as memory-less as before this existed.
|
|
124
|
+
* Scoped by whatever `scopeId` string a connection's own client sends in
|
|
125
|
+
* its "context" message (see ConnectionDeps' own doc comment) — this SDK
|
|
126
|
+
* invents no identity of its own. */
|
|
127
|
+
memory?: MemoryStore;
|
|
128
|
+
/** Architecture Pillar 3 (Skill half) — see ConnectionDeps' own doc
|
|
129
|
+
* comment. Optional; omitting it keeps every connection exactly as it
|
|
130
|
+
* was before this existed. */
|
|
131
|
+
skills?: SkillStore;
|
|
132
|
+
/** See ConnectionDeps' own doc comment. Defaults to "default" when `skills` is set but this is omitted. */
|
|
133
|
+
skillsScopeId?: string;
|
|
53
134
|
}
|
|
54
135
|
|
|
55
136
|
type ServerMessage =
|
|
56
137
|
| { type: "interim"; text: string }
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
138
|
+
/** `generation` (and on every other message below that carries one) is
|
|
139
|
+
* the server's own barge-in generation counter at the moment THIS
|
|
140
|
+
* message was produced — see `triggerServerBargeIn`'s `generation`
|
|
141
|
+
* variable. Real, live-found bug this closes: the client's own local
|
|
142
|
+
* barge-in (VAD-triggered, entirely independent of the server) can
|
|
143
|
+
* start a brand-new turn's "final" before an EARLIER turn's own verb/
|
|
144
|
+
* audio — already in flight on the wire when the server processed the
|
|
145
|
+
* barge-in — actually arrives. WebSocket delivers messages in order,
|
|
146
|
+
* but "in order" isn't "still relevant": without a way to tell an
|
|
147
|
+
* older turn's message apart from the current one, the client applied
|
|
148
|
+
* it anyway, misattributing a stale answer to whatever question was
|
|
149
|
+
* now current — the exact "one question, but a different, unrelated-
|
|
150
|
+
* sounding answer showed up later" bug found live. The client tracks
|
|
151
|
+
* the generation of the most recent "final" it's processed and drops
|
|
152
|
+
* any later verb/speaking/audio message whose generation is older. */
|
|
153
|
+
| { type: "final"; text: string; generation: number }
|
|
154
|
+
| { type: "verb"; verb: VerbResponse; generation: number }
|
|
155
|
+
| { type: "speaking_start"; generation: number }
|
|
60
156
|
/** One chunk of raw linear16 PCM audio, base64-encoded, as it's rendered — never the whole clip at once. */
|
|
61
|
-
| { type: "audio_chunk"; audio: string; sampleRate: number }
|
|
157
|
+
| { type: "audio_chunk"; audio: string; sampleRate: number; generation: number }
|
|
62
158
|
/** No more audio chunks are coming for this turn. The client may still be mid-playback of what it already has. */
|
|
63
|
-
| { type: "speaking_end" }
|
|
64
|
-
| { type: "turn_complete" }
|
|
159
|
+
| { type: "speaking_end"; generation: number }
|
|
160
|
+
| { type: "turn_complete"; generation: number }
|
|
161
|
+
/** Phase 2 step 3 — the ack phrase's text, sent alongside the audio
|
|
162
|
+
* that speaks it. Purely informational (see emitEvent's own "inj"
|
|
163
|
+
* case) — mainly so packages/evals' voiceFrames capture has something
|
|
164
|
+
* readable to grade the Talker's persona against. */
|
|
165
|
+
| { type: "ack"; text: string }
|
|
65
166
|
| { type: "error"; message: string };
|
|
66
167
|
|
|
168
|
+
// seedHistoryFromMemory/formatRememberedFacts moved to memory-sqlite.ts
|
|
169
|
+
// (Phase 5 step 4) — the SAME shared, storage-agnostic logic both the
|
|
170
|
+
// realtime relay and the typed/HTTP transport need. Re-exported here
|
|
171
|
+
// (not just imported) so every existing import from "./realtime-server"
|
|
172
|
+
// keeps working unchanged.
|
|
173
|
+
export { seedHistoryFromMemory, formatRememberedFacts };
|
|
174
|
+
|
|
67
175
|
export function createRealtimeServer(options: CreateRealtimeServerOptions): http.Server {
|
|
68
176
|
const registeredActions = options.registeredActions ?? [];
|
|
69
177
|
const capability = options.capability ?? "act";
|
|
70
178
|
const llm = createVerbLLM(options);
|
|
179
|
+
// Phase 3 steps 2-3 — real, separately-configured Planner/Critic LLMs.
|
|
180
|
+
// See finalizeTurn's own doc comment for how they're actually used.
|
|
181
|
+
const planLLM = createPlanLLM(options);
|
|
182
|
+
const criticLLM = createCriticLLM(options);
|
|
71
183
|
// "text" is optional on highlight/open/navigate/do in the base prompt —
|
|
72
184
|
// fine for the typed/HTTP path, which always has a visible answer area,
|
|
73
185
|
// but silence reads as broken in a live voice conversation (the client
|
|
@@ -75,8 +187,9 @@ export function createRealtimeServer(options: CreateRealtimeServerOptions): http
|
|
|
75
187
|
// instruction asks for a confirmation grounded in what was actually
|
|
76
188
|
// done, not filler — generic phrasing here is what made replies feel
|
|
77
189
|
// "unrelated" to the question that was just asked.
|
|
190
|
+
const actionDescriptions = options.actionDescriptions ?? {};
|
|
78
191
|
const systemPrompt =
|
|
79
|
-
buildSystemPrompt(options.manifest, registeredActions, options.persona) +
|
|
192
|
+
buildSystemPrompt(options.manifest, registeredActions, options.persona, actionDescriptions) +
|
|
80
193
|
`\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.`;
|
|
81
194
|
const sttModel = options.sttModel ?? process.env.DEEPGRAM_MODEL ?? DEFAULT_STT_MODEL;
|
|
82
195
|
const ttsVoice = options.ttsVoice ?? process.env.DEEPGRAM_VOICE ?? DEFAULT_TTS_VOICE;
|
|
@@ -88,10 +201,46 @@ export function createRealtimeServer(options: CreateRealtimeServerOptions): http
|
|
|
88
201
|
});
|
|
89
202
|
const wss = new WebSocketServer({ server: httpServer });
|
|
90
203
|
|
|
204
|
+
// Real, server-side visibility into how many browser tabs/connections
|
|
205
|
+
// are actually live at once — added specifically to answer, with real
|
|
206
|
+
// data instead of a guess, a live-raised concern: could a page reload
|
|
207
|
+
// (or several in quick succession) leave more than one realtime
|
|
208
|
+
// connection open at the same time, each independently running its own
|
|
209
|
+
// Deepgram STT/TTS and LLM calls for the same user? Every connection
|
|
210
|
+
// gets a short id, logged on open and close, alongside a live count —
|
|
211
|
+
// if that count is ever more than 1 during normal single-tab use, THAT
|
|
212
|
+
// is the real, direct evidence of a genuine duplicate-connection bug;
|
|
213
|
+
// if it always reads 1, duplication server-side is ruled out with real
|
|
214
|
+
// proof, not assumed away.
|
|
215
|
+
let nextConnectionId = 1;
|
|
216
|
+
let activeConnections = 0;
|
|
217
|
+
|
|
91
218
|
wss.on("connection", (client) => {
|
|
92
|
-
|
|
219
|
+
const connectionId = nextConnectionId++;
|
|
220
|
+
activeConnections++;
|
|
221
|
+
console.log(`[cairn realtime] connection ${connectionId} opened — ${activeConnections} active`);
|
|
222
|
+
client.on("close", () => {
|
|
223
|
+
activeConnections--;
|
|
224
|
+
console.log(`[cairn realtime] connection ${connectionId} closed — ${activeConnections} active`);
|
|
225
|
+
});
|
|
226
|
+
handleConnection(client, {
|
|
227
|
+
deepgramApiKey,
|
|
228
|
+
sttModel,
|
|
229
|
+
ttsVoice,
|
|
230
|
+
llm,
|
|
231
|
+
planLLM,
|
|
232
|
+
criticLLM,
|
|
233
|
+
systemPrompt,
|
|
234
|
+
manifest: options.manifest,
|
|
235
|
+
registeredActions,
|
|
236
|
+
actionDescriptions,
|
|
237
|
+
capability,
|
|
238
|
+
memory: options.memory,
|
|
239
|
+
skills: options.skills,
|
|
240
|
+
skillsScopeId: options.skillsScopeId,
|
|
241
|
+
}).catch(
|
|
93
242
|
(err) => {
|
|
94
|
-
console.error(
|
|
243
|
+
console.error(`[cairn realtime] connection ${connectionId} error:`, err);
|
|
95
244
|
safeSend(client, { type: "error", message: "internal error" });
|
|
96
245
|
client.close();
|
|
97
246
|
},
|
|
@@ -106,15 +255,47 @@ export interface ConnectionDeps {
|
|
|
106
255
|
sttModel: string;
|
|
107
256
|
ttsVoice: string;
|
|
108
257
|
llm: ReturnType<typeof createVerbLLM>;
|
|
258
|
+
/** Phase 3 step 2 — a separately-configured Planner LLM, called on the
|
|
259
|
+
* first continuing step of a turn (see finalizeTurn). Optional so
|
|
260
|
+
* existing ConnectionDeps construction (and every existing test) keeps
|
|
261
|
+
* working unchanged; absent means no Planner call happens at all. */
|
|
262
|
+
planLLM?: ReturnType<typeof createPlanLLM>;
|
|
263
|
+
/** Phase 3 step 3 — a separately-configured Critic LLM. Only engages
|
|
264
|
+
* (task-advancement/replan/give-up actually driving the loop, not just
|
|
265
|
+
* logging) when BOTH this and planLLM are present — the Critic needs a
|
|
266
|
+
* real Plan's current task to check against. Optional for the same
|
|
267
|
+
* backward-compatibility reason as planLLM. */
|
|
268
|
+
criticLLM?: ReturnType<typeof createCriticLLM>;
|
|
109
269
|
systemPrompt: string;
|
|
110
270
|
manifest: Manifest;
|
|
111
271
|
registeredActions: string[];
|
|
272
|
+
/** Phase 4 step 4 — real descriptions for registeredActions ids, same
|
|
273
|
+
* shape/purpose as CreateCopilotHandlerOptions.actionDescriptions.
|
|
274
|
+
* Optional, defaults to {} — an existing ConnectionDeps construction
|
|
275
|
+
* (own or a test's) keeps working with every action rendered bare. */
|
|
276
|
+
actionDescriptions?: Record<string, string>;
|
|
112
277
|
capability: CapabilityTier;
|
|
278
|
+
/** Phase 5 — see CreateRealtimeServerOptions' own doc comment. Optional,
|
|
279
|
+
* same backward-compatibility reason as every other addition here:
|
|
280
|
+
* absent means no memory read/write happens for any connection, ever
|
|
281
|
+
* — today's exact behavior. */
|
|
282
|
+
memory?: MemoryStore;
|
|
283
|
+
/**
|
|
284
|
+
* Architecture Pillar 3 (Skill half) — real, persistent storage for
|
|
285
|
+
* self-authored Skills (skill-store.ts). A DIFFERENT axis of scope than
|
|
286
|
+
* `memory` above: Skills are meant to be shared across every user who
|
|
287
|
+
* talks to this deployment (the same scope `ui-manifest.json` itself
|
|
288
|
+
* already has), never per-user — see skill-store.ts's own doc comment.
|
|
289
|
+
* Optional; absent means no Skill retrieval/saving happens at all, zero
|
|
290
|
+
* overhead, today's exact behavior.
|
|
291
|
+
*/
|
|
292
|
+
skills?: SkillStore;
|
|
293
|
+
/** The deployment-wide scope Skills are stored/looked up under when
|
|
294
|
+
* `skills` is configured. Defaults to "default" — a single-deployment
|
|
295
|
+
* setup, today's only real usage — when omitted. */
|
|
296
|
+
skillsScopeId?: string;
|
|
113
297
|
}
|
|
114
298
|
|
|
115
|
-
const MAX_HISTORY_TURNS = 8; // 4 exchanges — enough for "the first one"/"do that instead" without growing the prompt unbounded over a long call
|
|
116
|
-
const MAX_LOOP_ITERATIONS = 6; // a hard cap on one turn's agent-loop steps, not a target — see finalizeTurn
|
|
117
|
-
|
|
118
299
|
async function handleConnection(client: WebSocket, deps: ConnectionDeps): Promise<void> {
|
|
119
300
|
// liveElements/webMcpTools refresh on every "context" resend (the client
|
|
120
301
|
// sends one on route changes and each time it's about to start listening
|
|
@@ -131,6 +312,21 @@ async function handleConnection(client: WebSocket, deps: ConnectionDeps): Promis
|
|
|
131
312
|
// one WebSocket per call — so this is accumulated here directly rather
|
|
132
313
|
// than round-tripped through the client.
|
|
133
314
|
const history: HistoryTurn[] = [];
|
|
315
|
+
// Phase 5 — real cross-session memory. `scopeId` is set from the FIRST
|
|
316
|
+
// "context" message that carries one (see the "context" handler below)
|
|
317
|
+
// and never changed again for the life of this connection — a real,
|
|
318
|
+
// deliberate v1 simplification (no attempt to handle a scopeId that
|
|
319
|
+
// legitimately changes mid-connection, e.g. a mid-session login) rather
|
|
320
|
+
// than guessed-at complexity. `historySeededFromMemory` guards the
|
|
321
|
+
// ONE-TIME load of this scope's prior turns into `history` — a later
|
|
322
|
+
// "context" resend (route changes send fresh ones routinely) must never
|
|
323
|
+
// re-seed and duplicate them.
|
|
324
|
+
let scopeId: string | null = null;
|
|
325
|
+
let historySeededFromMemory = false;
|
|
326
|
+
function recordMemoryTurn(role: "user" | "assistant", text: string): void {
|
|
327
|
+
if (!deps.memory || !scopeId) return;
|
|
328
|
+
deps.memory.recordTurn(scopeId, role, text);
|
|
329
|
+
}
|
|
134
330
|
// Resolves the agent loop's in-flight waitForToolResult() call once the
|
|
135
331
|
// client reports back what a click/fill/read/call_tool step actually
|
|
136
332
|
// did — same "a mutable pending-callback slot, resolved when the right
|
|
@@ -140,15 +336,75 @@ async function handleConnection(client: WebSocket, deps: ConnectionDeps): Promis
|
|
|
140
336
|
const dgUrl =
|
|
141
337
|
`${DEEPGRAM_LIVE_URL}?model=${encodeURIComponent(deps.sttModel)}` +
|
|
142
338
|
`&encoding=linear16&sample_rate=16000&channels=1&interim_results=true&endpointing=300&utterance_end_ms=1000`;
|
|
143
|
-
const dg = new WebSocket(dgUrl, { headers: { Authorization: `Token ${deps.deepgramApiKey}` } });
|
|
144
339
|
|
|
340
|
+
// Real, live-reported bug this closes: "status says Listening but nothing
|
|
341
|
+
// happens" — the client keeps looking and sounding fine (mic still
|
|
342
|
+
// capturing, WS still open, no error ever shown), because the REAL
|
|
343
|
+
// failure is silent and one layer deeper: Deepgram's own STT connection
|
|
344
|
+
// can close mid-session (an idle timeout, a network blip, Deepgram's own
|
|
345
|
+
// connection lifetime limit) and this code never noticed — there was no
|
|
346
|
+
// `dg.on("close", ...)` handler at all, `dgOpen` never got reset to
|
|
347
|
+
// false, and every subsequent mic frame kept calling `dg.send(buf)` on an
|
|
348
|
+
// already-CLOSED socket with no callback to catch the failure. The client
|
|
349
|
+
// never heard about any of this, because nothing here ever sent it an
|
|
350
|
+
// "error" message — from the outside it looks exactly like "listening,
|
|
351
|
+
// but the mic just isn't picking anything up."
|
|
352
|
+
//
|
|
353
|
+
// Fixed by making the STT connection self-healing instead of a single
|
|
354
|
+
// fire-and-forget WebSocket: `dg` is now reassignable, and a real close
|
|
355
|
+
// triggers a bounded number of automatic reconnects (fresh handshake,
|
|
356
|
+
// same handlers) before finally giving up and telling the client — so a
|
|
357
|
+
// transient Deepgram-side drop recovers on its own instead of silently
|
|
358
|
+
// bricking the rest of the call.
|
|
359
|
+
let dg: WebSocket;
|
|
145
360
|
let dgOpen = false;
|
|
361
|
+
let dgReconnectAttempts = 0;
|
|
362
|
+
const MAX_DG_RECONNECT_ATTEMPTS = 3;
|
|
146
363
|
const pendingAudio: Buffer[] = [];
|
|
364
|
+
// Accumulates Deepgram "Results" transcript segments across one utterance
|
|
365
|
+
// — see handleDeepgramMessage for why this can't just react to every
|
|
366
|
+
// is_final. Declared before connectDeepgramStt so its own "message"
|
|
367
|
+
// handler closes over an already-initialized binding, not just a
|
|
368
|
+
// same-scope one that happens to be safe only because WS events are
|
|
369
|
+
// always async.
|
|
370
|
+
const turnState = { buffer: "" };
|
|
147
371
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
372
|
+
function connectDeepgramStt(): void {
|
|
373
|
+
const socket = new WebSocket(dgUrl, { headers: { Authorization: `Token ${deps.deepgramApiKey}` } });
|
|
374
|
+
dg = socket;
|
|
375
|
+
|
|
376
|
+
socket.on("open", () => {
|
|
377
|
+
dgOpen = true;
|
|
378
|
+
dgReconnectAttempts = 0;
|
|
379
|
+
for (const chunk of pendingAudio.splice(0)) socket.send(chunk);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
socket.on("message", (data) => {
|
|
383
|
+
void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history, turnState, () => generation, waitForToolResult, recordMemoryTurn, () => scopeId, () => {
|
|
384
|
+
generation++;
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
socket.on("error", (err) => {
|
|
389
|
+
console.error("[cairn realtime] Deepgram STT connection error:", err);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
socket.on("close", (code, reason) => {
|
|
393
|
+
dgOpen = false;
|
|
394
|
+
console.log(`[cairn realtime] Deepgram STT connection closed (code ${code}${reason ? `, ${reason}` : ""})`);
|
|
395
|
+
if (client.readyState !== WebSocket.OPEN) return; // the whole call already ended — nothing to reconnect for
|
|
396
|
+
if (dgReconnectAttempts >= MAX_DG_RECONNECT_ATTEMPTS) {
|
|
397
|
+
console.error(`[cairn realtime] Deepgram STT gave up reconnecting after ${MAX_DG_RECONNECT_ATTEMPTS} attempts`);
|
|
398
|
+
safeSend(client, { type: "error", message: "Speech recognition connection was lost and couldn't be restored — try starting the call again." });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
dgReconnectAttempts++;
|
|
402
|
+
console.log(`[cairn realtime] reconnecting to Deepgram STT (attempt ${dgReconnectAttempts}/${MAX_DG_RECONNECT_ATTEMPTS})`);
|
|
403
|
+
connectDeepgramStt();
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
connectDeepgramStt();
|
|
152
408
|
|
|
153
409
|
// ONE Speak connection reused for every turn in this session — a fresh
|
|
154
410
|
// handshake per turn is a real, measurable chunk of the latency this
|
|
@@ -184,10 +440,22 @@ async function handleConnection(client: WebSocket, deps: ConnectionDeps): Promis
|
|
|
184
440
|
return { stream: speakStream, ready: speakStreamReady! };
|
|
185
441
|
}
|
|
186
442
|
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
443
|
+
// A real, live-reported bug in what used to live here: a "confirm-or-
|
|
444
|
+
// reverse" grace window that, on ANY barge-in with no confirming STT
|
|
445
|
+
// transcript arriving within 600ms, concluded it was a false positive
|
|
446
|
+
// and RE-SPOKE THE SAME TEXT FROM THE TOP. Live symptom, reported
|
|
447
|
+
// directly: saying "stop" cut the agent off, paused for about a
|
|
448
|
+
// second, then the exact same answer started playing again from the
|
|
449
|
+
// beginning — because Deepgram's own transcript for "stop" routinely
|
|
450
|
+
// arrived a little later than the 600ms window, so every clean,
|
|
451
|
+
// deliberate interruption looked exactly like an unconfirmed false
|
|
452
|
+
// alarm and got "resumed." Direct user instruction: there should be no
|
|
453
|
+
// such system at all — a barge-in should behave like it does in any
|
|
454
|
+
// normal voice assistant, an immediate, permanent stop, never a guess
|
|
455
|
+
// at whether to talk over the user again. `triggerServerBargeIn` is
|
|
456
|
+
// now exactly that: bump generation (drops any audio/verb already in
|
|
457
|
+
// flight), clear the TTS stream, unstick a pending speakStreamed()
|
|
458
|
+
// call — and nothing else.
|
|
191
459
|
function triggerServerBargeIn(): void {
|
|
192
460
|
generation++;
|
|
193
461
|
speakStream?.clear();
|
|
@@ -200,24 +468,24 @@ async function handleConnection(client: WebSocket, deps: ConnectionDeps): Promis
|
|
|
200
468
|
|
|
201
469
|
stream.setAudioHandler((chunk) => {
|
|
202
470
|
if (myGeneration !== generation) return; // stale — dropped by barge-in
|
|
203
|
-
safeSend(client, { type: "audio_chunk", audio: chunk.toString("base64"), sampleRate: TTS_SAMPLE_RATE });
|
|
471
|
+
safeSend(client, { type: "audio_chunk", audio: chunk.toString("base64"), sampleRate: TTS_SAMPLE_RATE, generation: myGeneration });
|
|
204
472
|
});
|
|
205
473
|
|
|
206
474
|
await ready;
|
|
207
475
|
if (!speakStream || myGeneration !== generation) {
|
|
208
476
|
// Reconnect failed, or barge-in happened before the stream connected —
|
|
209
477
|
// either way, only degrade to turn_complete if this is still current.
|
|
210
|
-
if (myGeneration === generation) safeSend(client, { type: "turn_complete" });
|
|
478
|
+
if (myGeneration === generation) safeSend(client, { type: "turn_complete", generation: myGeneration });
|
|
211
479
|
return;
|
|
212
480
|
}
|
|
213
481
|
|
|
214
482
|
await new Promise<void>((resolve) => {
|
|
215
483
|
onCurrentTurnFlushed = () => {
|
|
216
484
|
onCurrentTurnFlushed = null;
|
|
217
|
-
if (myGeneration === generation) safeSend(client, { type: "speaking_end" });
|
|
485
|
+
if (myGeneration === generation) safeSend(client, { type: "speaking_end", generation: myGeneration });
|
|
218
486
|
resolve();
|
|
219
487
|
};
|
|
220
|
-
safeSend(client, { type: "speaking_start" });
|
|
488
|
+
safeSend(client, { type: "speaking_start", generation: myGeneration });
|
|
221
489
|
stream.sendText(text);
|
|
222
490
|
stream.flush();
|
|
223
491
|
});
|
|
@@ -243,24 +511,16 @@ async function handleConnection(client: WebSocket, deps: ConnectionDeps): Promis
|
|
|
243
511
|
});
|
|
244
512
|
}
|
|
245
513
|
|
|
246
|
-
// Accumulates Deepgram "Results" transcript segments across one utterance
|
|
247
|
-
// — see handleDeepgramMessage for why this can't just react to every
|
|
248
|
-
// is_final.
|
|
249
|
-
const turnState = { buffer: "" };
|
|
250
|
-
|
|
251
|
-
dg.on("message", (data) => {
|
|
252
|
-
void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history, turnState, () => generation, waitForToolResult);
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
dg.on("error", (err) => {
|
|
256
|
-
console.error("[cairn realtime] Deepgram STT connection error:", err);
|
|
257
|
-
safeSend(client, { type: "error", message: "speech recognition unavailable" });
|
|
258
|
-
});
|
|
259
|
-
|
|
260
514
|
client.on("message", (data, isBinary) => {
|
|
261
515
|
if (isBinary) {
|
|
262
516
|
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer);
|
|
263
|
-
|
|
517
|
+
// The readyState check (not just dgOpen) is real, defensive belt-and-
|
|
518
|
+
// suspenders: dgOpen is reset to false the instant "close" fires, but
|
|
519
|
+
// a mic frame arriving in the same tick as a not-yet-processed close
|
|
520
|
+
// event should never risk calling .send() on a socket that's already
|
|
521
|
+
// gone — that used to be exactly how a dead connection kept silently
|
|
522
|
+
// swallowing audio with no error ever surfacing.
|
|
523
|
+
if (dgOpen && dg.readyState === WebSocket.OPEN) dg.send(buf);
|
|
264
524
|
else pendingAudio.push(buf);
|
|
265
525
|
return;
|
|
266
526
|
}
|
|
@@ -273,6 +533,32 @@ async function handleConnection(client: WebSocket, deps: ConnectionDeps): Promis
|
|
|
273
533
|
liveElements: parseLiveElements(msg.liveElements),
|
|
274
534
|
webMcpTools: parseWebMcpTools(msg.webMcpTools),
|
|
275
535
|
};
|
|
536
|
+
// Phase 5 — `scopeId` is whatever opaque id the CUSTOMER's own
|
|
537
|
+
// client code chooses to send (their own end-user id if they have
|
|
538
|
+
// login, anything else stable otherwise) — this SDK never invents
|
|
539
|
+
// one. Only the first real scopeId this connection ever sees is
|
|
540
|
+
// used; a later "context" resend's scopeId (route changes send
|
|
541
|
+
// these routinely) is ignored, and the one-time prior-turn load
|
|
542
|
+
// below never repeats.
|
|
543
|
+
if (!scopeId && typeof msg.scopeId === "string" && msg.scopeId) {
|
|
544
|
+
const newScopeId: string = msg.scopeId;
|
|
545
|
+
scopeId = newScopeId;
|
|
546
|
+
if (deps.memory && !historySeededFromMemory) {
|
|
547
|
+
historySeededFromMemory = true;
|
|
548
|
+
const priorTurns = deps.memory.recentTurns(newScopeId);
|
|
549
|
+
const seeded = seedHistoryFromMemory(history, priorTurns, MAX_HISTORY_TURNS);
|
|
550
|
+
history.length = 0;
|
|
551
|
+
history.push(...seeded);
|
|
552
|
+
|
|
553
|
+
// Prepended AFTER the cap above, deliberately exempt from
|
|
554
|
+
// it — a remembered fact ("prefers metric units") should
|
|
555
|
+
// stay in context for the WHOLE connection, not age out the
|
|
556
|
+
// same way an ordinary conversation turn does once enough
|
|
557
|
+
// new turns accumulate.
|
|
558
|
+
const factsSummary = formatRememberedFacts(deps.memory.recallFacts(newScopeId));
|
|
559
|
+
if (factsSummary) history.unshift({ role: "assistant", text: factsSummary });
|
|
560
|
+
}
|
|
561
|
+
}
|
|
276
562
|
} else if (msg.type === "tool_result" && typeof msg.observation === "string") {
|
|
277
563
|
// The client finished executing a click/fill/read/call_tool step
|
|
278
564
|
// the agent loop sent it — this is what finalizeTurn's
|
|
@@ -330,6 +616,36 @@ export async function handleDeepgramMessage(
|
|
|
330
616
|
turnState: { buffer: string },
|
|
331
617
|
getGeneration: () => number,
|
|
332
618
|
waitForToolResult: () => Promise<string>,
|
|
619
|
+
/** Phase 5 — called with each real (role, text) turn as it's finalized,
|
|
620
|
+
* right alongside the same-shaped `history.push`. Optional and a no-op
|
|
621
|
+
* by default so every existing call site keeps working unchanged. The
|
|
622
|
+
* realtime connection's own recordMemoryTurn writes it to durable
|
|
623
|
+
* storage when memory + a scopeId are both configured for this
|
|
624
|
+
* connection — see ConnectionDeps.memory's own doc comment. */
|
|
625
|
+
recordMemoryTurn?: (role: "user" | "assistant", text: string) => void,
|
|
626
|
+
/** Phase 5 step 2 — see finalizeTurn's own doc comment. Threaded
|
|
627
|
+
* through here purely to reach finalizeTurn's two call sites below. */
|
|
628
|
+
getScopeId?: () => string | null,
|
|
629
|
+
/** Real, live-found gap this closes: `generation` (getGeneration/
|
|
630
|
+
* triggerServerBargeIn) previously only ever bumped on an EXPLICIT
|
|
631
|
+
* barge-in — two ordinary, sequential turns with no interruption
|
|
632
|
+
* between them shared the exact same generation number. That was
|
|
633
|
+
* fine for what `generation` was originally built for (dropping
|
|
634
|
+
* audio/verbs abandoned mid-turn by a real interruption), but it
|
|
635
|
+
* left the CLIENT's own generation-based staleness check (added for
|
|
636
|
+
* that same reason, in index.tsx) with no way to tell a merely SLOW
|
|
637
|
+
* turn's late-arriving reply apart from the current one — nothing
|
|
638
|
+
* had bumped, so the late reply's generation still matched. Found
|
|
639
|
+
* live: a "hello" reply that took long enough to arrive AFTER the
|
|
640
|
+
* next question's own "final" had already fired, landing on the
|
|
641
|
+
* wrong caption because both were tagged the same generation.
|
|
642
|
+
* Called once per genuinely NEW turn (both call sites below), so
|
|
643
|
+
* every real "final" gets its own fresh generation — a turn is now
|
|
644
|
+
* "superseded" the instant a newer one starts, not only when an
|
|
645
|
+
* explicit interruption says so. Optional and a no-op by default so
|
|
646
|
+
* every existing call site (own or a test's) that doesn't pass this
|
|
647
|
+
* keeps behaving exactly as before. */
|
|
648
|
+
bumpGeneration?: () => void,
|
|
333
649
|
): Promise<void> {
|
|
334
650
|
let msg: any;
|
|
335
651
|
try {
|
|
@@ -343,7 +659,10 @@ export async function handleDeepgramMessage(
|
|
|
343
659
|
// after utterance_end_ms of silence — a safety net for the rare case a
|
|
344
660
|
// Results message never carries speech_final:true, so a turn can't get
|
|
345
661
|
// permanently stuck with real transcript sitting in the buffer forever.
|
|
346
|
-
if (turnState.buffer)
|
|
662
|
+
if (turnState.buffer) {
|
|
663
|
+
bumpGeneration?.();
|
|
664
|
+
await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration, waitForToolResult, recordMemoryTurn, getScopeId);
|
|
665
|
+
}
|
|
347
666
|
return;
|
|
348
667
|
}
|
|
349
668
|
|
|
@@ -372,7 +691,8 @@ export async function handleDeepgramMessage(
|
|
|
372
691
|
return;
|
|
373
692
|
}
|
|
374
693
|
|
|
375
|
-
|
|
694
|
+
bumpGeneration?.();
|
|
695
|
+
await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration, waitForToolResult, recordMemoryTurn, getScopeId);
|
|
376
696
|
}
|
|
377
697
|
|
|
378
698
|
/**
|
|
@@ -396,12 +716,13 @@ export async function handleDeepgramMessage(
|
|
|
396
716
|
* ones aren't) doesn't end the turn here: the server can't execute a DOM
|
|
397
717
|
* action itself, so it sends the step to the client, awaits its real
|
|
398
718
|
* result over waitForToolResult(), folds that into a *local* working copy
|
|
399
|
-
* of history, and calls resolveVerb again — repeat up to
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
719
|
+
* of history, and calls resolveVerb again — repeat up to the iteration cap
|
|
720
|
+
* (driveAgentLoop's default of 6, in agent-loop.ts — the shared skeleton
|
|
721
|
+
* this function and the HTTP path's runTypedAgentLoop (index.tsx) both
|
|
722
|
+
* drive). The connection's real `history` only gets the user's real
|
|
723
|
+
* question plus the turn's final answer, committed once at the end here —
|
|
724
|
+
* a turn that hits the cap mid-loop doesn't leave partial tool noise in
|
|
725
|
+
* the conversation's real memory.
|
|
405
726
|
*/
|
|
406
727
|
async function finalizeTurn(
|
|
407
728
|
turnState: { buffer: string },
|
|
@@ -412,15 +733,22 @@ async function finalizeTurn(
|
|
|
412
733
|
history: HistoryTurn[],
|
|
413
734
|
getGeneration: () => number,
|
|
414
735
|
waitForToolResult: () => Promise<string>,
|
|
736
|
+
recordMemoryTurn?: (role: "user" | "assistant", text: string) => void,
|
|
737
|
+
/** Phase 5 step 2 — this connection's real scopeId, if it has one yet
|
|
738
|
+
* (see the "context" message handler). A getter, same pattern as
|
|
739
|
+
* getContext/getGeneration, since it can be set AFTER this turn
|
|
740
|
+
* already started (a scopeId only ever arrives via a "context"
|
|
741
|
+
* message, and a turn can begin before one has). Optional; absent or
|
|
742
|
+
* returning null both mean "no memory-backed tools offered." */
|
|
743
|
+
getScopeId?: () => string | null,
|
|
415
744
|
): Promise<void> {
|
|
416
745
|
const transcript = turnState.buffer;
|
|
417
746
|
turnState.buffer = "";
|
|
418
747
|
const myGeneration = getGeneration();
|
|
419
|
-
safeSend(client, { type: "final", text: transcript });
|
|
748
|
+
safeSend(client, { type: "final", text: transcript, generation: myGeneration });
|
|
420
749
|
|
|
421
|
-
let loopHistory = history;
|
|
422
750
|
// The Talker: set once, the first time a turn turns out to need more
|
|
423
|
-
// than one step (see
|
|
751
|
+
// than one step (see onStep below) — a real, in-flight speakStreamed()
|
|
424
752
|
// call, never awaited until we're actually ready to speak the real
|
|
425
753
|
// answer. Deliberately not re-triggered per step: the Speak connection
|
|
426
754
|
// (speakStreamed) only ever handles one utterance at a time, so a second
|
|
@@ -428,28 +756,138 @@ async function finalizeTurn(
|
|
|
428
756
|
// handling instead of queuing cleanly.
|
|
429
757
|
let ackPromise: Promise<void> | null = null;
|
|
430
758
|
|
|
759
|
+
// Phase 3 step 5 — the Talker's real event stream ("Revisable by
|
|
760
|
+
// Design"'s pattern): a pure, fire-and-forget consumer, never awaited
|
|
761
|
+
// by driveAgentLoop, never able to affect its control flow. This
|
|
762
|
+
// realtime transport's own Talker projection is intentionally small —
|
|
763
|
+
// the only event type it currently DOES anything with is "inj" (the
|
|
764
|
+
// ack phrase), which it turns into the same real speakStreamed() call
|
|
765
|
+
// as before, just reached through a real event instead of an inline
|
|
766
|
+
// side effect inside onStep. "act"/"obs"/"thk" events flow through the
|
|
767
|
+
// same stream (driveAgentLoop already emits act/obs on its own; the
|
|
768
|
+
// Critic below emits a real "thk" with its own reasoning) but aren't
|
|
769
|
+
// consumed for anything yet — logged, not narrated, a real seam for a
|
|
770
|
+
// future richer Talker to attach to without touching the loop again.
|
|
771
|
+
function emitEvent(event: AgentEvent): void {
|
|
772
|
+
switch (event.type) {
|
|
773
|
+
case "inj":
|
|
774
|
+
// Phase 2 step 3 — the ack phrase's audio was already the only
|
|
775
|
+
// thing the user hears; this text-bearing sibling message makes
|
|
776
|
+
// WHAT was said visible in the wire protocol too — today the
|
|
777
|
+
// only way to know (packages/evals' voiceFrames capture full
|
|
778
|
+
// frames, but an "inj" event never otherwise reaches the client
|
|
779
|
+
// as readable text, only as synthesized audio). Purely
|
|
780
|
+
// informational — a client that ignores unknown message types
|
|
781
|
+
// loses nothing.
|
|
782
|
+
safeSend(client, { type: "ack", text: event.text });
|
|
783
|
+
ackPromise = speakStreamed(event.text);
|
|
784
|
+
return;
|
|
785
|
+
case "act":
|
|
786
|
+
console.log("[cairn talker] act:", summarizeVerbForHistory(event.verb));
|
|
787
|
+
return;
|
|
788
|
+
case "obs":
|
|
789
|
+
console.log("[cairn talker] obs:", event.observation);
|
|
790
|
+
return;
|
|
791
|
+
case "thk":
|
|
792
|
+
console.log("[cairn talker] thk:", event.text);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Architecture Pillar 4 — real Plan/Progress state the Critic (below)
|
|
798
|
+
// actually acts on, not just observability. Started EAGERLY, before the
|
|
799
|
+
// first real step even runs, when looksMultiStep(transcript) already
|
|
800
|
+
// flags this as a probable compound goal — replacing the old lazy gate
|
|
801
|
+
// (kicked off only once a turn had already revealed a non-terminal
|
|
802
|
+
// first step, one full model round trip later than it needed to be).
|
|
803
|
+
// A false-negative heuristic miss still falls back to that same lazy
|
|
804
|
+
// path below (`if (planLLM && !planPromise)`), so nothing regresses —
|
|
805
|
+
// this only ever makes planning START EARLIER, never skips it. Only
|
|
806
|
+
// the realtime transport had this Plan/Progress wiring until now — see
|
|
807
|
+
// index.tsx's runTypedAgentLoop for the typed/HTTP transport's own
|
|
808
|
+
// version, added in the same pass.
|
|
809
|
+
let planPromise: Promise<Plan> | null = null;
|
|
810
|
+
let plan: Plan | null = null;
|
|
811
|
+
let progress: ProgressLedger | null = null;
|
|
812
|
+
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
|
|
813
|
+
|
|
814
|
+
// Architecture Pillar 3 (Skill half) — real, per-deployment Skills
|
|
815
|
+
// (skill-store.ts), a DIFFERENT scope axis than `memory` (per-user).
|
|
816
|
+
// Computed once, up front, since both the Planner (retrieval) and the
|
|
817
|
+
// Critic (accumulating what gets saved after this turn) need it.
|
|
818
|
+
const skillsScopeId = deps.skillsScopeId ?? "default";
|
|
819
|
+
const skillSummaries = deps.skills ? deps.skills.listSkillSummaries(skillsScopeId) : [];
|
|
820
|
+
const matchedSkillSummary = skillSummaries.length ? matchSkillByGoal(skillSummaries, transcript) : null;
|
|
821
|
+
const skillsPayload = skillSummaries.length
|
|
822
|
+
? {
|
|
823
|
+
summariesText: renderSkillSummaries(skillSummaries) || undefined,
|
|
824
|
+
suggestedInstructions: matchedSkillSummary ? (deps.skills!.getSkill(skillsScopeId, matchedSkillSummary.id)?.instructions ?? undefined) : undefined,
|
|
825
|
+
}
|
|
826
|
+
: undefined;
|
|
827
|
+
// Every real, Critic-verified learnedFact from this turn's steps — the
|
|
828
|
+
// Formulator (compileSkill) compiles whatever's here into a real Skill
|
|
829
|
+
// once the turn concludes, below. Empty is the common case, not a gap.
|
|
830
|
+
const learnedFacts: string[] = [];
|
|
831
|
+
|
|
832
|
+
// Architecture Pillar 5 — the Archive tier, checked once per turn
|
|
833
|
+
// (never always-injected the way Core facts are — those are seeded
|
|
834
|
+
// once per CONNECTION, in the "context" message handler above). Added
|
|
835
|
+
// only to THIS turn's own ephemeral working history, never persisted
|
|
836
|
+
// into the connection's real `history` array below — a fact resurfaced
|
|
837
|
+
// because it happened to relate to this one question shouldn't linger
|
|
838
|
+
// in context for the rest of the conversation the way a Core fact
|
|
839
|
+
// deliberately does.
|
|
840
|
+
const archiveScopeId = getScopeId?.() ?? null;
|
|
841
|
+
const archivedSummary = deps.memory && archiveScopeId ? formatArchivedFacts(deps.memory.recallArchivedFacts(archiveScopeId, transcript)) : null;
|
|
842
|
+
const historyForThisTurn = archivedSummary ? [...history, { role: "assistant" as const, text: archivedSummary }] : history;
|
|
843
|
+
|
|
431
844
|
try {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
liveElements,
|
|
439
|
-
webMcpTools,
|
|
440
|
-
history: loopHistory,
|
|
441
|
-
});
|
|
845
|
+
const planLLM = deps.planLLM;
|
|
846
|
+
const criticLLM = deps.criticLLM;
|
|
847
|
+
|
|
848
|
+
if (planLLM && looksMultiStep(transcript)) {
|
|
849
|
+
planPromise = resolvePlan(planLLM, transcript, 1, deps.manifest, renderRegisteredActions(deps.registeredActions, deps.actionDescriptions), skillsPayload);
|
|
850
|
+
}
|
|
442
851
|
|
|
443
|
-
|
|
852
|
+
const result = await driveAgentLoop(historyForThisTurn, {
|
|
853
|
+
async getNextStep(loopHistory) {
|
|
854
|
+
const { route, visible, liveElements, webMcpTools } = getContext();
|
|
855
|
+
// Phase 5 step 2 — offered only when there's somewhere real to
|
|
856
|
+
// write it (memory configured AND this connection has a real
|
|
857
|
+
// scopeId) — never a tool the model can call into a void.
|
|
858
|
+
const availableTools = deps.memory && getScopeId?.() ? [...webMcpTools, REMEMBER_FACT_TOOL] : webMcpTools;
|
|
859
|
+
return resolveVerb(deps.llm, deps.systemPrompt, deps.manifest, deps.registeredActions, deps.capability, {
|
|
860
|
+
route,
|
|
861
|
+
question: transcript,
|
|
862
|
+
visible,
|
|
863
|
+
liveElements,
|
|
864
|
+
webMcpTools: availableTools,
|
|
865
|
+
history: loopHistory,
|
|
866
|
+
});
|
|
867
|
+
},
|
|
868
|
+
onStep({ verb, iteration, terminal }) {
|
|
869
|
+
if (myGeneration !== getGeneration()) return true; // superseded by a barge-in while this turn was resolving
|
|
444
870
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
871
|
+
// Phase 5 step 2 — a remember_fact call is handled entirely
|
|
872
|
+
// server-side (see executeStep below) and must NEVER be sent to
|
|
873
|
+
// the client: the client would try to look it up in its own
|
|
874
|
+
// real WebMCP tool registry, fail to find it (it's synthetic,
|
|
875
|
+
// server-only), and report an error tool_result back — landing
|
|
876
|
+
// on whatever's THEN occupying the single-slot
|
|
877
|
+
// pendingToolResultResolve, which by then could easily belong
|
|
878
|
+
// to a genuinely later, unrelated step. Suppressing this send
|
|
879
|
+
// is not an optimization, it's what keeps that real race from
|
|
880
|
+
// ever being possible.
|
|
881
|
+
const isRememberFactCall = verb.verb === "call_tool" && verb.name === REMEMBER_FACT_TOOL_NAME;
|
|
882
|
+
if (!isRememberFactCall) {
|
|
883
|
+
// Sent immediately — before speech synthesis even starts — so
|
|
884
|
+
// highlight/navigate/do execute in the browser right away instead
|
|
885
|
+
// of waiting on audio. The agent visibly acts while it's still
|
|
886
|
+
// about to speak, not after.
|
|
887
|
+
safeSend(client, { type: "verb", verb, generation: myGeneration });
|
|
888
|
+
}
|
|
450
889
|
|
|
451
|
-
|
|
452
|
-
if (i === 0) {
|
|
890
|
+
if (!terminal && iteration === 0) {
|
|
453
891
|
// This turn just revealed it needs more than one step — speak a
|
|
454
892
|
// quick, cheap acknowledgment *now*, in parallel with the rest
|
|
455
893
|
// of the loop's own real work below (not awaited here), so the
|
|
@@ -457,22 +895,136 @@ async function finalizeTurn(
|
|
|
457
895
|
// air for however long the real multi-step answer takes.
|
|
458
896
|
// Single-step turns (the common case) never reach this branch
|
|
459
897
|
// at all, so they keep today's latency exactly as it is.
|
|
460
|
-
|
|
898
|
+
// Emitted as a real "inj" event now (step 5), consumed by
|
|
899
|
+
// emitEvent above — same real speakStreamed() call, reached
|
|
900
|
+
// through the event stream instead of an inline side effect.
|
|
901
|
+
emitEvent({ type: "inj", text: ACK_PHRASES[Math.floor(Math.random() * ACK_PHRASES.length)], at: Date.now() });
|
|
902
|
+
// The lazy fallback — only fires when looksMultiStep missed
|
|
903
|
+
// (planPromise is still null): a real Plan is still guaranteed
|
|
904
|
+
// before the Critic needs one, just one round trip later than
|
|
905
|
+
// the eager path above.
|
|
906
|
+
if (planLLM && !planPromise) planPromise = resolvePlan(planLLM, transcript, 1, deps.manifest, renderRegisteredActions(deps.registeredActions, deps.actionDescriptions), skillsPayload);
|
|
461
907
|
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
908
|
+
return false;
|
|
909
|
+
},
|
|
910
|
+
// A continuing step itself stays silent (keeps the loop fast; the
|
|
911
|
+
// client still shows it visually) — wait for its real result and go
|
|
912
|
+
// around again instead of ending the turn.
|
|
913
|
+
executeStep: (verb) => {
|
|
914
|
+
// Phase 5 step 2 — resolved entirely in-process, never routed
|
|
915
|
+
// through the client's real tool-execution round trip (see
|
|
916
|
+
// onStep's own doc comment for why the client is never even
|
|
917
|
+
// told this step happened).
|
|
918
|
+
const currentScopeId = getScopeId?.() ?? null;
|
|
919
|
+
if (verb.verb === "call_tool" && verb.name === REMEMBER_FACT_TOOL_NAME && deps.memory && currentScopeId) {
|
|
920
|
+
return handleRememberFactTool(deps.memory, currentScopeId, verb.args);
|
|
921
|
+
}
|
|
922
|
+
return waitForToolResult();
|
|
923
|
+
},
|
|
924
|
+
onStepResult: () => myGeneration !== getGeneration(),
|
|
925
|
+
onEvent: emitEvent,
|
|
926
|
+
runCritic:
|
|
927
|
+
planLLM && criticLLM
|
|
928
|
+
? async ({ verb, observation }) => {
|
|
929
|
+
// Real state, not the Executor's self-report — see
|
|
930
|
+
// resolveCritic's own doc comment for why this is a
|
|
931
|
+
// genuinely separate pass, mirroring judge.ts's own
|
|
932
|
+
// precedent. Awaited here (not just logged) on its FIRST
|
|
933
|
+
// use — by now at least one real tool round trip has
|
|
934
|
+
// already happened, so the Planner call kicked off above
|
|
935
|
+
// has likely already resolved in parallel; this is not a
|
|
936
|
+
// NEW blocking wait so much as picking up work already in
|
|
937
|
+
// flight.
|
|
938
|
+
if (!plan) {
|
|
939
|
+
plan = planPromise ? await planPromise : fallbackPlan(transcript, 1);
|
|
940
|
+
progress = { planVersion: plan.version, currentTaskIndex: 0, stallCount: 0 };
|
|
941
|
+
}
|
|
942
|
+
const currentProgress = progress!;
|
|
943
|
+
const currentTask = plan.tasks[currentProgress.currentTaskIndex];
|
|
944
|
+
const verdict = await resolveCritic(criticLLM, currentTask, transcript, verb, observation);
|
|
945
|
+
// A real "thk" event — the Critic's own reasoning, narrated
|
|
946
|
+
// onto the same event stream the ack/act/obs events already
|
|
947
|
+
// flow through (not spoken today, just carried — see
|
|
948
|
+
// emitEvent's own doc comment on why that's a deliberate,
|
|
949
|
+
// small v1 scope).
|
|
950
|
+
emitEvent({ type: "thk", text: verdict.reasoning, at: Date.now() });
|
|
951
|
+
// Architecture Pillar 3 (Skill half) — accumulate whatever
|
|
952
|
+
// this step's real, Critic-verified fact was; the
|
|
953
|
+
// Formulator compiles whatever's here into a real Skill
|
|
954
|
+
// once the turn concludes, below. The common case adds
|
|
955
|
+
// nothing here at all.
|
|
956
|
+
if (verdict.learnedFact) learnedFacts.push(verdict.learnedFact);
|
|
957
|
+
|
|
958
|
+
if (verdict.verdict === "task_complete") {
|
|
959
|
+
currentTask.status = "done";
|
|
960
|
+
if (currentProgress.currentTaskIndex < plan.tasks.length - 1) {
|
|
961
|
+
// More tasks remain — advance and keep looping instead
|
|
962
|
+
// of ending the turn here.
|
|
963
|
+
currentProgress.currentTaskIndex++;
|
|
964
|
+
plan.tasks[currentProgress.currentTaskIndex].status = "in_progress";
|
|
965
|
+
currentProgress.stallCount = 0;
|
|
966
|
+
return { ...verdict, verdict: "continue" };
|
|
967
|
+
}
|
|
968
|
+
// The real bug fix: the LAST task is genuinely done —
|
|
969
|
+
// end the loop right here instead of asking the model
|
|
970
|
+
// again and hoping it notices its own success.
|
|
971
|
+
return verdict;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
if (verdict.verdict === "replan") {
|
|
975
|
+
// A fresh Planner call, a real new version — never a
|
|
976
|
+
// silent patch to the existing plan.
|
|
977
|
+
plan = await resolvePlan(planLLM, transcript, plan.version + 1, deps.manifest, renderRegisteredActions(deps.registeredActions, deps.actionDescriptions), skillsPayload);
|
|
978
|
+
progress = { planVersion: plan.version, currentTaskIndex: 0, stallCount: 0 };
|
|
979
|
+
return { ...verdict, verdict: "continue" };
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
if (verdict.verdict === "give_up") return verdict;
|
|
473
983
|
|
|
984
|
+
// "continue" — a harness-enforced fail-safe on top of the
|
|
985
|
+
// Critic's own judgment: crossing a bounded stall budget
|
|
986
|
+
// escalates to give_up itself, rather than trusting the
|
|
987
|
+
// Critic alone to eventually notice it's stuck (Magentic-One's
|
|
988
|
+
// own two-tier tolerance pattern).
|
|
989
|
+
currentProgress.stallCount++;
|
|
990
|
+
if (currentProgress.stallCount >= STALL_THRESHOLD) {
|
|
991
|
+
return {
|
|
992
|
+
verdict: "give_up",
|
|
993
|
+
reasoning: `Stuck after ${currentProgress.stallCount} steps with no confirmed progress on "${currentTask.description}" — ${verdict.reasoning}`,
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
return verdict;
|
|
997
|
+
}
|
|
998
|
+
: undefined,
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
if (result.outcome === "aborted") return;
|
|
1002
|
+
|
|
1003
|
+
// Architecture Pillar 3 (Skill half) — the Formulator, run once per
|
|
1004
|
+
// turn (not per-step — cheap on purpose). Saves nothing when nothing
|
|
1005
|
+
// was learned (the common case) or no SkillStore is configured (zero
|
|
1006
|
+
// overhead, today's exact behavior). Classified from whatever this
|
|
1007
|
+
// exact moment's live context reports — a best-effort snapshot, not
|
|
1008
|
+
// necessarily the exact page a given fact was learned on, which is
|
|
1009
|
+
// an acceptable trade for a Skill meant to be a general per-platform
|
|
1010
|
+
// note rather than a per-page one.
|
|
1011
|
+
if (deps.skills && learnedFacts.length > 0) {
|
|
1012
|
+
const patternMatches = classifyUiPattern(deriveStructureSignals(getContext().liveElements));
|
|
1013
|
+
const skill = compileSkill(transcript, learnedFacts, patternMatches[0]?.pattern);
|
|
1014
|
+
if (skill) deps.skills.saveSkill(skillsScopeId, skill);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
if (result.outcome === "terminal" || result.outcome === "unparseable" || result.outcome === "critic-complete") {
|
|
1018
|
+
const verb: VerbResponse =
|
|
1019
|
+
result.outcome === "terminal"
|
|
1020
|
+
? result.finalVerb
|
|
1021
|
+
: result.outcome === "critic-complete"
|
|
1022
|
+
? { verb: "explain", text: result.verdict.reasoning }
|
|
1023
|
+
: { verb: "explain", text: "I'm not sure how to help with that." };
|
|
474
1024
|
history.push({ role: "user", text: transcript }, { role: "assistant", text: summarizeVerbForHistory(verb) });
|
|
475
1025
|
history.splice(0, Math.max(0, history.length - MAX_HISTORY_TURNS));
|
|
1026
|
+
recordMemoryTurn?.("user", transcript);
|
|
1027
|
+
recordMemoryTurn?.("assistant", summarizeVerbForHistory(verb));
|
|
476
1028
|
|
|
477
1029
|
if (ackPromise) {
|
|
478
1030
|
// Never start a second speakStreamed call before the first (the
|
|
@@ -488,29 +1040,39 @@ async function finalizeTurn(
|
|
|
488
1040
|
// A verb with no spoken text (highlight/navigate/do often have none)
|
|
489
1041
|
// still needs to unstick the client's "thinking" state and let the mic
|
|
490
1042
|
// resume — turn_complete covers that with no audio path involved.
|
|
491
|
-
|
|
492
|
-
|
|
1043
|
+
const textToSpeak = "text" in verb ? (verb.text ?? undefined) : undefined;
|
|
1044
|
+
|
|
1045
|
+
if (textToSpeak) {
|
|
1046
|
+
await speakStreamed(textToSpeak);
|
|
493
1047
|
} else {
|
|
494
|
-
safeSend(client, { type: "turn_complete" });
|
|
1048
|
+
safeSend(client, { type: "turn_complete", generation: myGeneration });
|
|
495
1049
|
}
|
|
496
1050
|
return;
|
|
497
1051
|
}
|
|
498
1052
|
|
|
499
|
-
// Iteration cap hit with no terminal verb
|
|
500
|
-
// of leaving the client
|
|
501
|
-
|
|
1053
|
+
// Iteration cap hit with no terminal verb, OR the Critic/stall
|
|
1054
|
+
// fail-safe gave up — degrade honestly instead of leaving the client
|
|
1055
|
+
// waiting forever. A real Critic give-up carries its own specific
|
|
1056
|
+
// reasoning, which is a genuinely better message than the generic
|
|
1057
|
+
// fallback below — use it when there is one.
|
|
1058
|
+
const giveUpText =
|
|
1059
|
+
result.outcome === "critic-give-up" ? result.verdict.reasoning : "I wasn't able to finish that — try asking again or breaking it into smaller steps.";
|
|
1060
|
+
const gaveUpSummary = result.outcome === "critic-give-up" ? giveUpText : "(gave up after too many steps)";
|
|
1061
|
+
history.push({ role: "user", text: transcript }, { role: "assistant", text: gaveUpSummary });
|
|
502
1062
|
history.splice(0, Math.max(0, history.length - MAX_HISTORY_TURNS));
|
|
503
|
-
|
|
1063
|
+
recordMemoryTurn?.("user", transcript);
|
|
1064
|
+
recordMemoryTurn?.("assistant", gaveUpSummary);
|
|
1065
|
+
safeSend(client, { type: "verb", verb: { verb: "explain", text: giveUpText }, generation: myGeneration });
|
|
504
1066
|
if (ackPromise) {
|
|
505
1067
|
await ackPromise;
|
|
506
1068
|
if (myGeneration !== getGeneration()) return;
|
|
507
1069
|
}
|
|
508
|
-
await speakStreamed(
|
|
1070
|
+
await speakStreamed(giveUpText);
|
|
509
1071
|
} catch (err) {
|
|
510
1072
|
console.error("[cairn realtime] failed to resolve/speak this turn:", err);
|
|
511
1073
|
if (myGeneration === getGeneration()) {
|
|
512
1074
|
safeSend(client, { type: "error", message: "Something went wrong answering that — try again." });
|
|
513
|
-
safeSend(client, { type: "turn_complete" });
|
|
1075
|
+
safeSend(client, { type: "turn_complete", generation: myGeneration });
|
|
514
1076
|
}
|
|
515
1077
|
}
|
|
516
1078
|
}
|
|
@@ -556,32 +1118,3 @@ function parseWebMcpTools(raw: unknown): WebMcpTool[] {
|
|
|
556
1118
|
return tools;
|
|
557
1119
|
}
|
|
558
1120
|
|
|
559
|
-
/** A short text form of any verb for the history log — not shown to the
|
|
560
|
-
* user, just fed back to the model on later turns so it knows what it
|
|
561
|
-
* already did/said. */
|
|
562
|
-
function summarizeVerbForHistory(verb: VerbResponse): string {
|
|
563
|
-
if ("text" in verb && verb.text) return verb.text;
|
|
564
|
-
switch (verb.verb) {
|
|
565
|
-
case "highlight":
|
|
566
|
-
case "open":
|
|
567
|
-
return `(highlighted ${verb.target})`;
|
|
568
|
-
case "navigate":
|
|
569
|
-
return `(navigated to ${verb.route})`;
|
|
570
|
-
case "do":
|
|
571
|
-
return `(ran ${verb.action}${verb.target ? ` on ${verb.target}` : ""})`;
|
|
572
|
-
case "tour":
|
|
573
|
-
return verb.steps.map((s) => s.text).join(" ");
|
|
574
|
-
case "click":
|
|
575
|
-
return `(clicked ${verb.target})`;
|
|
576
|
-
case "fill":
|
|
577
|
-
return `(typed "${verb.value}" into ${verb.target})`;
|
|
578
|
-
case "read":
|
|
579
|
-
return `(read ${verb.target})`;
|
|
580
|
-
case "call_tool":
|
|
581
|
-
return `(called ${verb.name})`;
|
|
582
|
-
case "batch":
|
|
583
|
-
return `(${verb.actions.length} steps: ${verb.actions.map((a) => a.verb).join(", ")})`;
|
|
584
|
-
default:
|
|
585
|
-
return "(no response)";
|
|
586
|
-
}
|
|
587
|
-
}
|