wizards 0.2.25 → 0.2.27

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 (126) hide show
  1. package/apps/runtime/dist/agents/architect.js +5 -0
  2. package/apps/runtime/dist/agents/project-assistant.js +10 -1
  3. package/apps/runtime/dist/authoring/guide.js +61 -8
  4. package/apps/runtime/dist/connectors/builtin.js +17 -1
  5. package/apps/runtime/dist/connectors/index.js +43 -14
  6. package/apps/runtime/dist/connectors/plugin-connections.js +248 -0
  7. package/apps/runtime/dist/connectors/via-account.js +63 -0
  8. package/apps/runtime/dist/credits/estimate.js +12 -2
  9. package/apps/runtime/dist/db/migrations/tenant/0010_plugin_connection.sql +15 -0
  10. package/apps/runtime/dist/db/migrations/tenant/meta/0010_snapshot.json +2942 -0
  11. package/apps/runtime/dist/db/migrations/tenant/meta/_journal.json +7 -0
  12. package/apps/runtime/dist/db/schema.js +21 -0
  13. package/apps/runtime/dist/engenty/connections-google/connectors/calendar.js +25 -3
  14. package/apps/runtime/dist/engenty/connections-microsoft/outlook.js +29 -1
  15. package/apps/runtime/dist/engine/decide.js +82 -0
  16. package/apps/runtime/dist/engine/input.js +21 -5
  17. package/apps/runtime/dist/engine/requirements.js +80 -15
  18. package/apps/runtime/dist/engine/runner.js +252 -6
  19. package/apps/runtime/dist/engine/steps.js +206 -18
  20. package/apps/runtime/dist/engine/surface.js +90 -0
  21. package/apps/runtime/dist/mcp/tools.js +3 -2
  22. package/apps/runtime/dist/plugins/loader.js +22 -1
  23. package/apps/runtime/dist/plugins/registry.js +10 -0
  24. package/apps/runtime/dist/render/downloads.js +7 -0
  25. package/apps/runtime/dist/routes/runs.js +28 -7
  26. package/apps/runtime/dist/routes/studio.js +3 -1
  27. package/apps/runtime/dist/services/marketplace.js +134 -3
  28. package/apps/runtime/dist/services/relevance.js +17 -49
  29. package/apps/runtime/dist/services/runs.js +43 -15
  30. package/apps/runtime/dist/services/wizards.js +4 -2
  31. package/apps/runtime/dist/tools/pages.js +31 -6
  32. package/apps/runtime/dist/tools/plugin.js +29 -1
  33. package/apps/web/dist/assets/Account-DW396ig5.js +1 -0
  34. package/apps/web/dist/assets/{ChangelogDialog-B7C0r9dF.js → ChangelogDialog-BdZ1SmEd.js} +1 -1
  35. package/apps/web/dist/assets/CreditsDialog-fOYdoev3.js +1 -0
  36. package/apps/web/dist/assets/EditorPage-CAARFsaI.js +3 -0
  37. package/apps/web/dist/assets/{FlowPreview-D0m5Qk6S.js → FlowPreview-U2AEnKlX.js} +1 -1
  38. package/apps/web/dist/assets/{Harness-bIL3pHB_.js → Harness-CtDlm_2b.js} +2 -2
  39. package/apps/web/dist/assets/HomePage-Bd-mkLxW.js +1 -0
  40. package/apps/web/dist/assets/ImportWizard-BMPGjd_t.js +1 -0
  41. package/apps/web/dist/assets/LocalRuntime-DjSNCRnV.js +1 -0
  42. package/apps/web/dist/assets/NewWizardPage-DZPcDR9i.js +2 -0
  43. package/apps/web/dist/assets/ProjectPage-_LAtG3tn.js +2 -0
  44. package/apps/web/dist/assets/PublicRunner-6Iqp0DmU.js +1 -0
  45. package/apps/web/dist/assets/RunDrawer-CtQoY3Ha.js +4 -0
  46. package/apps/web/dist/assets/RunnerView-BLOCNZaZ.js +2 -0
  47. package/apps/web/dist/assets/SettingsPage-Z6skBf6I.js +1 -0
  48. package/apps/web/dist/assets/SetupPage-4-Lic4pT.js +1 -0
  49. package/apps/web/dist/assets/SharePage-DfdhXxZm.js +1 -0
  50. package/apps/web/dist/assets/arrow-right-BOtqnsA1.js +1 -0
  51. package/apps/web/dist/assets/brand-CCAI9p0f.js +2 -0
  52. package/apps/web/dist/assets/changelog-DQM0oz9S.js +1 -0
  53. package/apps/web/dist/assets/circle-alert-BC6xmJ9r.js +1 -0
  54. package/apps/web/dist/assets/definition-D5V79jHA.js +1 -0
  55. package/apps/web/dist/assets/esm-FEWi61F_.js +7 -0
  56. package/apps/web/dist/assets/external-link-wKL1HE6G.js +1 -0
  57. package/apps/web/dist/assets/image-BMdSChOR.js +1 -0
  58. package/apps/web/dist/assets/index-BOp2fz-2.css +1 -0
  59. package/apps/web/dist/assets/index-YJMooz0J.js +132 -0
  60. package/apps/web/dist/assets/lock-BpqzLMuv.js +1 -0
  61. package/apps/web/dist/assets/meta-BqZ7SlZz.js +1 -0
  62. package/apps/web/dist/assets/pen-line-DM-u0dES.js +1 -0
  63. package/apps/web/dist/assets/refresh-cw-Bk7HkOIM.js +1 -0
  64. package/apps/web/dist/assets/settings-menu-BM_NfZfn.js +1 -0
  65. package/apps/web/dist/assets/star-JHzYYl5R.js +1 -0
  66. package/apps/web/dist/assets/trash-CdLg-Ela.js +1 -0
  67. package/apps/web/dist/assets/ui-8QmvuC9u.js +9 -0
  68. package/apps/web/dist/assets/upload-CL1R6shM.js +1 -0
  69. package/apps/web/dist/assets/video-BioLmXNO.js +1 -0
  70. package/apps/web/dist/assets/volume-2-_IOfPNCv.js +1 -0
  71. package/apps/web/dist/assets/wand-sparkles-DYtDQSJZ.js +1 -0
  72. package/apps/web/dist/index.html +4 -5
  73. package/apps/web/dist/mcp/flow.html +61 -61
  74. package/node_modules/@engenty-wizards/plugin-sdk/dist/index.d.ts +85 -0
  75. package/node_modules/@engenty-wizards/plugin-sdk/dist/studio.d.ts +23 -0
  76. package/node_modules/@engenty-wizards/shared/dist/decision.d.ts +76 -0
  77. package/node_modules/@engenty-wizards/shared/dist/decision.js +147 -0
  78. package/node_modules/@engenty-wizards/shared/dist/definition.d.ts +1076 -51
  79. package/node_modules/@engenty-wizards/shared/dist/definition.js +597 -44
  80. package/node_modules/@engenty-wizards/shared/dist/marketplace-entry.js +19 -2
  81. package/node_modules/@engenty-wizards/shared/dist/marketplace.d.ts +6 -1
  82. package/node_modules/@engenty-wizards/shared/dist/run.d.ts +55 -0
  83. package/node_modules/@engenty-wizards/shared/dist/run.js +3 -0
  84. package/node_modules/@engenty-wizards/shared/dist/surface.d.ts +89 -0
  85. package/node_modules/@engenty-wizards/shared/dist/surface.js +198 -0
  86. package/package.json +1 -1
  87. package/apps/web/dist/assets/Account-CpxsROpe.js +0 -1
  88. package/apps/web/dist/assets/CreditsDialog-Com2Y3g8.js +0 -1
  89. package/apps/web/dist/assets/EditorPage-BCagWjrz.js +0 -2
  90. package/apps/web/dist/assets/HomePage-DPE_Z9HB.js +0 -1
  91. package/apps/web/dist/assets/ImportWizard-Dz2uKznp.js +0 -1
  92. package/apps/web/dist/assets/LocalRuntime-wMKUVZOu.js +0 -1
  93. package/apps/web/dist/assets/NewWizardPage-BOxU2jWq.js +0 -2
  94. package/apps/web/dist/assets/ProjectPage-BJp2wqIk.js +0 -5
  95. package/apps/web/dist/assets/PublicRunner-BsrqVK0J.js +0 -1
  96. package/apps/web/dist/assets/RunDrawer-CLfJ0W_V.js +0 -1
  97. package/apps/web/dist/assets/RunnerView-BXMELFC4.js +0 -70
  98. package/apps/web/dist/assets/SettingsPage-BkuUm-bf.js +0 -1
  99. package/apps/web/dist/assets/SetupPage-CoyXpcvS.js +0 -1
  100. package/apps/web/dist/assets/SharePage-BuR-he1L.js +0 -1
  101. package/apps/web/dist/assets/api-rJBQEu1G.js +0 -10
  102. package/apps/web/dist/assets/arrow-right-6FyN3e7D.js +0 -1
  103. package/apps/web/dist/assets/changelog-Bmw6dg-T.js +0 -1
  104. package/apps/web/dist/assets/check-CUpby_J9.js +0 -1
  105. package/apps/web/dist/assets/chevron-down-DymW-2BP.js +0 -1
  106. package/apps/web/dist/assets/circle-alert-D2SCwFT-.js +0 -1
  107. package/apps/web/dist/assets/createLucideIcon-q3MZMmnf.js +0 -1
  108. package/apps/web/dist/assets/definition-BznNdlhf.js +0 -55
  109. package/apps/web/dist/assets/esm-BrGYYg2h.js +0 -7
  110. package/apps/web/dist/assets/external-link-MAL7vJYj.js +0 -1
  111. package/apps/web/dist/assets/image-CC_2mJd6.js +0 -1
  112. package/apps/web/dist/assets/index-BZs1IxAk.css +0 -1
  113. package/apps/web/dist/assets/index-Co5QOA9_.js +0 -10
  114. package/apps/web/dist/assets/lock-DZL2N67v.js +0 -1
  115. package/apps/web/dist/assets/meta-B3jzXP27.js +0 -1
  116. package/apps/web/dist/assets/pen-line-NK46C_nG.js +0 -1
  117. package/apps/web/dist/assets/plus-Du-Jx8aI.js +0 -1
  118. package/apps/web/dist/assets/refresh-cw-BNtHis1x.js +0 -1
  119. package/apps/web/dist/assets/settings-menu-CCNUb0Fd.js +0 -1
  120. package/apps/web/dist/assets/table-2-BdDuBcF2.js +0 -1
  121. package/apps/web/dist/assets/trash-0IafyhZB.js +0 -1
  122. package/apps/web/dist/assets/ui-DJOZSSKo.js +0 -1
  123. package/apps/web/dist/assets/upload-DNRENWzQ.js +0 -1
  124. package/apps/web/dist/assets/video-9fDPRPCv.js +0 -1
  125. package/apps/web/dist/assets/volume-2-DE67_rue.js +0 -1
  126. package/apps/web/dist/assets/wand-sparkles-Clj3VRB5.js +0 -1
