@cairnvibe/sdk 0.2.13 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/agent-loop.d.ts +113 -0
  2. package/dist/agent-loop.js +128 -0
  3. package/dist/cairn-widget.js +14 -9
  4. package/dist/cursor-overlay.d.ts +19 -0
  5. package/dist/cursor-overlay.js +126 -0
  6. package/dist/element-ladder.d.ts +71 -0
  7. package/dist/element-ladder.js +168 -0
  8. package/dist/index.d.ts +79 -1
  9. package/dist/index.js +886 -96
  10. package/dist/key-rotator.d.ts +28 -0
  11. package/dist/key-rotator.js +57 -3
  12. package/dist/memory-sqlite.d.ts +86 -0
  13. package/dist/memory-sqlite.js +230 -0
  14. package/dist/realtime-cli.js +22 -1
  15. package/dist/realtime-server.d.ts +83 -2
  16. package/dist/realtime-server.js +561 -121
  17. package/dist/server.d.ts +266 -5
  18. package/dist/server.js +1013 -83
  19. package/dist/skill-store.d.ts +17 -0
  20. package/dist/skill-store.js +78 -0
  21. package/dist/tts-stream.d.ts +25 -0
  22. package/dist/tts-stream.js +32 -0
  23. package/dist/vad.d.ts +27 -0
  24. package/dist/vad.js +128 -0
  25. package/dist/verb-executor.d.ts +32 -11
  26. package/dist/verb-executor.js +315 -39
  27. package/dist/webmcp-client.d.ts +14 -1
  28. package/dist/webmcp-client.js +22 -1
  29. package/package.json +3 -1
  30. package/src/agent-loop.ts +222 -0
  31. package/src/cursor-overlay.ts +130 -0
  32. package/src/element-ladder.ts +170 -0
  33. package/src/index.tsx +935 -100
  34. package/src/key-rotator.ts +57 -2
  35. package/src/memory-sqlite.ts +283 -0
  36. package/src/realtime-cli.ts +24 -1
  37. package/src/realtime-server.ts +669 -123
  38. package/src/server.ts +1119 -83
  39. package/src/skill-store.ts +88 -0
  40. package/src/tts-stream.ts +30 -0
  41. package/src/vad.ts +153 -0
  42. package/src/verb-executor.ts +329 -42
  43. package/src/web-component.ts +97 -24
  44. package/src/webmcp-client.ts +30 -2
package/dist/server.js CHANGED
@@ -7,18 +7,41 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
7
7
  return (mod && mod.__esModule) ? mod : { "default": mod };
8
8
  };
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
- exports.GroqVerbLLM = exports.AnthropicVerbLLM = void 0;
10
+ exports.GroqStreamingTextLLM = exports.GroqVerbLLM = exports.AnthropicStreamingTextLLM = exports.AnthropicVerbLLM = exports.KeyRotator = void 0;
11
11
  exports.createCopilotHandler = createCopilotHandler;
12
12
  exports.createCopilotHandlerWithLLM = createCopilotHandlerWithLLM;
13
13
  exports.resolveVerb = resolveVerb;
14
+ exports.resolvePlan = resolvePlan;
15
+ exports.fallbackPlan = fallbackPlan;
16
+ exports.resolveCritic = resolveCritic;
17
+ exports.createCriticLLM = createCriticLLM;
14
18
  exports.createVerbLLM = createVerbLLM;
19
+ exports.createPlanLLM = createPlanLLM;
20
+ exports.compileSkill = compileSkill;
21
+ exports.matchSkillByGoal = matchSkillByGoal;
22
+ exports.renderSkillSummaries = renderSkillSummaries;
23
+ exports.createPlanHandler = createPlanHandler;
24
+ exports.createPlanHandlerWithLLM = createPlanHandlerWithLLM;
25
+ exports.createCriticHandler = createCriticHandler;
26
+ exports.createCriticHandlerWithLLM = createCriticHandlerWithLLM;
27
+ exports.createSkillSaveHandler = createSkillSaveHandler;
28
+ exports.renderRegisteredActions = renderRegisteredActions;
15
29
  exports.buildVerbToolSchema = buildVerbToolSchema;
16
30
  exports.buildSystemPrompt = buildSystemPrompt;
17
31
  const sdk_1 = __importDefault(require("@anthropic-ai/sdk"));
18
32
  const groq_sdk_1 = __importDefault(require("groq-sdk"));
33
+ const zod_1 = require("zod");
19
34
  const core_1 = require("@cairnvibe/core");
20
- const key_rotator_1 = require("./key-rotator");
35
+ const agent_loop_1 = require("./agent-loop");
36
+ const memory_sqlite_1 = require("./memory-sqlite");
37
+ var key_rotator_1 = require("./key-rotator");
38
+ Object.defineProperty(exports, "KeyRotator", { enumerable: true, get: function () { return key_rotator_1.KeyRotator; } });
39
+ const key_rotator_2 = require("./key-rotator");
21
40
  const VERB_TOOL_NAME = "respond_with_verb";