@@ -1,4 +1,5 @@
1
- import { branchValues, dataRef, formatsFor, LIST_FORMATS, listRef, nextStepId, } from "@engenty-wizards/shared/definition";
1
+ import { marginOf, } from "@engenty-wizards/shared/decision";
2
+ import { branchValues, conditionsOf, dataRef, followRules, formatsFor, isDecidedField, LIST_FORMATS, listRef, loopKey, MAX_SHOWN_FIELDS, optionsFromData, otherwiseOf, pageNeeds, whenHolds, } from "@engenty-wizards/shared/definition";
2
3
  import { eq } from "drizzle-orm";
3
4
  import { nanoid } from "nanoid";
4
5
  import { connectionViews } from "../connectors/index.js";
@@ -17,13 +18,14 @@ import { listRows, scopeOf } from "../store/index.js";
17
18
  import { activeRuns, indexRun, markRunActive } from "../tenants/control.js";
18
19
  import { currentTenant } from "../tenants/tenant.js";
19
20
  import { askPerson, clearStaleAsk, unattended } from "./asks.js";
21
+ import { decide } from "./decide.js";
20
22
  import { emitEvent, recentEvents, signalChanged } from "./events.js";
21
23
  import { readPageInput } from "./input.js";
22
24
  import { pushRun } from "./push.js";