41
+ const PLAN_TOOL_NAME = "create_plan";
42
+ const PLAN_TOOL_DESCRIPTION = "Submit an ordered task plan for achieving the user's real end goal.";
43
+ const CRITIC_TOOL_NAME = "submit_verdict";
44
+ const CRITIC_TOOL_DESCRIPTION = "Submit your verdict on whether the current task is actually done, based on the real resulting state.";
22
45
  const TIER_ALLOWED_VERBS = {
23
46
  // "read" is non-mutating (pure observation, like highlight) so it's
24
47
  // available at every tier — a turn that only ever reads is exactly as
@@ -31,19 +54,62 @@ function createCopilotHandler(manifest, options = {}) {
31
54
  const registeredActions = options.registeredActions ?? [];
32
55
  const capability = options.capability ?? "act";
33
56
  const llm = createVerbLLM(options);
34
- return createCopilotHandlerWithLLM(manifest, llm, { registeredActions, capability, persona: options.persona });
57
+ return createCopilotHandlerWithLLM(manifest, llm, {
58
+ registeredActions,
59
+ capability,
60
+ persona: options.persona,
61
+ actionDescriptions: options.actionDescriptions,
62
+ memory: options.memory,
63
+ });
35
64
  }
36
65
  /** Same as `createCopilotHandler`, but with the LLM injected — used by tests to fake it. */
37
66
  function createCopilotHandlerWithLLM(manifest, llm, options = {}) {
38
67
  const registeredActions = options.registeredActions ?? [];
39
68
  const capability = options.capability ?? "act";
40
- const systemPrompt = buildSystemPrompt(manifest, registeredActions, options.persona);
69
+ const actionDescriptions = options.actionDescriptions ?? {};
70
+ const systemPrompt = buildSystemPrompt(manifest, registeredActions, options.persona, actionDescriptions);
41
71
  return async function handleCopilotRequest(body) {
42
72
  const parsedRequest = core_1.CopilotRequestSchema.safeParse(body);
43
73
  if (!parsedRequest.success) {
44
74
  return { status: 400, body: { error: "invalid request body" } };
45
75
  }
46
- const verb = await resolveVerb(llm, systemPrompt, manifest, registeredActions, capability, parsedRequest.data);
76
+ const input = parsedRequest.data;
77
+ // Phase 5 step 4 — real cross-session memory for the typed/HTTP
78
+ // transport. Unlike the realtime relay (one persistent connection,
79
+ // seeded once), this is stateless per request — seeded only when
80
+ // the CLIENT's own history arrives empty, the real signal for "this
81
+ // is a genuinely fresh session" (a session already accumulating its
82
+ // own history client-side is never re-seeded on top of itself; see
83
+ // CreateCopilotHandlerOptions.memory's own doc comment).
84
+ let effectiveHistory = input.history ?? [];
85
+ if (options.memory && input.scopeId && effectiveHistory.length === 0) {
86
+ const priorTurns = options.memory.recentTurns(input.scopeId);
87
+ effectiveHistory = (0, memory_sqlite_1.seedHistoryFromMemory)([], priorTurns, agent_loop_1.MAX_HISTORY_TURNS);
88
+ const factsSummary = (0, memory_sqlite_1.formatRememberedFacts)(options.memory.recallFacts(input.scopeId));
89
+ if (factsSummary)
90
+ effectiveHistory = [{ role: "assistant", text: factsSummary }, ...effectiveHistory];
91
+ }
92
+ // Architecture Pillar 5 — the Archive tier, checked on EVERY request
93
+ // (not just a fresh session — unlike Core facts above, an archived
94
+ // fact is never always-injected, only surfaced when THIS question
95
+ // actually relates to it, which can happen at any point in an
96
+ // ongoing conversation, not only at its start).
97
+ if (options.memory && input.scopeId) {
98
+ const archivedMatch = options.memory.recallArchivedFacts(input.scopeId, input.question);
99
+ const archivedSummary = (0, memory_sqlite_1.formatArchivedFacts)(archivedMatch);
100
+ if (archivedSummary)
101
+ effectiveHistory = [...effectiveHistory, { role: "assistant", text: archivedSummary }];
102
+ }
103
+ const verb = await resolveVerb(llm, systemPrompt, manifest, registeredActions, capability, { ...input, history: effectiveHistory });
104
+ // Recorded only for a TERMINAL verb — matching the realtime relay's
105
+ // own discipline exactly: a continuing step (click/fill/read/
106
+ // call_tool/batch, or now a navigate marked continueAfter — see
107
+ // isTerminalVerb's own doc comment) is an internal implementation
108
+ // detail of one logical exchange, never its own remembered "turn".
109
+ if (options.memory && input.scopeId && (0, core_1.isTerminalVerb)(verb)) {
110
+ options.memory.recordTurn(input.scopeId, "user", input.question);
111
+ options.memory.recordTurn(input.scopeId, "assistant", (0, agent_loop_1.summarizeVerbForHistory)(verb));
112
+ }
47
113
  return { status: 200, body: verb };
48
114
  };
49
115
  }
@@ -56,13 +122,25 @@ function createCopilotHandlerWithLLM(manifest, llm, options = {}) {
56
122
  async function resolveVerb(llm, systemPrompt, manifest, registeredActions, capability, input) {
57
123
  let candidate;
58
124
  try {
125
+ // Architecture Pillar 2 — classified from the SAME liveElements this
126
+ // request already carries for element resolution, no new client
127
+ // wiring or payload field needed. Real, checkable evidence (which
128
+ // labels/roles matched), never a bare guess — see ui-patterns.ts's own
129
+ // doc comment. Absent entirely when nothing matched (a page that's
130
+ // none of the known patterns), rather than forcing a hint that isn't real.
131
+ const patternMatches = input.liveElements?.length ? (0, core_1.classifyUiPattern)((0, core_1.deriveStructureSignals)(input.liveElements)) : [];
59
132
  // Element-level detail for the current page only, attached here rather
60
133
  // than baked into the (static, cached) system prompt — see
61
134
  // buildSystemPrompt's comment for why. This payload is already
62
135
  // per-request and was never cached, so there's nothing to lose by
63
136
  // making it bigger; the system prompt is what has to stay small and
64
137
  // route-independent.
65
- const userMessage = JSON.stringify({ ...input, currentPageElements: buildPageElements(manifest, input.route) });
138
+ const userMessage = JSON.stringify({
139
+ ...input,
140
+ currentPageElements: buildPageElements(manifest, input.route),
141
+ currentPageDataShapes: buildPageDataShapes(manifest, input.route),
142
+ ...(patternMatches.length ? { suggestedApproach: (0, core_1.renderPlaybookHint)(patternMatches[0].pattern) } : {}),
143
+ });
66
144
  candidate = await llm.respond(systemPrompt, userMessage);
67
145
  }
68
146
  catch (err) {
@@ -114,11 +192,22 @@ async function resolveVerb(llm, systemPrompt, manifest, registeredActions, capab
114
192
  const pageElements = manifest.pages.find((p) => p.route === input.route)?.elements ?? [];
115
193
  const isKnownTarget = (target) => pageElements.some((e) => e.id === target) || (input.liveElements ?? []).some((e) => e.id === target);
116
194
  const isKnownTool = (name) => (input.webMcpTools ?? []).some((t) => t.name === name);
117
- if (parsedVerb.data.verb === "click" || parsedVerb.data.verb === "fill" || parsedVerb.data.verb === "read") {
195
+ if (parsedVerb.data.verb === "click" || parsedVerb.data.verb === "fill" || parsedVerb.data.verb === "read" || parsedVerb.data.verb === "select" || parsedVerb.data.verb === "scroll" || parsedVerb.data.verb === "wait_for") {
118
196
  if (!isKnownTarget(parsedVerb.data.target)) {
119
197
  return { verb: "explain", text: "I don't see that on this page right now." };
120
198
  }
121
199
  }
200
+ if (parsedVerb.data.verb === "drag") {
201
+ if (!isKnownTarget(parsedVerb.data.target) || !isKnownTarget(parsedVerb.data.to)) {
202
+ return { verb: "explain", text: "I don't see everything I'd need for that on this page right now." };
203
+ }
204
+ }
205
+ // key's target is optional (omitted means "whatever's currently
206
+ // focused") — only check it against real state when the model actually
207
+ // named one, same "never invented" invariant as every other target.
208
+ if (parsedVerb.data.verb === "key" && parsedVerb.data.target && !isKnownTarget(parsedVerb.data.target)) {
209
+ return { verb: "explain", text: "I don't see that on this page right now." };
210
+ }
122
211
  if (parsedVerb.data.verb === "call_tool") {
123
212
  if (!isKnownTool(parsedVerb.data.name)) {
124
213
  return { verb: "explain", text: "That isn't something I can do here." };
@@ -129,7 +218,15 @@ async function resolveVerb(llm, systemPrompt, manifest, registeredActions, capab
129
218
  // partially execute a batch whose later step names something the
130
219
  // model invented; refuse the whole turn instead of guessing which
131
220
  // steps were "safe enough" to run.
132
- const allKnown = parsedVerb.data.actions.every((action) => action.verb === "call_tool" ? isKnownTool(action.name) : isKnownTarget(action.target));
221
+ const allKnown = parsedVerb.data.actions.every((action) => {
222
+ if (action.verb === "call_tool")
223
+ return isKnownTool(action.name);
224
+ if (action.verb === "drag")
225
+ return isKnownTarget(action.target) && isKnownTarget(action.to);
226
+ if (action.verb === "key")
227
+ return !action.target || isKnownTarget(action.target);
228
+ return isKnownTarget(action.target);
229
+ });
133
230
  if (!allKnown) {
134
231
  return { verb: "explain", text: "I don't see everything I'd need for that on this page right now." };
135
232
  }
@@ -148,26 +245,337 @@ async function resolveVerb(llm, systemPrompt, manifest, registeredActions, capab
148
245
  }
149
246
  return parsedVerb.data;
150
247
  }
151
- /** Builds the provider-appropriate VerbLLM from the same options createCopilotHandler accepts — reused by the realtime relay. */
152
- function createVerbLLM(options = {}) {
153
- const registeredActions = options.registeredActions ?? [];
154
- const toolSchema = buildVerbToolSchema(registeredActions);
248
+ /**
249
+ * Phase 3, step 2 (see DEVELOPMENT.md/the plan file) — the Planner half
250
+ * of the Planner/Executor/Critic/Talker redesign. Decomposes a real end
251
+ * goal into an ordered task list BEFORE any execution happens, mirroring
252
+ * resolveVerb's own resilience discipline: never throws to the caller,
253
+ * degrades to a real, usable single-task fallback plan on any failure
254
+ * (a bad LLM response, a schema mismatch, a network error) rather than
255
+ * blocking the turn on a Planner hiccup. `version`/each task's `status`
256
+ * are harness-owned, not asked of the model (PlannerOutputSchema's own
257
+ * doc comment) — assembled here around the model's raw output.
258
+ *
259
+ * Deliberately does NOT yet change what the loop actually does with the
260
+ * result — step 2's own scope is observability only (see the doc comment
261
+ * on this function's call site in realtime-server.ts). The Critic (step
262
+ * 3) is what makes a Plan's tasks/doneContracts actually drive behavior.
263
+ */
264
+ async function resolvePlan(llm, goal, version = 1, manifest, actionsText, skills) {
265
+ let candidate;
266
+ try {
267
+ // manifest/actionsText are appended, optional, and default to absent —
268
+ // additive on purpose (see this function's own exported-API note
269
+ // above): an existing 2- or 3-arg call site (own or a published
270
+ // consumer's) keeps building the exact same {goal} userMessage it
271
+ // always has. Real page/data grounding (Phase 4 step 3) only applies
272
+ // when a caller has a manifest to pass — see buildPlannerPageDirectory's
273
+ // own doc comment for the token-budget discipline behind what it
274
+ // includes. actionsText (Phase 4 step 4) is the SAME rendering
275
+ // buildSystemPrompt/buildVerbToolSchema use for registered actions —
276
+ // pass renderRegisteredActions(...)'s own output, not a hand-rolled
277
+ // string, so the Planner and Executor never describe the same
278
+ // capability two different ways. skills (Architecture Pillar 3) is
279
+ // the same additive shape: `summariesText` (renderSkillSummaries'
280
+ // output — every this-deployment Skill's name+description, cheap to
281
+ // always include) and `suggestedInstructions` (matchSkillByGoal's own
282
+ // match, full instructions, only when one genuinely matched this
283
+ // goal) — both optional, both absent by default for a caller with no
284
+ // SkillStore configured.
285
+ const payload = { goal };
286
+ if (manifest)
287
+ payload.pages = buildPlannerPageDirectory(manifest);
288
+ if (actionsText)
289
+ payload.actions = actionsText;
290
+ if (skills?.summariesText)
291
+ payload.skills = skills.summariesText;
292
+ if (skills?.suggestedInstructions)
293
+ payload.suggestedSkill = skills.suggestedInstructions;
294
+ const userMessage = JSON.stringify(payload);
295
+ candidate = await llm.respond(buildPlannerSystemPrompt(), userMessage);
296
+ }
297
+ catch (err) {
298
+ console.error("[cairn] planner LLM call failed:", err);
299
+ return fallbackPlan(goal, version);
300
+ }
301
+ const parsed = core_1.PlannerOutputSchema.safeParse(candidate);
302
+ if (!parsed.success)
303
+ return fallbackPlan(goal, version);
304
+ return assemblePlan(parsed.data, version);
305
+ }
306
+ function assemblePlan(output, version) {
307
+ return {
308
+ version,
309
+ goal: output.goal,
310
+ facts: output.facts,
311
+ tasks: output.tasks.map((task, i) => ({ ...task, status: i === 0 ? "in_progress" : "pending" })),
312
+ };
313
+ }
314
+ /** The real, single-task plan used when the Planner call itself fails —
315
+ * "do the whole goal as one task" is always a valid (if unstructured)
316
+ * plan, so a Planner hiccup degrades the redesign back to today's
317
+ * behavior instead of blocking the turn. Exported so every caller that
318
+ * needs "a plan, even a trivial one, right now" (e.g. a Critic call that
319
+ * fires before a real Planner result has come back) builds the exact
320
+ * same shape instead of hand-rolling a duplicate literal — realtime-
321
+ * server.ts's own finalizeTurn and index.tsx's runTypedAgentLoop both do
322
+ * this, for the same reason. */
323
+ function fallbackPlan(goal, version) {
324
+ return {
325
+ version,
326
+ goal,
327
+ facts: [],
328
+ tasks: [{ id: "t1", description: goal, doneContract: "The stated goal has been achieved.", status: "in_progress" }],
329
+ };
330
+ }
331
+ /**
332
+ * Phase 3, step 3 — the Critic. A genuinely SEPARATE pass over the
333
+ * step's real observation, decoupled from the Executor/model's own
334
+ * self-report — this is the direct fix for the diagnosed bug (a batch
335
+ * of 2 clicks succeeded, and the model kept looping 4 more iterations
336
+ * before giving up, never recognizing its own success). Mirrors
337
+ * packages/evals/src/judge.ts's own judgeScenario shape on purpose (a
338
+ * separate model looking at real state, forced tool call, structured
339
+ * verdict) — same real precedent already proven and tested in this repo,
340
+ * not a new pattern invented for this. Same resilience discipline as
341
+ * resolveVerb/resolvePlan: never throws, degrades to a real "continue"
342
+ * verdict (harmless — the loop just behaves as if the Critic weren't
343
+ * there for this one step) on any failure.
344
+ */
345
+ async function resolveCritic(llm, task, goal, verb, observation) {
346
+ let candidate;
347
+ try {
348
+ candidate = await llm.respond(buildCriticSystemPrompt(), JSON.stringify({
349
+ goal,
350
+ taskDescription: task.description,
351
+ doneContract: task.doneContract,
352
+ action: (0, agent_loop_1.summarizeVerbForHistory)(verb),
353
+ observation: observation ?? "no result",
354
+ }));
355
+ }
356
+ catch (err) {
357
+ console.error("[cairn] critic LLM call failed:", err);
358
+ return { verdict: "continue", reasoning: "Critic call failed — defaulting to continue rather than blocking the turn." };
359
+ }
360
+ const parsed = core_1.CriticVerdictSchema.safeParse(candidate);
361
+ if (!parsed.success)
362
+ return { verdict: "continue", reasoning: "Critic response failed validation — defaulting to continue rather than blocking the turn." };
363
+ return parsed.data;
364
+ }
365
+ /** Same real rotation/model-selection logic as createVerbLLM/createPlanLLM,
366
+ * configured for the Critic's own tool instead — see resolveCritic. */
367
+ function createCriticLLM(options = {}) {
368
+ return createToolLLM(options, buildCriticToolSchema(), CRITIC_TOOL_NAME, CRITIC_TOOL_DESCRIPTION);
369
+ }
370
+ /** Builds a provider-appropriate forced-single-tool-call LLM for ANY tool
371
+ * shape (verb resolution, planning, ...) — the real rotation/model-
372
+ * selection logic every such caller needs, factored out once so
373
+ * createVerbLLM/createPlanLLM stay thin, tool-specific wrappers around it. */
374
+ function createToolLLM(options, toolSchema, toolName, toolDescription) {
155
375
  const provider = options.provider ?? "anthropic";
156
376
  if (provider === "groq") {
157
- const rotator = options.apiKeys
158
- ? new key_rotator_1.KeyRotator(options.apiKeys)
159
- : options.apiKey
160
- ? new key_rotator_1.KeyRotator([options.apiKey])
161
- : key_rotator_1.KeyRotator.fromEnvList(process.env.GROQ_API_KEYS);
377
+ const rotator = options.keyRotator
378
+ ?? (options.apiKeys
379
+ ? new key_rotator_2.KeyRotator(options.apiKeys)
380
+ : options.apiKey
381
+ ? new key_rotator_2.KeyRotator([options.apiKey])
382
+ : key_rotator_2.KeyRotator.fromEnvList(process.env.GROQ_API_KEYS));
162
383
  if (!rotator) {
163
- throw new Error("createVerbLLM: provider 'groq' needs apiKey(s), or GROQ_API_KEYS in env");
384
+ throw new Error("createToolLLM: provider 'groq' needs apiKey(s), or GROQ_API_KEYS in env");
164
385
  }
165
386
  const model = options.model ?? process.env.GROQ_MODEL ?? GROQ_DEFAULT_MODEL;
166
- return new GroqVerbLLM(rotator, model, toolSchema);
387
+ return new GroqVerbLLM(rotator, model, toolSchema, undefined, toolName, toolDescription);
167
388
  }
168
389
  const client = new sdk_1.default({ apiKey: options.apiKey });
169
390
  const model = options.model ?? process.env.CAIRN_RUNTIME_MODEL ?? "claude-opus-5";
170
- return new AnthropicVerbLLM(client, model, toolSchema);
391
+ return new AnthropicVerbLLM(client, model, toolSchema, toolName, toolDescription);
392
+ }
393
+ /** Builds the provider-appropriate VerbLLM from the same options createCopilotHandler accepts — reused by the realtime relay. */
394
+ function createVerbLLM(options = {}) {
395
+ const registeredActions = options.registeredActions ?? [];
396
+ return createToolLLM(options, buildVerbToolSchema(registeredActions, options.actionDescriptions ?? {}), VERB_TOOL_NAME, VERB_TOOL_DESCRIPTION);
397
+ }
398
+ /** Same real rotation/model-selection logic as createVerbLLM, configured
399
+ * for the Planner's own tool instead — see resolvePlan. */
400
+ function createPlanLLM(options = {}) {
401
+ return createToolLLM(options, buildPlanToolSchema(), PLAN_TOOL_NAME, PLAN_TOOL_DESCRIPTION);
402
+ }
403
+ /**
404
+ * Architecture Pillar 3 (Skill half) — the Formulator. Runs once a task
405
+ * genuinely completes (not per-step — cheap on purpose, matching the plan
406
+ * file's own framing), compiling whatever real, Critic-verified
407
+ * `learnedFact`s were collected along the way (CriticVerdictSchema's own
408
+ * doc comment is the enforcement point for "never user data") into one
409
+ * Skill. Deliberately DETERMINISTIC, not a fourth kind of real LLM call —
410
+ * every fact it compiles already passed through the Critic's own
411
+ * verification, so there's nothing left to "figure out" that would
412
+ * justify the added cost/latency/failure surface of another model round
413
+ * trip; see DEVELOPMENT.md's own entry for the real cost reasoning
414
+ * (this session already hit genuine Groq quota exhaustion more than once
415
+ * from cumulative call volume). Returns null when nothing was learned —
416
+ * the common case, not an error; a caller should simply not save anything.
417
+ */
418
+ function compileSkill(goal, learnedFacts, pattern) {
419
+ if (learnedFacts.length === 0)
420
+ return null;
421
+ const name = goal.length > 80 ? `${goal.slice(0, 79)}…` : goal;
422
+ const firstFact = learnedFacts[0];
423
+ return {
424
+ id: (0, core_1.slugifySkillId)(name),
425
+ name,
426
+ description: firstFact.length > 120 ? `${firstFact.slice(0, 119)}…` : firstFact,
427
+ instructions: learnedFacts.join(" "),
428
+ pattern,
429
+ createdAt: new Date().toISOString(),
430
+ };
431
+ }
432
+ const SIGNIFICANT_WORD_MIN_LENGTH = 4;
433
+ // Real, common phrasing variance between a Skill's own name (usually a
434
+ // gerund, "Connecting nodes...") and a later goal restating the same idea
435
+ // ("connect the node...") means exact word equality misses obvious
436
+ // matches ("connecting" vs "connect", "nodes" vs "node"). A crude 4-
437
+ // character-prefix "stem" — not a real stemming library, deliberately —
438
+ // catches this common case without a new dependency, at the cost of
439
+ // occasional false-positive stems on short unrelated words; the min
440
+ // significant-word length above already screens out the shortest, most
441
+ // collision-prone words.
442
+ const STEM_LENGTH = 4;
443
+ function significantWordStems(text) {
444
+ return new Set(text
445
+ .toLowerCase()
446
+ .split(/[^a-z0-9]+/)
447
+ .filter((w) => w.length >= SIGNIFICANT_WORD_MIN_LENGTH)
448
+ .map((w) => w.slice(0, STEM_LENGTH)));
449
+ }
450
+ /**
451
+ * Architecture Pillar 3 (Skill half) — the retrieval side. A cheap,
452
+ * deterministic keyword-overlap match against a NEW goal (never another
453
+ * real LLM call, same reasoning as compileSkill above) — real progressive
454
+ * disclosure: every Skill's summary is cheap enough to always list (see
455
+ * SkillStore's own doc comment), but only the ONE Skill whose own name
456
+ * shares real, significant words with the current goal gets its full
457
+ * instructions loaded. A caller still needs its own SkillStore.getSkill
458
+ * call to fetch those full instructions for whatever this returns — this
459
+ * function only ever sees cheap summaries, never a full Skill.
460
+ */
461
+ function matchSkillByGoal(summaries, goal) {
462
+ const goalStems = significantWordStems(goal);
463
+ if (goalStems.size === 0)
464
+ return null;
465
+ let best = null;
466
+ let bestScore = 0;
467
+ for (const summary of summaries) {
468
+ const score = Array.from(significantWordStems(summary.name)).filter((stem) => goalStems.has(stem)).length;
469
+ if (score > bestScore) {
470
+ bestScore = score;
471
+ best = summary;
472
+ }
473
+ }
474
+ return best;
475
+ }
476
+ /** Same rendering discipline as renderRegisteredActions — "id (description)" per Skill, for the Planner's own userMessage. */
477
+ function renderSkillSummaries(summaries) {
478
+ return summaries.map((s) => `${s.name} (${s.description})`).join("; ");
479
+ }
480
+ const PlanRequestSchema = zod_1.z
481
+ .object({
482
+ goal: zod_1.z.string().min(1),
483
+ version: zod_1.z.number().int().min(1).optional(),
484
+ })
485
+ .strict();
486
+ const CriticRequestSchema = zod_1.z
487
+ .object({
488
+ task: core_1.TaskSchema,
489
+ goal: zod_1.z.string().min(1),
490
+ verb: core_1.VerbResponseSchema,
491
+ observation: zod_1.z.string().nullable().optional(),
492
+ })
493
+ .strict();
494
+ /**
495
+ * Architecture Pillar 4 — the typed/HTTP transport's own real Planner
496
+ * endpoint, closing the gap the plan file names directly: "the typed/
497
+ * HTTP path (index.tsx's runTypedAgentLoop) has zero Planner/Critic
498
+ * wiring at all... today explicitly realtime-only by deferral, not by
499
+ * decision." A thin HTTP wrapper around the exact same resolvePlan the
500
+ * realtime relay already calls in-process — the LLM call itself only
501
+ * ever needs to happen server-side (it holds the real API key), so a
502
+ * client-side caller (index.tsx) reaches it over a real request instead
503
+ * of importing resolvePlan directly, same reasoning as createCopilotHandler
504
+ * itself.
505
+ */
506
+ function createPlanHandler(manifest, options = {}) {
507
+ return createPlanHandlerWithLLM(manifest, createPlanLLM(options), options);
508
+ }
509
+ /** Same as createPlanHandler, but with the LLM injected — used by tests to fake it, same pattern as createCopilotHandlerWithLLM. */
510
+ function createPlanHandlerWithLLM(manifest, planLLM, options = {}) {
511
+ const actionsText = renderRegisteredActions(options.registeredActions ?? [], options.actionDescriptions ?? {});
512
+ const skillsScopeId = options.skillsScopeId ?? "default";
513
+ return async function handlePlanRequest(body) {
514
+ const parsed = PlanRequestSchema.safeParse(body);
515
+ if (!parsed.success)
516
+ return { status: 400, body: { error: "invalid request body" } };
517
+ // Architecture Pillar 3 (Skill half) — the typed transport's own
518
+ // retrieval side, same shape realtime-server.ts's finalizeTurn
519
+ // already computes in-process. Absent `options.skills` (the
520
+ // overwhelming majority of deployments today) means zero overhead —
521
+ // this whole block is skipped entirely.
522
+ const skillSummaries = options.skills ? options.skills.listSkillSummaries(skillsScopeId) : [];
523
+ const matchedSkillSummary = skillSummaries.length ? matchSkillByGoal(skillSummaries, parsed.data.goal) : null;
524
+ const skillsPayload = skillSummaries.length
525
+ ? {
526
+ summariesText: renderSkillSummaries(skillSummaries) || undefined,
527
+ suggestedInstructions: matchedSkillSummary ? (options.skills.getSkill(skillsScopeId, matchedSkillSummary.id)?.instructions ?? undefined) : undefined,
528
+ }
529
+ : undefined;
530
+ const plan = await resolvePlan(planLLM, parsed.data.goal, parsed.data.version ?? 1, manifest, actionsText || undefined, skillsPayload);
531
+ return { status: 200, body: plan };
532
+ };
533
+ }
534
+ /** Architecture Pillar 4's Critic counterpart to createPlanHandler — see
535
+ * its own doc comment. A thin HTTP wrapper around the same resolveCritic
536
+ * the realtime relay already calls in-process. */
537
+ function createCriticHandler(options = {}) {
538
+ return createCriticHandlerWithLLM(createCriticLLM(options));
539
+ }
540
+ /** Same as createCriticHandler, but with the LLM injected — used by tests to fake it. */
541
+ function createCriticHandlerWithLLM(criticLLM) {
542
+ return async function handleCriticRequest(body) {
543
+ const parsed = CriticRequestSchema.safeParse(body);
544
+ if (!parsed.success)
545
+ return { status: 400, body: { error: "invalid request body" } };
546
+ const verdict = await resolveCritic(criticLLM, parsed.data.task, parsed.data.goal, parsed.data.verb, parsed.data.observation);
547
+ return { status: 200, body: verdict };
548
+ };
549
+ }
550
+ const SkillSaveRequestSchema = zod_1.z
551
+ .object({
552
+ goal: zod_1.z.string().min(1),
553
+ learnedFacts: zod_1.z.array(zod_1.z.string().min(1)),
554
+ pattern: zod_1.z.enum(core_1.UI_PATTERNS).optional(),
555
+ })
556
+ .strict();
557
+ /**
558
+ * Architecture Pillar 3 (Skill half) — the typed transport's own save
559
+ * side (the Formulator's HTTP counterpart to realtime-server.ts's own
560
+ * in-process `compileSkill`+`saveSkill` call at the end of `finalizeTurn`).
561
+ * No LLM involved — `compileSkill` is deterministic (see its own doc
562
+ * comment for why) — so this needs no `-WithLLM` variant; it's real
563
+ * client-callable storage access, nothing more. The caller (index.tsx's
564
+ * runTypedAgentLoop) accumulates `learnedFacts` from its own Critic calls
565
+ * across one whole turn and posts here exactly once, after the turn
566
+ * concludes — never per-step, matching the Formulator's own "cheap on
567
+ * purpose" framing.
568
+ */
569
+ function createSkillSaveHandler(skills, skillsScopeId = "default") {
570
+ return async function handleSkillSaveRequest(body) {
571
+ const parsed = SkillSaveRequestSchema.safeParse(body);
572
+ if (!parsed.success)
573
+ return { status: 400, body: { error: "invalid request body" } };
574
+ const skill = compileSkill(parsed.data.goal, parsed.data.learnedFacts, parsed.data.pattern);
575
+ if (skill)
576
+ skills.saveSkill(skillsScopeId, skill);
577
+ return { status: 200, body: { saved: skill !== null } };
578
+ };
171
579
  }
172
580
  // ---------------------------------------------------------------------------
173
581
  // Providers
@@ -176,10 +584,14 @@ class AnthropicVerbLLM {
176
584
  client;
177
585
  model;
178
586
  toolSchema;
179
- constructor(client, model, toolSchema) {
587
+ toolName;
588
+ toolDescription;
589
+ constructor(client, model, toolSchema, toolName = VERB_TOOL_NAME, toolDescription = VERB_TOOL_DESCRIPTION) {
180
590
  this.client = client;
181
591
  this.model = model;
182
592
  this.toolSchema = toolSchema;
593
+ this.toolName = toolName;
594
+ this.toolDescription = toolDescription;
183
595
  }
184
596
  async respond(systemPrompt, userMessage) {
185
597
  const response = await this.client.messages.create({
@@ -188,20 +600,47 @@ class AnthropicVerbLLM {
188
600
  system: [{ type: "text", text: systemPrompt, cache_control: { type: "ephemeral" } }],
189
601
  tools: [
190
602
  {
191
- name: VERB_TOOL_NAME,
192
- description: VERB_TOOL_DESCRIPTION,
603
+ name: this.toolName,
604
+ description: this.toolDescription,
193
605
  input_schema: this.toolSchema,
194
606
  strict: true,
195
607
  },
196
608
  ],
197
- tool_choice: { type: "tool", name: VERB_TOOL_NAME },
609
+ tool_choice: { type: "tool", name: this.toolName },
198
610
  messages: [{ role: "user", content: userMessage }],
199
611
  });
200
- const toolUse = response.content.find((block) => block?.type === "tool_use" && block?.name === VERB_TOOL_NAME);
612
+ const toolUse = response.content.find((block) => block?.type === "tool_use" && block?.name === this.toolName);
201
613
  return toolUse?.input;
202
614
  }
203
615
  }
204
616
  exports.AnthropicVerbLLM = AnthropicVerbLLM;
617
+ /** No tools, no tool_choice — see StreamingTextLLM's own doc comment for why plain, unforced generation is what streams. */
618
+ class AnthropicStreamingTextLLM {
619
+ client;
620
+ model;
621
+ constructor(client, model) {
622
+ this.client = client;
623
+ this.model = model;
624
+ }
625
+ async respondStreamed(systemPrompt, userMessage, onChunk) {
626
+ const stream = await this.client.messages.create({
627
+ model: this.model,
628
+ max_tokens: 1024,
629
+ stream: true,
630
+ system: [{ type: "text", text: systemPrompt, cache_control: { type: "ephemeral" } }],
631
+ messages: [{ role: "user", content: userMessage }],
632
+ });
633
+ let full = "";
634
+ for await (const event of stream) {
635
+ if (event?.type === "content_block_delta" && event?.delta?.type === "text_delta" && typeof event.delta.text === "string") {
636
+ full += event.delta.text;
637
+ onChunk(event.delta.text);
638
+ }
639
+ }
640
+ return full;
641
+ }
642
+ }
643
+ exports.AnthropicStreamingTextLLM = AnthropicStreamingTextLLM;
205
644
  // Groq's chat-completions API is OpenAI-compatible: function-calling tools
206
645
  // instead of Anthropic's native tool_use blocks, arguments come back as a
207
646
  // JSON *string* to parse. Model list verified live against
@@ -212,44 +651,120 @@ class GroqVerbLLM {
212
651
  model;
213
652
  toolSchema;
214
653
  clientFactory;
215
- constructor(keys, model, toolSchema, clientFactory = (apiKey) => new groq_sdk_1.default({ apiKey })) {
654
+ toolName;
655
+ toolDescription;
656
+ constructor(keys, model, toolSchema,
657
+ // maxRetries: 0 — real, live-found latency bug this closes: the Groq
658
+ // SDK's own default (2 automatic retries with exponential backoff) ran
659
+ // UNDERNEATH respond()'s own key-rotation retry loop, so a single 429
660
+ // key attempt could silently eat several real seconds of SDK-internal
661
+ // backoff before respond() ever saw the rejection and moved on to a
662
+ // DIFFERENT key. With several keys in rotation genuinely rate-limited
663
+ // at once (the common case this closes for), that compounded into a
664
+ // real, live-reported multi-second-to-a-minute hang with no visible
665
+ // progress — worse than useless, since respond()'s own retry already
666
+ // tries a different key/quota entirely, which the SDK's blind same-key
667
+ // backoff can never fix. respond() is the sole source of retry policy
668
+ // here now.
669
+ clientFactory = (apiKey) => new groq_sdk_1.default({ apiKey, maxRetries: 0 }), toolName = VERB_TOOL_NAME, toolDescription = VERB_TOOL_DESCRIPTION) {
216
670
  this.keys = keys;
217
671
  this.model = model;
218
672
  this.toolSchema = toolSchema;
219
673
  this.clientFactory = clientFactory;
674
+ this.toolName = toolName;
675
+ this.toolDescription = toolDescription;
220
676
  }
221
677
  async respond(systemPrompt, userMessage) {
222
- try {
223
- return await this.attemptRespond(systemPrompt, userMessage);
224
- }
225
- catch (err) {
226
- // Real, live bugs, not theoretical two distinct non-deterministic
227
- // failure modes from openai/gpt-oss-120b (a reasoning-capable open
228
- // model), both rejected by Groq's own server-side validation before
229
- // this code ever sees a real response to work with, and both found
230
- // to recover cleanly on an identical retry a moment later:
231
- // - "output_parse_failed": the model "thinks out loud" in plain
232
- // prose instead of emitting the forced tool call.
233
- // - "tool_use_failed": the model hallucinates a slightly-wrong tool
234
- // name ("json", "response_with_verb" seen live, both against
235
- // the real, correctly-configured VERB_TOOL_NAME) instead of the
236
- // one forced tool it was actually given. This one was the actual
237
- // cause behind a real "voice keeps breaking" report found live
238
- // running the new eval harness's synthetic-voice scenario, where
239
- // it surfaced as "Something went wrong on my end" with no other
240
- // symptom, exactly matching what got reported.
241
- // One retry not exponential backoff, this is a latency-sensitive
242
- // voice/chat path genuinely helps rather than just delaying the
243
- // same failure. Anything else still propagates to resolveVerb's own
244
- // catch, unchanged.
245
- if (isRetryableToolCallFailure(err)) {
246
- return await this.attemptRespond(systemPrompt, userMessage);
678
+ // Three independent, real retry policies, combined in one loop:
679
+ // - Invalid key (401): the key itself is confirmed dead (see
680
+ // isInvalidKeyError/KeyRotator.markDead) — excluded from rotation
681
+ // for the rest of this process's life, then retried on a
682
+ // DIFFERENT configured key, same bound as rate-limit retries
683
+ // below. Real, live-found gap this closes: before this existed, a
684
+ // single expired/invalid key in the rotation silently sabotaged
685
+ // roughly (dead keys / total keys) of every real request forever
686
+ // GroqVerbLLM only ever retried a 429, never a 401, so hitting a
687
+ // dead key on the rotation just failed the whole turn outright
688
+ // even when other, genuinely working keys were configured.
689
+ // - Rate-limit (429): retried on a DIFFERENT configured key, up to
690
+ // once per distinct key. Found live a Groq account's own daily
691
+ // token quota exhausting mid-session doesn't mean every OTHER
692
+ // configured account/key is also exhausted; KeyRotator.take()
693
+ // already advances on every call, so simply retrying reaches a
694
+ // different key automatically. With only one key configured this
695
+ // never fires (nothing else to fall back to) same behavior as
696
+ // before this existed.
697
+ // - Tool-call failure (see below): exactly one retry, regardless of
698
+ // key countunrelated to which key was used.
699
+ const maxKeyAttempts = Math.max(this.keys.size, 1);
700
+ let keyAttempts = 0;
701
+ let usedToolCallRetry = false;
702
+ for (;;) {
703
+ const key = this.keys.take();
704
+ try {
705
+ return await this.attemptRespond(key, systemPrompt, userMessage);
706
+ }
707
+ catch (err) {
708
+ if (isInvalidKeyError(err)) {
709
+ this.keys.markDead(key);
710
+ if (keyAttempts < maxKeyAttempts - 1) {
711
+ keyAttempts++;
712
+ continue;
713
+ }
714
+ throw err;
715
+ }
716
+ if (isRateLimitError(err) && keyAttempts < maxKeyAttempts - 1) {
717
+ keyAttempts++;
718
+ continue;
719
+ }
720
+ // Real, live bugs, not theoretical — two distinct non-deterministic
721
+ // failure modes from openai/gpt-oss-120b (a reasoning-capable open
722
+ // model), both rejected by Groq's own server-side validation before
723
+ // this code ever sees a real response to work with, and both found
724
+ // to recover cleanly on an identical retry a moment later:
725
+ // - "output_parse_failed": the model "thinks out loud" in plain
726
+ // prose instead of emitting the forced tool call.
727
+ // - "tool_use_failed": the model hallucinates a slightly-wrong tool
728
+ // name ("json", "response_with_verb" — seen live, both against
729
+ // the real, correctly-configured VERB_TOOL_NAME) instead of the
730
+ // one forced tool it was actually given. This one was the actual
731
+ // cause behind a real "voice keeps breaking" report — found live
732
+ // running the new eval harness's synthetic-voice scenario, where
733
+ // it surfaced as "Something went wrong on my end" with no other
734
+ // symptom, exactly matching what got reported.
735
+ // Real, live bug found AFTER the single-retry mitigation above had
736
+ // already shipped: the SAME hallucinated-tool-name failure can hit
737
+ // twice in a row (seen live, back to back, on one otherwise-normal
738
+ // question), exhausting the one retry and still falling through to
739
+ // the generic fallback — even though Groq's own error response
740
+ // carries the model's complete, correctly-shaped answer right there
741
+ // in `failed_generation` (it parsed the arguments fine; it only
742
+ // picked the wrong TOOL NAME to wrap them in). Recovering that
743
+ // directly costs nothing (no extra round trip) and can't make
744
+ // things worse than today — resolveVerb's own VerbResponseSchema
745
+ // check right after this returns is the same safety net a normal,
746
+ // successful response already goes through, so a malformed
747
+ // extraction just falls to its existing "I'm not sure" fallback.
748
+ // Tried before spending the one real retry on output_parse_failed's
749
+ // case, where there's genuinely nothing to extract (no forced tool
750
+ // call was even produced).
751
+ const recovered = extractFailedGenerationArguments(err);
752
+ if (recovered !== undefined)
753
+ return recovered;
754
+ // One retry — not exponential backoff, this is a latency-sensitive
755
+ // voice/chat path — genuinely helps rather than just delaying the
756
+ // same failure. Anything else still propagates to resolveVerb's own
757
+ // catch, unchanged.
758
+ if (isRetryableToolCallFailure(err) && !usedToolCallRetry) {
759
+ usedToolCallRetry = true;
760
+ continue;
761
+ }
762
+ throw err;
247
763
  }
248
- throw err;
249
764
  }
250
765
  }
251
- async attemptRespond(systemPrompt, userMessage) {
252
- const client = this.clientFactory(this.keys.take());
766
+ async attemptRespond(apiKey, systemPrompt, userMessage) {
767
+ const client = this.clientFactory(apiKey);
253
768
  const completion = await client.chat.completions.create({
254
769
  model: this.model,
255
770
  messages: [
@@ -260,13 +775,13 @@ class GroqVerbLLM {
260
775
  {
261
776
  type: "function",
262
777
  function: {
263
- name: VERB_TOOL_NAME,
264
- description: VERB_TOOL_DESCRIPTION,
778
+ name: this.toolName,
779
+ description: this.toolDescription,
265
780
  parameters: this.toolSchema,
266
781
  },
267
782
  },
268
783
  ],
269
- tool_choice: { type: "function", function: { name: VERB_TOOL_NAME } },
784
+ tool_choice: { type: "function", function: { name: this.toolName } },
270
785
  });
271
786
  const toolCall = completion.choices[0]?.message?.tool_calls?.[0];
272
787
  if (!toolCall)
@@ -280,16 +795,100 @@ class GroqVerbLLM {
280
795
  }
281
796
  }
282
797
  exports.GroqVerbLLM = GroqVerbLLM;
798
+ /** No tools, no tool_choice — see StreamingTextLLM's own doc comment for why plain, unforced generation is what streams. No retry-on-hallucinated-tool-name logic here (GroqVerbLLM's own real, live-found bug) — there's no tool to hallucinate the name of. */
799
+ class GroqStreamingTextLLM {
800
+ keys;
801
+ model;
802
+ clientFactory;
803
+ constructor(keys, model,
804
+ // maxRetries: 0 — same real latency bug as GroqVerbLLM's own
805
+ // clientFactory default; see its doc comment for the full reasoning.
806
+ // respondStreamed below already has its own key-rotation retry loop
807
+ // (maxAttempts bounded by keys.size), which makes the SDK's blind
808
+ // same-key backoff redundant AND a source of silent multi-second
809
+ // delay stacked underneath it.
810
+ clientFactory = (apiKey) => new groq_sdk_1.default({ apiKey, maxRetries: 0 })) {
811
+ this.keys = keys;
812
+ this.model = model;
813
+ this.clientFactory = clientFactory;
814
+ }
815
+ async respondStreamed(systemPrompt, userMessage, onChunk) {
816
+ // Same rate-limit/invalid-key-retries-on-a-different-key policy as
817
+ // GroqVerbLLM.respond (see its own doc comment, including the real,
818
+ // live-found "a dead key sabotages roughly 1/N of every request"
819
+ // gap markDead closes). The one thing this path has to guard against
820
+ // that the non-streaming call doesn't: a real chunk already having
821
+ // reached the caller via onChunk before something fails mid-stream —
822
+ // both a 429 and a 401 always arrive on the initial request, before
823
+ // any chunk streams, so retrying is only ever attempted when nothing
824
+ // has been emitted yet; a genuinely different mid-stream failure is
825
+ // never retried, since doing so would duplicate output already sent.
826
+ const maxAttempts = Math.max(this.keys.size, 1);
827
+ let lastErr;
828
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
829
+ let emittedAnyChunk = false;
830
+ const key = this.keys.take();
831
+ try {
832
+ const client = this.clientFactory(key);
833
+ const stream = await client.chat.completions.create({
834
+ model: this.model,
835
+ stream: true,
836
+ messages: [
837
+ { role: "system", content: systemPrompt },
838
+ { role: "user", content: userMessage },
839
+ ],
840
+ });
841
+ let full = "";
842
+ for await (const chunk of stream) {
843
+ const delta = chunk?.choices?.[0]?.delta?.content;
844
+ if (typeof delta === "string" && delta) {
845
+ full += delta;
846
+ emittedAnyChunk = true;
847
+ onChunk(delta);
848
+ }
849
+ }
850
+ return full;
851
+ }
852
+ catch (err) {
853
+ lastErr = err;
854
+ if (emittedAnyChunk)
855
+ throw err;
856
+ if (isInvalidKeyError(err))
857
+ this.keys.markDead(key);
858
+ if ((isInvalidKeyError(err) || isRateLimitError(err)) && attempt < maxAttempts - 1)
859
+ continue;
860
+ throw err;
861
+ }
862
+ }
863
+ throw lastErr;
864
+ }
865
+ }
866
+ exports.GroqStreamingTextLLM = GroqStreamingTextLLM;
283
867
  /** Groq's SDK doesn't export a stable error shape to import and check
284
868
  * against, so this checks defensively across the ways the real error has
285
869
  * actually been observed to surface — a thrown APIError with a nested
286
870
  * `.error.code`, a plain `.code`, or just the code string showing up
287
- * somewhere in the message — rather than relying on exactly one of them. */
871
+ * somewhere in the message — rather than relying on exactly one of them.
872
+ *
873
+ * Real, live bug found AFTER this function had already shipped and been
874
+ * unit-tested: the actual Groq SDK error is doubly-nested
875
+ * (`err.error.error.code`, matching isRateLimitError's own real-shape
876
+ * fix below) but this only ever checked ONE level (`err.error?.code`) —
877
+ * so `code` always came back `undefined` against the real API surface,
878
+ * and the retry this function exists for NEVER actually fired, despite
879
+ * every unit test passing (they all hand-built a shallow, one-level
880
+ * mock that doesn't match what Groq really throws). Caught live: a
881
+ * `tool_use_failed`/hallucinated-tool-name error with a perfectly good
882
+ * answer sitting right there in `failed_generation` fell straight to
883
+ * the generic "Something went wrong on my end" fallback instead of
884
+ * retrying — exactly the failure mode this function's own doc comment
885
+ * already claimed to fix. Now checks the same depth `isRateLimitError`
886
+ * does. */
288
887
  function isRetryableToolCallFailure(err) {
289
888
  if (!err || typeof err !== "object")
290
889
  return false;
291
890
  const e = err;
292
- const code = e.code ?? e.error?.code;
891
+ const code = e.code ?? e.error?.code ?? e.error?.error?.code;
293
892
  const message = typeof e.message === "string" ? e.message : "";
294
893
  if (code === "output_parse_failed" || message.includes("output_parse_failed"))
295
894
  return true;
@@ -301,11 +900,98 @@ function isRetryableToolCallFailure(err) {
301
900
  return true;
302
901
  return false;
303
902
  }
903
+ /**
904
+ * Recovers the model's real, already-generated answer straight out of a
905
+ * `tool_use_failed` error, instead of spending a retry re-asking for
906
+ * something Groq already has. Same doubly-nested shape
907
+ * isRetryableToolCallFailure/isRateLimitError check against
908
+ * (`err.error.error.*`) plus the one extra field this specific error
909
+ * carries: `failed_generation`, a JSON-encoded string of exactly what the
910
+ * model produced — `{"name": "<hallucinated tool name>", "arguments":
911
+ * {...the real verb payload...}}`. Deliberately narrow: only fires for
912
+ * `tool_use_failed` specifically (never `output_parse_failed`, where the
913
+ * model didn't produce a forced tool call at all, so there's nothing real
914
+ * to recover here), and only returns something when `arguments` actually
915
+ * parses as an object — anything else (a missing field, a non-JSON
916
+ * string, a differently-shaped error) returns undefined and the caller
917
+ * falls through to its existing retry/throw path, unchanged.
918
+ */
919
+ function extractFailedGenerationArguments(err) {
920
+ if (!err || typeof err !== "object")
921
+ return undefined;
922
+ const e = err;
923
+ if (e.error?.error?.code !== "tool_use_failed")
924
+ return undefined;
925
+ const raw = e.error.error.failed_generation;
926
+ if (typeof raw !== "string")
927
+ return undefined;
928
+ try {
929
+ const parsed = JSON.parse(raw);
930
+ return parsed && typeof parsed === "object" && parsed.arguments && typeof parsed.arguments === "object" ? parsed.arguments : undefined;
931
+ }
932
+ catch {
933
+ return undefined;
934
+ }
935
+ }
936
+ /** Same defensive-shape-checking approach as isRetryableToolCallFailure —
937
+ * the real error observed live (see DEVELOPMENT.md) is a thrown APIError
938
+ * with `.status === 429` and a doubly-nested `.error.error.code ===
939
+ * "rate_limit_exceeded"`, but checks a couple of shallower shapes too
940
+ * rather than depending on exactly that nesting. */
941
+ function isRateLimitError(err) {
942
+ if (!err || typeof err !== "object")
943
+ return false;
944
+ const e = err;
945
+ if (e.status === 429)
946
+ return true;
947
+ const code = e.code ?? e.error?.code ?? e.error?.error?.code;
948
+ if (code === "rate_limit_exceeded")
949
+ return true;
950
+ const message = typeof e.message === "string" ? e.message : "";
951
+ return message.includes("rate_limit_exceeded") || message.includes("Rate limit reached");
952
+ }
953
+ /**
954
+ * Same defensive-shape-checking approach as isRateLimitError — the real
955
+ * error was checked directly against the live Groq API before writing
956
+ * this (not guessed): a real 401 from an invalid/expired key throws with
957
+ * `.status === 401` and the SAME doubly-nested `.error.error.code ===
958
+ * "invalid_api_key"` shape isRateLimitError already has to check for its
959
+ * own 429 case, confirmed via `Groq({apiKey}).chat.completions.create(...)`
960
+ * against a genuinely dead key and inspecting the thrown error's own
961
+ * `.status`/`.error`/`.message` fields directly.
962
+ */
963
+ function isInvalidKeyError(err) {
964
+ if (!err || typeof err !== "object")
965
+ return false;
966
+ const e = err;
967
+ if (e.status === 401)
968
+ return true;
969
+ const code = e.code ?? e.error?.code ?? e.error?.error?.code;
970
+ if (code === "invalid_api_key")
971
+ return true;
972
+ const message = typeof e.message === "string" ? e.message : "";
973
+ return message.includes("invalid_api_key") || message.includes("Invalid API Key");
974
+ }
304
975
  // ---------------------------------------------------------------------------
305
976
  // Shared tool schema / system prompt
306
977
  // ---------------------------------------------------------------------------
307
978
  const VERB_TOOL_DESCRIPTION = "Respond with exactly one action for the UI to take. Never invent selectors, routes, or code.";
308
- function buildVerbToolSchema(registeredActions) {
979
+ /**
980
+ * Phase 4, layer 5 — the ONE place a registered action id is rendered
981
+ * with its (optional) real description, shared by buildVerbToolSchema,
982
+ * buildSystemPrompt's own do-verb text, and resolvePlan's userMessage —
983
+ * so the Executor and the Planner describe the exact same capability the
984
+ * exact same way, and there's no risk of the three drifting out of sync.
985
+ * Deliberately renders "id (description)" rather than baking the
986
+ * description into what the model must echo back — resolveVerb's own
987
+ * `registeredActions.includes(parsedVerb.data.action)` check (server.ts)
988
+ * needs the RAW id back, verbatim, or a real registered action would
989
+ * silently stop being recognized.
990
+ */
991
+ function renderRegisteredActions(registeredActions, actionDescriptions = {}) {
992
+ return registeredActions.map((id) => (actionDescriptions[id] ? `${id} (${actionDescriptions[id]})` : id)).join(", ");
993
+ }
994
+ function buildVerbToolSchema(registeredActions, actionDescriptions = {}) {
309
995
  // Every genuinely-optional field allows `null` as well as its real type
310
996
  // (`["string", "null"]`, not just `"string"`) — found live, not
311
997
  // theoretical: real models (verified against Groq's openai/gpt-oss-120b)
@@ -325,14 +1011,20 @@ function buildVerbToolSchema(registeredActions) {
325
1011
  properties: {
326
1012
  verb: { type: "string", enum: [...core_1.VERBS] },
327
1013
  text: nullableString("Shown to the user. Required for explain. null (or omitted) if not applicable."),
328
- target: nullableString("An id from currentPageElements or liveElements. Required for highlight/open/click/fill/read. For do, the id of what the action applies to, if it needs one — prefer a liveElements id when the user means one specific item among several. Not used for batch — each of its own actions carries its own target instead. null (or omitted) if not applicable."),
1014
+ target: nullableString("An id from currentPageElements or liveElements. Required for highlight/open/click/fill/read/select/scroll/wait_for, and for drag (the thing being dragged). For do, the id of what the action applies to, if it needs one — prefer a liveElements id when the user means one specific item among several. For key, the element to press the key on — omit to press it on whatever's currently focused. Not used for batch — each of its own actions carries its own target instead. null (or omitted) if not applicable."),
1015
+ to: nullableString("Required for drag — the id (from currentPageElements or liveElements) of where to drop it. null (or omitted) if not applicable."),
1016
+ key: nullableString('Required for key — one real key name: Escape, Enter, Tab, ArrowUp, ArrowDown, ArrowLeft, or ArrowRight. null (or omitted) if not applicable.'),
329
1017
  route: nullableString("A route from the manifest. Required for navigate. null (or omitted) if not applicable."),
1018
+ continueAfter: {
1019
+ type: ["boolean", "null"],
1020
+ description: 'Only for navigate. Set to true when the user\'s real goal needs MORE than just arriving at the new page — e.g. "buy earbuds" means navigate there, then search, then report back what you found, not just navigate. When true, you\'ll be asked again once you\'ve arrived, with that page\'s own real elements, so you can decide the next real step (or say you\'re done). Leave false/null for a plain "take me to X" request, where arriving IS the whole answer — setting this needlessly costs an extra real turn for no reason.',
1021
+ },
330
1022
  action: nullableString("Required for do. A short label for what's being done, e.g. \"archive-invoice\" " +
331
1023
  (registeredActions.length
332
- ? `— either one of this deployment's registered actions [${registeredActions.join(", ")}], or, for any other element from currentPageElements or liveElements whose own description/label says it performs a real action, any short label describing it.`
1024
+ ? `— either the exact id (never its description in parens) of one of this deployment's registered actions [${renderRegisteredActions(registeredActions, actionDescriptions)}], or, for any other element from currentPageElements or liveElements whose own description/label says it performs a real action, any short label describing it.`
333
1025
  : "for any element from currentPageElements or liveElements whose own description/label says it performs a real action — no actions are separately registered in this deployment, but that path still works.") +
334
1026
  " null (or omitted) if not applicable."),
335
- value: nullableString('Required for fill — the exact text to type into "target". null (or omitted) if not applicable.'),
1027
+ value: nullableString('Required for fill — the exact text to type into "target". Required for select — the option\'s visible text, never its raw internal value. null (or omitted) if not applicable.'),
336
1028
  name: nullableString("Required for call_tool — a tool name from this turn's webMcpTools list, exactly as given. null (or omitted) if not applicable."),
337
1029
  args: {
338
1030
  type: ["object", "null"],
@@ -358,14 +1050,16 @@ function buildVerbToolSchema(registeredActions) {
358
1050
  },
359
1051
  actions: {
360
1052
  type: ["array", "null"],
361
- description: "Required for batch, 2-5 items. Several click/fill/read/call_tool steps executed in order in ONE round trip, instead of one round trip each — use this when you already know several steps are needed and don't need to see one step's real result before choosing the next (e.g. filling three known fields, or clicking through a sequence you're already sure about). If a later step genuinely depends on what an earlier one turns up, use a single step instead and decide the next one once you see its real result. text (if any) is spoken once for the whole batch, not per step. null (or omitted) if not applicable.",
1053
+ description: "Required for batch, 2-5 items. Several click/fill/read/call_tool/drag/select/key/scroll/wait_for steps executed in order in ONE round trip, instead of one round trip each — use this when you already know several steps are needed and don't need to see one step's real result before choosing the next (e.g. filling three known fields, or clicking through a sequence you're already sure about). If a later step genuinely depends on what an earlier one turns up, use a single step instead and decide the next one once you see its real result. text (if any) is spoken once for the whole batch, not per step. null (or omitted) if not applicable.",
362
1054
  items: {
363
1055
  type: "object",
364
1056
  properties: {
365
- verb: { type: "string", enum: ["click", "fill", "read", "call_tool"] },
366
- target: nullableString("An id from currentPageElements or liveElements. Required for click/fill/read. null (or omitted) if not applicable."),
367
- value: nullableString('Required for fill — the exact text to type into "target". null (or omitted) if not applicable.'),
1057
+ verb: { type: "string", enum: ["click", "fill", "read", "call_tool", "drag", "select", "key", "scroll", "wait_for"] },
1058
+ target: nullableString("An id from currentPageElements or liveElements. Required for click/fill/read/select/scroll/wait_for/drag (the thing being dragged). For key, omit to press it on whatever's currently focused. null (or omitted) if not applicable."),
1059
+ value: nullableString('Required for fill — the exact text to type into "target". Required for select — the option\'s visible text. null (or omitted) if not applicable.'),
368
1060
  name: nullableString("Required for call_tool — a tool name from this turn's webMcpTools list. null (or omitted) if not applicable."),
1061
+ to: nullableString("Required for drag — the id of where to drop it. null (or omitted) if not applicable."),
1062
+ key: nullableString("Required for key — one real key name (Escape, Enter, Tab, ArrowUp, ArrowDown, ArrowLeft, ArrowRight). null (or omitted) if not applicable."),
369
1063
  args: {
370
1064
  type: ["object", "null"],
371
1065
  description: "For call_tool — the arguments object, matching that tool's own inputSchema. null (or omitted) if the tool takes none.",
@@ -395,7 +1089,7 @@ function buildVerbToolSchema(registeredActions) {
395
1089
  * element detail is attached separately, per request, in resolveVerb —
396
1090
  * see buildPageElements.
397
1091
  */
398
- function buildSystemPrompt(manifest, registeredActions, persona = "Cairn") {
1092
+ function buildSystemPrompt(manifest, registeredActions, persona = "Cairn", actionDescriptions = {}) {
399
1093
  const pageSummaries = manifest.pages.map((p) => `- ${p.route}: ${p.purpose}`).join("\n");
400
1094
  return `You are ${persona}, an in-app assistant. You help users of this web app by
401
1095
  answering what a page or button does, pointing at the right element, and
@@ -421,9 +1115,41 @@ directory below plus three things attached to each request:
421
1115
  directly (name, description, and its own input schema) — when a real
422
1116
  tool exists for what the user's asking, it's the most reliable way to do
423
1117
  it (see "call_tool" below), more so than clicking around.
1118
+ - "currentPageDataShapes": the real shape of the data this page works
1119
+ with — a type name and its real fields, e.g. Invoice { status: "Paid" |
1120
+ "Overdue" | "Archived" }. Use this to know a field's REAL possible
1121
+ values (e.g. what "status" can actually be set to) or what a record on
1122
+ this page actually looks like, instead of guessing from a button label
1123
+ or making up a value. "none" means this page's real data shape wasn't
1124
+ traced — don't treat that as "this page has no data," just don't invent
1125
+ field names or values for it.
1126
+ - "suggestedApproach": present only when this page's real, live-scanned
1127
+ elements matched a known UI pattern (a data table, a kanban board, a
1128
+ node/workflow canvas, a search/filter list, a multi-step wizard) — a
1129
+ short, general hint for how that KIND of page is usually best operated
1130
+ (e.g. "check whether this canvas connects nodes via a dropdown or a
1131
+ drag gesture before choosing"). A starting point, never a script — still
1132
+ verify everything against the real liveElements/currentPageElements
1133
+ exactly as you always would; absent entirely when nothing matched, which
1134
+ is not itself a signal of anything.
1135
+ Every id in currentPageElements/liveElements is for YOUR use only, to put
1136
+ in "target" so the right element gets acted on — it is never something to
1137
+ say or write to the user, in any answer, for any reason, even one that
1138
+ directly asks how to tell two identical-looking items apart, or asks
1139
+ outright what the "technical" or "internal" way something works is. That
1140
+ question still has a real answer without ever printing the id string
1141
+ itself: name the CONCEPT ("each one gets its own internal identifier
1142
+ behind the scenes") without the literal value, exactly the way you'd
1143
+ describe a database having primary keys without reading one out loud.
1144
+ Wrong: "its own unique ID (for example, board-card-card-1788625210797)."
1145
+ Right: "its own internal identifier, though that's not something you'd
1146
+ normally see or need." If nothing else actually distinguishes two items
1147
+ on screen, say so plainly — position, column, or other real visible text
1148
+ first; the CONCEPT of an internal id only as a last resort, and even then
1149
+ never its actual value.
424
1150
  Never invent a page, route, id, action, or tool name that isn't listed in
425
- one of these four places (the route directory, currentPageElements,
426
- liveElements, or webMcpTools). If a question is about a page other than
1151
+ one of these five places (the route directory, currentPageElements,
1152
+ liveElements, webMcpTools, or currentPageDataShapes). If a question is about a page other than
427
1153
  the current one, you know its route and purpose from the directory but not
428
1154
  its elements — say so and offer to navigate there rather than guessing at
429
1155
  a button that page might have.
@@ -438,6 +1164,11 @@ Always call ${VERB_TOOL_NAME} exactly once with one of these verbs:
438
1164
  panel — this one actually clicks the element after highlighting it, so
439
1165
  only use it when the element is meant to reveal something on click.
440
1166
  - navigate: send the user to a route that appears in the manifest, in "route".
1167
+ Set "continueAfter" to true only when the real goal needs more than just
1168
+ arriving there (e.g. "buy earbuds" — navigate, then search, then report
1169
+ back) — you'll be asked again once you've arrived, with that page's own
1170
+ real elements, to decide the next step. Leave it false/null for a plain
1171
+ "take me to X" request, where arriving is the whole answer.
441
1172
  - tour: 2-6 ordered "steps", each with its own "text" and (usually) a
442
1173
  "target". Use this whenever explaining the answer means touching more
443
1174
  than one element — e.g. "what can I do on this page" or "give me a tour" —
@@ -463,7 +1194,7 @@ Always call ${VERB_TOOL_NAME} exactly once with one of these verbs:
463
1194
  "target" and a short label in "action". Works even when the action has
464
1195
  no network call at all (e.g. a button that just reveals a form) — it
465
1196
  still gets clicked for real.
466
- 3. One of this deployment's registered action ids: [${registeredActions.join(", ") || "none registered"}] — put that exact id in "action".
1197
+ 3. One of this deployment's registered actions: [${renderRegisteredActions(registeredActions, actionDescriptions) || "none registered"}] — put that exact id (never its description in parens) in "action".
467
1198
  If none applies — the target isn't in liveElements or currentPageElements
468
1199
  and isn't a registered action — use "explain" and say you can't do that
469
1200
  from here. Never invent a target or action id that isn't in one of those
@@ -489,21 +1220,41 @@ response; once you do, answer with one of the verbs above instead):
489
1220
  listed in "webMcpTools" — "name" (exactly as given) and "args" (matching
490
1221
  that tool's own schema). This is the most reliable way to do something
491
1222
  when a real tool for it exists — prefer it over do/click when it does.
492
- All four require a real id/name from currentPageElements, liveElements, or
1223
+ - drag: drag a real element onto another one — "target" (what's being
1224
+ dragged) and "to" (where it's dropped), both real ids. Use this for
1225
+ anything click/fill can't reach: connecting two nodes on a canvas/
1226
+ workflow editor, reordering a list, moving a card between columns on a
1227
+ kanban board.
1228
+ - select: choose a real dropdown/listbox option — "target" (the dropdown)
1229
+ and "value" (the option's exact VISIBLE text, never an internal value
1230
+ you're guessing at).
1231
+ - key: press one real key — Escape, Enter, Tab, ArrowUp, ArrowDown,
1232
+ ArrowLeft, or ArrowRight, in "key". "target" is optional — omit it to
1233
+ press the key on whatever's currently focused (e.g. right after a fill),
1234
+ or name an element to focus it first.
1235
+ - scroll: bring a real, already-known element into view — "target" — for
1236
+ content you (or the user) need to actually see before acting on it or
1237
+ narrating it, without clicking or reading it.
1238
+ - wait_for: explicitly pause until a real, already-known element appears
1239
+ or becomes findable — "target" — use this when you know something
1240
+ should show up after an earlier step (a confirmation toast, a panel
1241
+ that opens async) and want to confirm it actually did before reporting
1242
+ success, instead of guessing that enough time has passed.
1243
+ All nine require a real id/name from currentPageElements, liveElements, or
493
1244
  webMcpTools — never invent one. You'll be shown the real result of each
494
1245
  step and asked again what to do next; after a small number of steps,
495
1246
  answer with a terminal verb even if incomplete, explaining what you found.
496
1247
 
497
- - batch: 2-5 of the four steps above (click/fill/read/call_tool, each in
498
- its own shape — no separate "text"), run in order, in "actions" — use
499
- this INSTEAD of separate single steps when you already know every step
500
- you need and none of them depends on seeing an earlier one's real result
501
- first (e.g. filling three fields you can already see, or a known
502
- sequence of clicks). If a later step needs to react to what an earlier
503
- one turns up, or depends on something an earlier step's click would
504
- newly reveal, use single steps instead — a batch only sees the page as
505
- it is right now, not as an earlier step in the same batch leaves it. One
506
- step failing stops the rest of that batch.
1248
+ - batch: 2-5 of the nine steps above (click/fill/read/call_tool/drag/
1249
+ select/key/scroll/wait_for, each in its own shape — no separate "text"),
1250
+ run in order, in "actions" — use this INSTEAD of separate single steps
1251
+ when you already know every step you need and none of them depends on
1252
+ seeing an earlier one's real result first (e.g. filling three fields you
1253
+ can already see, or a known sequence of clicks). If a later step needs to
1254
+ react to what an earlier one turns up, or depends on something an earlier
1255
+ step's click would newly reveal, use single steps instead — a batch only
1256
+ sees the page as it is right now, not as an earlier step in the same
1257
+ batch leaves it. One step failing stops the rest of that batch.
507
1258
 
508
1259
  Every "text" field (in explain, or per-step in tour, or the optional text on
509
1260
  any other verb) is read aloud AND shown on screen, so it must sound like a
@@ -511,9 +1262,63 @@ person talking, not documentation:
511
1262
  - No markdown — no "**bold**", no bullet lists, no backticks, no headings.
512
1263
  - Never say an element's internal id (e.g. never say "create-invoice" or
513
1264
  "the element id invoice-table") — describe it the way a user sees it
514
- instead (its visible label, e.g. "the Create Invoice button").
1265
+ instead (its visible label, e.g. "the Create Invoice button"). This
1266
+ holds even when asked how to tell two same-named/identical-looking
1267
+ items apart — that question feels like it needs the id, but it doesn't:
1268
+ describe them by what's actually visible instead (their column, their
1269
+ position, any other real text on them), or say plainly that they look
1270
+ identical on screen and the user would need to open each one to tell
1271
+ which is which. Never reach for the id as the answer just because
1272
+ nothing else seems to distinguish them.
515
1273
  - Short, natural sentences — one idea per sentence, the way you'd actually
516
1274
  explain something out loud to someone standing next to you.
1275
+ - Say only what actually answers the question, then stop — a real person
1276
+ giving a quick answer doesn't restate the question, list every possible
1277
+ angle, or pile on a second and third example once the first one landed.
1278
+ One or two sentences is the normal length for most answers; reach for
1279
+ more only when the question genuinely has several distinct parts. If
1280
+ you notice you're explaining the same point twice in different words,
1281
+ cut one of them.
1282
+ - Never a self-referential disclaimer ("As an AI...", "I'm just a language
1283
+ model", "I don't have the ability to..."). If something's genuinely out
1284
+ of reach, say the specific reason in plain terms instead (e.g. "that's
1285
+ not on this page" beats any variant of "I am unable to").
1286
+ - Never open with "Certainly!", "Great question!", "Absolutely!", or "I'd
1287
+ be happy to" — answer the thing directly, the way a coworker would, not
1288
+ a customer-service script. Never close with "Hope this helps!" or
1289
+ similar. Skip corporate-memo words too — "delve," "leverage," "utilize,"
1290
+ "streamline," "robust," "unlock" — plain words do the same job.
1291
+ - Use contractions ("I'll," "you're," "it's," "don't") — the spelled-out
1292
+ form reads stiffer for no reason.
1293
+ - State things plainly when you actually know them — don't wrap a real
1294
+ answer in "I think," "it seems like," or "possibly" just to sound
1295
+ careful; hedging a correct answer makes people trust and act on it LESS,
1296
+ not more. Save "I'm not sure" for when you genuinely are, and say it in
1297
+ exactly those plain words rather than a longer disclaimer.
1298
+ - When something can't be done or went wrong, say so once, plainly, and
1299
+ move straight to what's true or what to try instead — skip "I'm sorry"
1300
+ and its variants entirely. A flat, competent correction reads as more
1301
+ trustworthy than an apology, and repeating "sorry" across a conversation
1302
+ reads as worse, not more polite.
1303
+ - Match the tone to what's actually happening: brief and easy for a
1304
+ routine confirmation ("Moved it to Done."), plain and direct — not
1305
+ jokey — for an error or anything destructive/irreversible. A casual
1306
+ quip lands fine on a small thing and badly on a real one.
1307
+ - Let a small, real hesitation through sometimes instead of always
1308
+ polishing it away — aim for roughly one answer in every three or four,
1309
+ not every single one and not zero: open with "Um," "Well," "Hmm," or
1310
+ "So," right before something genuinely tricky, surprising, or hard to
1311
+ phrase; catch yourself and correct once mid-answer ("Actually, wait —
1312
+ the New Invoice button, not New Card.") the way a person naturally
1313
+ does; or use a real pause — a comma, a dash, a trailing "..." — right
1314
+ before landing on the actual answer, instead of stating it flatly.
1315
+ Never more than one of these in the same answer, never two in a row,
1316
+ and never on a routine confirmation or anything serious/destructive —
1317
+ a stray "um" there reads as careless, not human. Don't force one onto
1318
+ an answer that has nothing tricky about it — a plain, easy question
1319
+ still gets a plain, easy answer most of the time; reaching for a
1320
+ hesitation on EVERY response reads as more annoying than the flat,
1321
+ polished tone it's meant to fix.
517
1322
 
518
1323
  The request may include "history" — earlier turns of this same
519
1324
  conversation, oldest first. Use it to resolve references like "the first
@@ -547,3 +1352,128 @@ function buildPageElements(manifest, route) {
547
1352
  return "none";
548
1353
  return page.elements.map((e) => `${e.id} (${e.does})`).join("; ");
549
1354
  }
1355
+ /**
1356
+ * Phase 4, layer 2's own consumer — the real interface/type-alias fields
1357
+ * l1-data-shapes.ts traced for the current page (e.g. Invoice's actual
1358
+ * status: "Paid" | "Overdue" | "Archived" union), so a fill/do/explain can
1359
+ * reason about a field's REAL possible values instead of guessing from a
1360
+ * button label. Same per-request, uncached placement as buildPageElements,
1361
+ * for the same reason — this is app-size-scaling detail, not something the
1362
+ * route-independent system prompt should carry. Absent/empty dataShapes
1363
+ * (a page with no explicit-return-typed data call, or a manifest built
1364
+ * before this field existed) degrades to "none", same shape as
1365
+ * buildPageElements' own no-elements case — never a crash, never invented.
1366
+ */
1367
+ function buildPageDataShapes(manifest, route) {
1368
+ const page = manifest.pages.find((p) => p.route === route);
1369
+ const shapes = page?.dataShapes;
1370
+ if (!shapes || shapes.length === 0)
1371
+ return "none";
1372
+ return shapes
1373
+ .map((s) => `${s.name} { ${s.fields.map((f) => `${f.name}${f.optional ? "?" : ""}: ${f.type}`).join(", ")} }`)
1374
+ .join("; ");
1375
+ }
1376
+ /** Deliberately narrower than PlanSchema — no `version`/task `status`,
1377
+ * see PlannerOutputSchema's own doc comment for why those stay
1378
+ * harness-owned rather than something the model is asked to invent. */
1379
+ function buildPlanToolSchema() {
1380
+ return {
1381
+ type: "object",
1382
+ properties: {
1383
+ goal: { type: "string", description: "The real end goal, restated in your own words." },
1384
+ facts: {
1385
+ type: "array",
1386
+ items: { type: "string" },
1387
+ description: "Real facts already known that are relevant to the goal, from the context you were given. Empty array if there's nothing worth carrying forward — never invent one.",
1388
+ },
1389
+ tasks: {
1390
+ type: "array",
1391
+ minItems: 1,
1392
+ items: {
1393
+ type: "object",
1394
+ properties: {
1395
+ id: { type: "string", description: "A short, stable id for this task, e.g. \"t1\"." },
1396
+ description: { type: "string", description: "What this task achieves, in plain language, concrete enough to act on." },
1397
+ doneContract: {
1398
+ type: "string",
1399
+ description: "What counts as this task being ACTUALLY done, checkable against real state — a real observable outcome, never \"the user is satisfied\" or similar.",
1400
+ },
1401
+ },
1402
+ required: ["id", "description", "doneContract"],
1403
+ additionalProperties: false,
1404
+ },
1405
+ },
1406
+ },
1407
+ required: ["goal", "facts", "tasks"],
1408
+ additionalProperties: false,
1409
+ };
1410
+ }
1411
+ function buildPlannerSystemPrompt() {
1412
+ return `You are the planning layer of an in-app AI agent that operates a web app on a user's behalf. You do NOT act directly — you decompose the user's real end goal into an ordered list of concrete tasks a separate execution layer will carry out one at a time, using real clicks/fills/reads/tool calls against the real app.
1413
+
1414
+ The user message may include "pages" — a real directory of this app's actual routes, what each is for, and, where known, the real named data shape(s) that page's records actually have (e.g. "(data: Invoice)" means real Invoice-shaped records live there). When present, ground tasks in this real structure instead of guessing: prefer a task whose description matches a real page's real purpose over a generic one, mention a page's real route when a task is genuinely about that page, and let a listed data shape tell you what a record on that page can legitimately contain — never invent a field or a status a listed shape doesn't have. If "pages" is absent, decompose from the goal alone, same as before.
1415
+
1416
+ It may also include "actions" — real, deployment-specific actions this app actually supports, by id, with a description in parens where one exists (e.g. "archiveInvoice (Archives the invoice; cannot be undone.)"). When a task is best achieved through one of these, say so concretely in the task's description (e.g. "use the archiveInvoice action") instead of only describing it as clicking around — the execution layer will still decide exactly how, but a task that already knows a real action exists is more likely to use it. Never invent an action id that isn't listed.
1417
+
1418
+ It may also include "skills" — real, previously-learned notes this exact deployment has already confirmed about its own platform (name and a one-line description each, e.g. "Connecting nodes on the workflow canvas (The canvas connects nodes via a dropdown, not a drag gesture.)"), and "suggestedSkill" — the FULL learned instructions for the one skill that most closely matches THIS goal, when one does. Treat both as a real, verified starting point for how this specific platform behaves — still a hint, never a script: the execution layer still verifies every real step against the actual page regardless of what a skill suggests.
1419
+
1420
+ Break the goal into as FEW tasks as genuinely make sense — most goals need only 1-3 tasks; only split further when steps are genuinely independent or need to happen in a specific real order. Each task needs:
1421
+ - id: a short, stable id, e.g. "t1", "t2".
1422
+ - description: what this task achieves, concrete enough to act on.
1423
+ - doneContract: what counts as this task being ACTUALLY done, checkable against real state — a real observable outcome, never "the model thinks it's done" or "the user is satisfied."
1424
+
1425
+ List any real facts already known that bear on the goal, in "facts" — leave it empty if there's nothing worth carrying forward. Never invent a task that isn't a real, necessary step toward the stated goal.`;
1426
+ }
1427
+ /**
1428
+ * Phase 4 step 3 — the Planner's own version of buildSystemPrompt's route
1429
+ * directory: route + purpose for every page, same page-COUNT-scaled (not
1430
+ * total-content-scaled) budget discipline as that directory's own doc
1431
+ * comment explains (a real production app's full per-page detail on every
1432
+ * request once blew an 8000 TPM provider limit before a single question
1433
+ * was answered — see buildSystemPrompt). For a page with real traced data
1434
+ * shapes (l1-data-shapes.ts), appends just the SHAPE NAMES — never full
1435
+ * field lists, that's what buildPageDataShapes already gives the Executor
1436
+ * once a task narrows down to one specific page — so the Planner knows
1437
+ * e.g. "the /invoices page deals with Invoice-shaped data" without paying
1438
+ * for every field of every shape on every page, on every planning call.
1439
+ */
1440
+ function buildPlannerPageDirectory(manifest) {
1441
+ return manifest.pages
1442
+ .map((p) => {
1443
+ const shapeNames = p.dataShapes?.map((s) => s.name).join(", ");
1444
+ return shapeNames ? `${p.route}: ${p.purpose} (data: ${shapeNames})` : `${p.route}: ${p.purpose}`;
1445
+ })
1446
+ .join("\n");
1447
+ }
1448
+ function buildCriticToolSchema() {
1449
+ return {
1450
+ type: "object",
1451
+ properties: {
1452
+ verdict: { type: "string", enum: ["continue", "task_complete", "replan", "give_up"] },
1453
+ expected: { type: "string", description: "Only for replan — what SHOULD have happened, per the task's doneContract." },
1454
+ actual: { type: "string", description: "Only for replan — what actually happened instead, per the real observation." },
1455
+ learnedFact: {
1456
+ type: "string",
1457
+ description: "Only for task_complete, and only if this step revealed a genuinely NEW, confirmed-true fact about how THIS PLATFORM behaves (e.g. \"the canvas connects nodes via a dropdown, not a drag gesture\" or \"the search box needs about 300ms before results update\") — never a fact about any one user's own data or content. Omit entirely on every other verdict, or when nothing new was learned (the common case).",
1458
+ },
1459
+ reasoning: { type: "string", description: "2-3 sentences, specific to what actually happened in THIS step, not generic." },
1460
+ },
1461
+ required: ["verdict", "reasoning"],
1462
+ additionalProperties: false,
1463
+ };
1464
+ }
1465
+ function buildCriticSystemPrompt() {
1466
+ return `You are the verification layer of an in-app AI agent that operates a web app on a user's behalf. You do NOT act — you look at what a real execution step ACTUALLY did, independent of what it or its own summary claimed, and decide what happens next.
1467
+
1468
+ You'll be given: the overall goal, the current task's own description and doneContract (what counts as it being done), the real action just taken, and its real observed result.
1469
+
1470
+ Score the verdict:
1471
+ - "task_complete": the doneContract is genuinely satisfied by the real observation — the task is done. Say so even if this took just one step; don't wait for confirmation that was never going to come.
1472
+ - "continue": real progress happened but the doneContract isn't satisfied yet — more steps are needed on this same task.
1473
+ - "replan": the real observation contradicts what the task expected (a click didn't register, the wrong element was targeted, an error occurred) — the current approach isn't working and needs a different plan. Fill in "expected" (what the doneContract implied should happen) and "actual" (what really happened instead).
1474
+ - "give_up": repeated real attempts have failed and continuing wouldn't help — be honest about being stuck rather than looping forever.
1475
+
1476
+ When you say "task_complete", also consider "learnedFact": did this step's real outcome reveal a genuinely NEW, confirmed-true fact about how THIS PLATFORM behaves — something worth remembering for a similar goal later (e.g. "the canvas connects nodes via a dropdown, not a drag gesture")? Only ever a structural fact about the platform itself, NEVER anything about this user's own data or content (never a person's name, an amount, a specific record). Leave it out entirely if nothing new was learned — that's the common case, not a gap to fill.
1477
+
1478
+ Never trust the action's own claim of success — judge only the real observation. reasoning: 2-3 sentences, specific to what actually happened in this step, not generic.`;
1479
+ }