23
25
  import { blockingMessage, closedChoices, missingModels, openValue, stepsWithoutModel, } from "./requirements.js";
24
26
  import { releaseResources, resourcesFor } from "./resources.js";
25
27
  import { runAutomaticStep } from "./steps.js";
26
- import { resolveRef } from "./template.js";
28
+ import { answersAsText, resolveRef } from "./template.js";
27
29
  import { StepError } from "./types.js";
28
30
  /** A command the run cannot take in its current state. */
29
31
  export class RunConflict extends Error {
@@ -226,6 +228,45 @@ export function kick(runId) {
226
228
  signalChanged(runId);
227
229
  });
228
230
  }
231
+ /** What conditions read in this run: answers, outputs, and how many rows each stored list has. */
232
+ async function flowValues(run, state) {
233
+ const counts = {};
234
+ if (run.definition.lists?.length) {
235
+ for (const [id, list] of Object.entries(await storedLists(run))) {
236
+ counts[id] = list.rows.length;
237
+ }
238
+ }
239
+ return branchValues(state.values, state.outputs, counts);
240
+ }
241
+ /**
242
+ * What a page knows besides the person's input: what its fields' `when` read from before the
243
+ * page (its own earlier answers left out, so a revisit decides on what is typed now) and the
244
+ * choices its `optionsFrom` fields find in the run.
245
+ */
246
+ async function pageContext(run, step) {
247
+ const needs = pageNeeds(step.fields);
248
+ const sourced = step.fields.filter((f) => f.optionsFrom);
249
+ const decided = step.fields.some(isDecidedField) ? (run.state.pages?.[step.id]?.shown ?? []) : [];
250
+ if (!needs.length && !sourced.length) {
251
+ return { known: {}, options: {}, decided };
252
+ }
253
+ const all = await flowValues(run, run.state);
254
+ const known = Object.fromEntries(needs.map((ref) => [ref, all[ref]]));
255
+ const lists = sourced.some((f) => f.optionsFrom?.startsWith("lists."))
256
+ ? await storedLists(run)
257
+ : {};
258
+ const options = {};
259
+ for (const field of sourced) {
260
+ const found = optionsFromData(field.optionsFrom ?? "", run.state.outputs, lists);
261
+ if (found.length) {
262
+ options[field.id] = found;
263
+ }
264
+ }
265
+ return { known, options, decided };
266
+ }
267
+ function viewContext(ctx) {
268
+ return { known: ctx.known, options: ctx.options, decidedFields: ctx.decided };
269
+ }
229
270
  /** The wizard's lists with the person's rows, by list id. */
230
271
  async function storedLists(run) {
231
272
  const scope = scopeOf(run);
@@ -243,6 +284,7 @@ async function makeContext(run, project, state, signal) {
243
284
  return {
244
285
  runId: run.id,
245
286
  tenantId: run.tenantId,
287
+ test: run.mode === "test",
246
288
  stepId,
247
289
  store,
248
290
  def: run.definition,
@@ -271,6 +313,146 @@ async function makeContext(run, project, state, signal) {
271
313
  }),
272
314
  };
273
315
  }
316
+ /** A decided branch is taken only when its pick leads the runner-up by this much. */
317
+ const BRANCH_MARGIN = 0.1;
318
+ /**
319
+ * Where the run goes after `stepId`: a `when` rule or the next step at once — counted, so a loop
320
+ * stops at its rule's max — or, for `ask` rules, a decision the drive loop makes (`deciding`).
321
+ */
322
+ async function advance(run, stepId, state) {
323
+ const outcome = followRules(run.definition, stepId, await flowValues(run, state), state.loops);
324
+ if (outcome.kind === "decide") {
325
+ state.deciding = stepId;
326
+ return stepId;
327
+ }
328
+ return taken(run.definition, stepId, outcome.rule, outcome.cursor, state);
329
+ }
330
+ function taken(def, stepId, rule, cursor, state) {
331
+ const goto = rule === null ? null : stepOf(def, stepId)?.next?.[rule]?.goto;
332
+ if (goto) {
333
+ const key = loopKey(stepId, goto);
334
+ state.loops = { ...state.loops, [key]: (state.loops?.[key] ?? 0) + 1 };
335
+ }
336
+ return cursor;
337
+ }
338
+ /** What a decision about the run reads: the person's answers and what the steps made so far. */
339
+ function runSummary(run, state) {
340
+ const results = {};
341
+ for (const step of run.definition.steps) {
342
+ const out = state.outputs[step.id];
343
+ const text = out?.text ?? (out?.json === undefined ? "" : JSON.stringify(out.json));
344
+ if (text) {
345
+ results[step.title] = text.slice(0, 6000);
346
+ }
347
+ }
348
+ return {
349
+ answers: answersAsText({ def: run.definition, state, brand: {} }),
350
+ results,
351
+ };
352
+ }
353
+ /**
354
+ * The decision over a step's open `ask` rules: one choice among their statements and "none".
355
+ * Kept in the run; a pick that does not lead clearly (where there are probabilities) is none.
356
+ */
357
+ async function decideBranch(run, step, state, signal) {
358
+ const outcome = followRules(run.definition, step.id, {}, state.loops);
359
+ const rules = outcome.kind === "decide" ? outcome.rules : [];
360
+ const criteria = {};
361
+ for (const i of rules) {
362
+ criteria[`r${i}`] = step.next?.[i]?.ask ?? "";
363
+ }
364
+ criteria.none = "None of these clearly holds: go on with the next step.";
365
+ await emitEvent(run.id, step.id, "info", { code: "deciding" });
366
+ const decision = await decide({
367
+ state: runSummary(run, state),
368
+ questions: {
369
+ next: {
370
+ type: "choice",
371
+ instructions: "Which statement is true of this run? Answer none unless one clearly holds.",
372
+ criteria,
373
+ },
374
+ },
375
+ call: { runId: run.id, stepId: step.id },
376
+ signal,
377
+ charge: (usd) => addLocalCost(run.id, step.id, usd),
378
+ });
379
+ const answer = decision.answers.next;
380
+ const margin = marginOf(answer);
381
+ const pick = margin !== null && margin < BRANCH_MARGIN ? "none" : answer.choice;
382
+ const rule = pick === "none" ? null : Number(pick.slice(1));
383
+ state.decisions = {
384
+ ...state.decisions,
385
+ [step.id]: {
386
+ rule,
387
+ source: decision.source,
388
+ ...(answer.probabilities ? { probabilities: answer.probabilities } : {}),
389
+ at: new Date().toISOString(),
390
+ },
391
+ };
392
+ delete state.deciding;
393
+ if (rule === null) {
394
+ const otherwise = otherwiseOf(run.definition, step.id, state.loops);
395
+ return taken(run.definition, step.id, otherwise.rule, otherwise.cursor, state);
396
+ }
397
+ const goto = step.next?.[rule]?.goto ?? "end";
398
+ return taken(run.definition, step.id, rule, goto === "end" ? null : goto, state);
399
+ }
400
+ /**
401
+ * The decided fields (`ask`, `group`) of a page a decision keeps, in one call as the run reaches
402
+ * the page. A field whose condition on earlier data already fails is not asked about. With the
403
+ * fields always shown, at most MAX_SHOWN_FIELDS appear.
404
+ */
405
+ async function decidePageFields(run, page, state, signal) {
406
+ const values = await flowValues(run, state);
407
+ const own = new Set(page.fields.map((f) => f.id));
408
+ const possible = page.fields.filter((f) => isDecidedField(f) &&
409
+ whenHolds(conditionsOf(f.when).filter((c) => !own.has(c.field)), values));
410
+ const questions = {};
411
+ for (const f of possible.filter((f) => f.ask)) {
412
+ questions[`f_${f.id}`] = {
413
+ type: "noul",
414
+ instructions: `Should the page ask "${f.label}"? It is asked when this holds: ${f.ask}`,
415
+ };
416
+ }
417
+ for (const g of page.groups ?? []) {
418
+ const members = possible.filter((f) => f.group === g.id);
419
+ if (members.length) {
420
+ questions[`g_${g.id}`] = {
421
+ type: "choice",
422
+ instructions: g.instructions,
423
+ criteria: {
424
+ ...Object.fromEntries(members.map((f) => [f.id, f.help ? `${f.label} — ${f.help}` : f.label])),
425
+ ...(g.optional ? { none: "None of these." } : {}),
426
+ },
427
+ };
428
+ }
429
+ }
430
+ if (!Object.keys(questions).length) {
431
+ return [];
432
+ }
433
+ await emitEvent(run.id, page.id, "info", { code: "choosingFields" });
434
+ const decision = await decide({
435
+ state: runSummary(run, state),
436
+ questions,
437
+ call: { runId: run.id, stepId: page.id },
438
+ signal,
439
+ charge: (usd) => addLocalCost(run.id, page.id, usd),
440
+ });
441
+ const kept = new Set();
442
+ for (const [name, answer] of Object.entries(decision.answers)) {
443
+ if (answer.type === "noul" && answer.noul >= 0.5) {
444
+ kept.add(name.slice(2));
445
+ }
446
+ else if (answer.type === "choice" && answer.choice !== "none") {
447
+ kept.add(answer.choice);
448
+ }
449
+ }
450
+ const room = MAX_SHOWN_FIELDS - page.fields.filter((f) => !isDecidedField(f)).length;
451
+ return page.fields
452
+ .filter((f) => isDecidedField(f) && kept.has(f.id))
453
+ .slice(0, Math.max(0, room))
454
+ .map((f) => f.id);
455
+ }
274
456
  async function drive(runId, signal) {
275
457
  await clearStaleAsk(runId);
276
458
  while (!signal.aborted) {
@@ -279,11 +461,59 @@ async function drive(runId, signal) {
279
461
  return;
280
462
  }
281
463
  const def = run.definition;
464
+ // A step's `ask` branches wait on a decision before the run goes on.
465
+ const deciding = run.state.deciding ? stepOf(def, run.state.deciding) : undefined;
466
+ if (deciding) {
467
+ const state = structuredClone(run.state);
468
+ try {
469
+ const cursor = await decideBranch(run, deciding, state, signal);
470
+ if (signal.aborted) {
471
+ return;
472
+ }
473
+ await syncRunCost(runId);
474
+ await updateRun(runId, { state, cursor, status: cursor ? "running" : "done", error: null });
475
+ }
476
+ catch (err) {
477
+ if (signal.aborted) {
478
+ return;
479
+ }
480
+ console.error(`[run ${runId} deciding ${deciding.id}]`, err);
481
+ const message = friendly(err);
482
+ await emitEvent(runId, deciding.id, "error", message);
483
+ await updateRun(runId, { status: "failed", error: message });
484
+ return;
485
+ }
486
+ continue;
487
+ }
282
488
  const step = stepOf(def, run.cursor);
283
489
  if (!step) {
284
490
  await updateRun(runId, { status: "done" });
285
491
  return;
286
492
  }
493
+ // A page whose fields a decision picks: picked once, as the run reaches it.
494
+ if (step.type === "page" && step.fields.some(isDecidedField) && !run.state.pages?.[step.id]) {
495
+ const state = structuredClone(run.state);
496
+ try {
497
+ const shown = await decidePageFields(run, step, state, signal);
498
+ if (signal.aborted) {
499
+ return;
500
+ }
501
+ state.pages = { ...state.pages, [step.id]: { shown } };
502
+ await syncRunCost(runId);
503
+ await updateRun(runId, { state, error: null });
504
+ }
505
+ catch (err) {
506
+ if (signal.aborted) {
507
+ return;
508
+ }
509
+ console.error(`[run ${runId} fields of ${step.id}]`, err);
510
+ const message = friendly(err);
511
+ await emitEvent(runId, step.id, "error", message);
512
+ await updateRun(runId, { status: "failed", error: message });
513
+ return;
514
+ }
515
+ continue;
516
+ }
287
517
  if (step.type === "result") {
288
518
  await updateRun(runId, { status: "done" });
289
519
  return;
@@ -312,7 +542,7 @@ async function drive(runId, signal) {
312
542
  state.outputs[step.id] = output;
313
543
  delete state.notes[step.id];
314
544
  delete state.redo?.[step.id];
315
- const cursor = nextStepId(def, step.id, branchValues(state.values, state.outputs));
545
+ const cursor = await advance(run, step.id, state);
316
546
  await updateRun(runId, { state, cursor, status: cursor ? "running" : "done", error: null });
317
547
  await emitEvent(runId, step.id, "step_done", step.title);
318
548
  }
@@ -363,7 +593,7 @@ export async function submitPage(runId, stepId, input) {
363
593
  if (step?.type !== "page") {
364
594
  throw new RunConflict("Not a page");
365
595
  }
366
- const { values, errors } = readPageInput(step, input);
596
+ const { values, errors } = readPageInput(step, input, await pageContext(run, step));
367
597
  // An answer that leads to a step without a model here would stop the run there, later.
368
598
  const closed = await closedChoices(run.definition, step, run.state.values);
369
599
  for (const [id, choice] of Object.entries(closed)) {
@@ -408,8 +638,13 @@ export async function submitPage(runId, stepId, input) {
408
638
  delete state.values[field.id];
409
639
  }
410
640
  }
641
+ // Later pages decide their fields again on what is answered now.
642
+ const at = run.definition.steps.indexOf(step);
643
+ for (const later of run.definition.steps.slice(at + 1)) {
644
+ delete state.pages?.[later.id];
645
+ }
411
646
  state.history.push(stepId);
412
- const cursor = nextStepId(run.definition, stepId, branchValues(state.values, state.outputs));
647
+ const cursor = await advance(run, stepId, state);
413
648
  await updateRun(runId, { state, cursor, status: "running", error: null });
414
649
  kick(runId);
415
650
  }
@@ -427,7 +662,7 @@ export async function reviewStep(runId, stepId, action) {
427
662
  }
428
663
  }
429
664
  state.history.push(stepId);
430
- const cursor = nextStepId(run.definition, stepId, branchValues(state.values, state.outputs));
665
+ const cursor = await advance(run, stepId, state);
431
666
  await updateRun(runId, { state, cursor, status: "running", error: null });
432
667
  kick(runId);
433
668
  return;
@@ -458,6 +693,8 @@ export async function goBack(runId) {
458
693
  if (!previous) {
459
694
  throw new RunConflict("Kein vorheriger Schritt.");
460
695
  }
696
+ // A decision that failed belongs to the step left behind.
697
+ delete state.deciding;
461
698
  await updateRun(runId, { state, cursor: previous, status: "waiting_input", error: null });
462
699
  }
463
700
  export async function retry(runId) {
@@ -578,6 +815,15 @@ export async function runView(run, brand) {
578
815
  step,
579
816
  values: run.state.values,
580
817
  prefill: step?.type === "page" ? prefillOf(step, run, closed) : {},
818
+ ...(step?.type === "page" ? viewContext(await pageContext(run, step)) : {}),
819
+ ...(run.mode === "test" && run.state.decisions
820
+ ? {
821
+ decisions: Object.fromEntries(Object.entries(run.state.decisions).map(([id, d]) => [
822
+ id,
823
+ { ...d, over: def.steps.find((s) => s.id === id)?.next ?? [] },
824
+ ])),
825
+ }
826
+ : {}),
581
827
  closed,
582
828
  outputs: Object.fromEntries(shownIds.map((id) => [id, run.state.outputs[id]]).filter(([, o]) => o)),
583
829
  shown,
@@ -1,4 +1,6 @@
1
+ import { pOf } from "@engenty-wizards/shared/decision";
1
2
  import { allFields, dataRef, isDecisionStep, LOCALES, templateRefs, wizardLang, } from "@engenty-wizards/shared/definition";
3
+ import { SURFACE_GUIDE, validateSurface, } from "@engenty-wizards/shared/surface";
2
4
  import { Agent } from "@mastra/core/agent";
3
5
  import { generateText, Output } from "ai";
4
6
  import { z } from "zod";
@@ -12,7 +14,9 @@ import { buildStepTools } from "../tools/index.js";
12
14
  import { spaceContextOf } from "../tools/plugin.js";
13
15
  import { personUploads } from "../tools/store.js";
14
16
  import { runWidgetStep } from "../widgets/step.js";
17
+ import { decide } from "./decide.js";
15
18
  import { prepareInputs } from "./prepare.js";
19
+ import { runSurfaceStep } from "./surface.js";
16
20
  import { answersAsText, colorLine, factLines, renderTemplate, resolveRef, } from "./template.js";
17
21
  import { StepError } from "./types.js";
18
22
  /** Today's date as the wizard's people write it. */
@@ -51,7 +55,12 @@ async function previousResult(ctx, stepId) {
51
55
  async function revisionBlock(ctx, stepId) {
52
56
  const note = ctx.state.notes[stepId];
53
57
  if (!note) {
54
- return "";
58
+ // A branch back to this step (a loop) runs it again: it builds on what it made last time.
59
+ const looped = Object.entries(ctx.state.loops ?? {}).some(([key, n]) => n > 0 && key.endsWith(`→${stepId}`));
60
+ const prev = looped ? await previousResult(ctx, stepId) : "";
61
+ return prev
62
+ ? `# EARLIER ATTEMPT\nA later step sent the run back to this step. What it made last time — improve on it with what is known now:\n"""\n${prev}\n"""`
63
+ : "";
55
64
  }
56
65
  const prev = await previousResult(ctx, stepId);
57
66
  return `# REVISION REQUEST\nThe person reviewed the previous result and asked for this change:\n"""${note}"""\n\n${prev ? `Previous result:\n"""\n${prev}\n"""` : ""}`;
@@ -88,6 +97,11 @@ function outputSchema(step) {
88
97
  case "choice":
89
98
  shape[f.id] = z.enum(f.options).describe(d);
90
99
  break;
100
+ case "score":
101
+ shape[f.id] = z
102
+ .enum(f.options)
103
+ .describe(`${d} (lowest to highest)`);
104
+ break;
91
105
  case "table":
92
106
  shape[f.id] = f.columns?.length
93
107
  ? z
@@ -148,7 +162,186 @@ async function seenPhotos(step, ctx, resolved, uploads) {
148
162
  }
149
163
  return out;
150
164
  }
165
+ /**
166
+ * A decision step: its yes/no, choice and score fields are the questions, its prompt the state,
167
+ * answered in one call through decide() — with probabilities where a decision model answers.
168
+ */
169
+ async function decideStep(step, prompt, ctx) {
170
+ const fields = step.output.fields ?? [];
171
+ const questions = {};
172
+ for (const f of fields) {
173
+ const instructions = f.description ?? f.id;
174
+ if (f.kind === "yesno") {
175
+ questions[f.id] = { type: "noul", instructions };
176
+ }
177
+ else if (f.kind === "choice") {
178
+ questions[f.id] = {
179
+ type: "choice",
180
+ instructions,
181
+ criteria: Object.fromEntries((f.options ?? []).map((o) => [o, null])),
182
+ };
183
+ }
184
+ else if (f.kind === "score") {
185
+ questions[f.id] = { type: "score", instructions, criteria: f.options ?? [] };
186
+ }
187
+ }
188
+ const decision = await decide({
189
+ state: prompt,
190
+ questions,
191
+ call: ctx.call,
192
+ signal: ctx.signal,
193
+ charge: ctx.chargeUsd,
194
+ });
195
+ const json = {};
196
+ const decided = {};
197
+ for (const [id, answer] of Object.entries(decision.answers)) {
198
+ json[id] =
199
+ answer.type === "noul"
200
+ ? answer.noul >= 0.5
201
+ : answer.type === "choice"
202
+ ? answer.choice
203
+ : answer.label;
204
+ decided[id] = Math.round(pOf(answer) * 1000) / 1000;
205
+ }
206
+ const text = fields
207
+ .map((f) => {
208
+ const value = json[f.id];
209
+ return `${f.description ?? f.id}: ${value === true ? "ja" : value === false ? "nein" : String(value)}`;
210
+ })
211
+ .join("\n");
212
+ return { text, json, decided, at: new Date().toISOString() };
213
+ }
214
+ /** The JSON array in a model's answer, fenced or not. */
215
+ function jsonArrayIn(text) {
216
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/)?.[1] ?? text;
217
+ const start = fenced.indexOf("[");
218
+ const end = fenced.lastIndexOf("]");
219
+ if (start < 0 || end <= start) {
220
+ return null;
221
+ }
222
+ try {
223
+ return JSON.parse(fenced.slice(start, end + 1));
224
+ }
225
+ catch {
226
+ return null;
227
+ }
228
+ }
229
+ /**
230
+ * The view an agent step composes of its result: the standard class writes components from the
231
+ * surface catalog bound to the step's fields, the catalog's validator checks them, and one try
232
+ * with the issues follows. Without a valid view the plain result shows: the step never fails
233
+ * for its view.
234
+ */
235
+ async function composeSurface(step, json, ctx) {
236
+ const keys = Object.keys(json);
237
+ const fields = (step.output.fields ?? [])
238
+ .map((f) => `- /${f.id} (${f.kind}${f.columns ? `: ${f.columns.join(", ")}` : ""}): ${f.description ?? ""}`)
239
+ .join("\n");
240
+ const sample = JSON.stringify(json).slice(0, 4000);
241
+ try {
242
+ const writer = await textModel("standard", ctx.call);
243
+ let issues = [];
244
+ for (let attempt = 0; attempt < 2; attempt++) {
245
+ const result = await generateText({
246
+ model: writer.model,
247
+ abortSignal: ctx.signal,
248
+ prompt: [
249
+ `Compose a view of this result for the person who ran "${ctx.def.title}". Answer with the JSON array of components only.`,
250
+ SURFACE_GUIDE,
251
+ `# THE DATA (bind by path, never copy values)\n${fields}\n\nSample: ${sample}`,
252
+ issues.length
253
+ ? `# YOUR LAST ANSWER HAD THESE PROBLEMS — fix them\n${issues.join("\n")}`
254
+ : "",
255
+ ]
256
+ .filter(Boolean)
257
+ .join("\n\n"),
258
+ });
259
+ await ctx.chargeUsd(costOf(writer, result.usage));
260
+ const components = jsonArrayIn(result.text);
261
+ issues = validateSurface(components, keys).map((i) => i.message);
262
+ if (!issues.length) {
263
+ return { components: components, data: json };
264
+ }
265
+ }
266
+ console.warn(`[step ${step.id}] no valid view:`, issues.join("; "));
267
+ }
268
+ catch (err) {
269
+ if (ctx.signal.aborted) {
270
+ throw err;
271
+ }
272
+ console.warn(`[step ${step.id}] view not composed:`, err.message);
273
+ }
274
+ return undefined;
275
+ }
276
+ /** An agent step runs this many times at most with `each`; more entries are left out. */
277
+ const MAX_AGENT_ENTRIES = 20;
278
+ /** The entries an agent step with `each` runs for: rows of a table, entries of a list, list rows. */
279
+ function agentEntries(step, ctx) {
280
+ const ref = dataRef(step.each ?? "");
281
+ const list = ref.match(/^lists\.([a-zA-Z][a-zA-Z0-9_]*)$/)?.[1];
282
+ const value = list
283
+ ? (ctx.scope.lists?.[list]?.rows ?? []).map((r) => r.cells)
284
+ : resolveRef(ref, ctx.scope);
285
+ return (Array.isArray(value) ? value : []).slice(0, MAX_AGENT_ENTRIES);
286
+ }
287
+ /**
288
+ * An agent step with `each`: the step once per entry, one after the other, each reading its
289
+ * entry as {{item}}. The results become one table — the entry's columns and the step's output
290
+ * fields — and one text with a heading per entry.
291
+ */
292
+ async function runAgentEach(step, ctx) {
293
+ const entries = agentEntries(step, ctx);
294
+ if (!entries.length) {
295
+ throw new StepError("Es gibt keine Einträge, für die dieser Schritt arbeiten könnte.");
296
+ }
297
+ // The view, if any, is composed once over the table of all entries.
298
+ const single = {
299
+ ...step,
300
+ each: undefined,
301
+ output: { ...step.output, surface: false },
302
+ };
303
+ const rows = [];
304
+ const texts = [];
305
+ const assets = [];
306
+ for (const [index, item] of entries.entries()) {
307
+ await ctx.emit("info", { code: "entry", params: { n: index + 1, total: entries.length } });
308
+ const scope = { ...ctx.scope, entry: { item, index, count: entries.length } };
309
+ const out = await runAgentStep(single, { ...ctx, scope });
310
+ const own = item && typeof item === "object" ? item : { item };
311
+ const json = out.json && typeof out.json === "object" ? out.json : {};
312
+ rows.push({ ...own, ...json });
313
+ const title = Object.values(own).find((v) => typeof v === "string") ?? `${index + 1}`;
314
+ texts.push(`## ${String(title)}\n\n${out.text ?? ""}`.trim());
315
+ assets.push(...(out.assets ?? []));
316
+ }
317
+ const json = { rows };
318
+ const surface = step.output.surface
319
+ ? await composeSurface({
320
+ ...step,
321
+ output: {
322
+ ...step.output,
323
+ fields: [
324
+ {
325
+ id: "rows",
326
+ kind: "table",
327
+ description: "One row per entry: its columns and the step's fields",
328
+ },
329
+ ],
330
+ },
331
+ }, json, ctx)
332
+ : undefined;
333
+ return {
334
+ text: texts.join("\n\n"),
335
+ json,
336
+ ...(surface ? { surface } : {}),
337
+ assets: assets.length ? assets : undefined,
338
+ at: new Date().toISOString(),
339
+ };
340
+ }
151
341
  export async function runAgentStep(step, ctx) {
342
+ if (step.each) {
343
+ return runAgentEach(step, ctx);
344
+ }
152
345
  const resolved = await textModel(step.model ?? "high", { ...ctx.call, effort: step.effort });
153
346
  const uploads = await personUploads(ctx);
154
347
  const { tools, assets, close } = await buildStepTools(step, ctx, resolved, uploads);
@@ -192,22 +385,7 @@ export async function runAgentStep(step, ctx) {
192
385
  .join("\n\n");
193
386
  // A decision needs no agent: its questions go to the classifier class in one call.
194
387
  if (isDecisionStep(step) && uploads.length === 0) {
195
- const { schema, fields } = outputSchema(step);
196
- const decision = await generateText({
197
- model: resolved.model,
198
- abortSignal: ctx.signal,
199
- output: Output.object({ schema }),
200
- prompt,
201
- });
202
- await ctx.chargeUsd(costOf(resolved, decision.usage));
203
- const json = decision.output;
204
- const text = fields
205
- .map((f) => {
206
- const value = json[f.id];
207
- return `${f.description ?? f.id}: ${value === true ? "ja" : value === false ? "nein" : String(value)}`;
208
- })
209
- .join("\n");
210
- return { text, json, at: new Date().toISOString() };
388
+ return decideStep(step, prompt, ctx);
211
389
  }
212
390
  const agent = new Agent({
213
391
  id: `step-${step.id}`,
@@ -260,7 +438,14 @@ export async function runAgentStep(step, ctx) {
260
438
  });
261
439
  await ctx.chargeUsd(costOf(structurer, structured.usage));
262
440
  const json = tablesToRecords(structured.output, fields);
263
- return { text, json, assets: assets.length ? assets : undefined, at: new Date().toISOString() };
441
+ const surface = step.output.surface ? await composeSurface(step, json, ctx) : undefined;
442
+ return {
443
+ text,
444
+ json,
445
+ ...(surface ? { surface } : {}),
446
+ assets: assets.length ? assets : undefined,
447
+ at: new Date().toISOString(),
448
+ };
264
449
  }
265
450
  finally {
266
451
  await close();
@@ -604,5 +789,8 @@ export async function runAutomaticStep(step, ctx) {
604
789
  if (step.type === "film") {
605
790
  return runFilmStep(step, ctx);
606
791
  }
792
+ if (step.type === "surface") {
793
+ return runSurfaceStep(step, ctx);
794
+ }
607
795
  throw new StepError(`Step ${step.id} is not automatic.`);
608
796
  }