wizards 0.2.25 → 0.2.26

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 (68) hide show
  1. package/apps/runtime/dist/agents/architect.js +5 -0
  2. package/apps/runtime/dist/authoring/guide.js +55 -6
  3. package/apps/runtime/dist/connectors/builtin.js +17 -1
  4. package/apps/runtime/dist/connectors/index.js +43 -14
  5. package/apps/runtime/dist/connectors/via-account.js +63 -0
  6. package/apps/runtime/dist/credits/estimate.js +12 -2
  7. package/apps/runtime/dist/engine/decide.js +82 -0
  8. package/apps/runtime/dist/engine/input.js +12 -5
  9. package/apps/runtime/dist/engine/requirements.js +80 -15
  10. package/apps/runtime/dist/engine/runner.js +252 -6
  11. package/apps/runtime/dist/engine/steps.js +206 -18
  12. package/apps/runtime/dist/engine/surface.js +90 -0
  13. package/apps/runtime/dist/render/downloads.js +7 -0
  14. package/apps/runtime/dist/routes/runs.js +18 -5
  15. package/apps/runtime/dist/services/relevance.js +17 -49
  16. package/apps/runtime/dist/tools/pages.js +31 -6
  17. package/apps/web/dist/assets/{Account-CpxsROpe.js → Account-Cnksw5Ea.js} +1 -1
  18. package/apps/web/dist/assets/{ChangelogDialog-B7C0r9dF.js → ChangelogDialog-CdtQWq0m.js} +1 -1
  19. package/apps/web/dist/assets/{CreditsDialog-Com2Y3g8.js → CreditsDialog-Ti43boCN.js} +1 -1
  20. package/apps/web/dist/assets/EditorPage-N7EzlRSj.js +3 -0
  21. package/apps/web/dist/assets/{FlowPreview-D0m5Qk6S.js → FlowPreview-IZcNrBJi.js} +1 -1
  22. package/apps/web/dist/assets/{Harness-bIL3pHB_.js → Harness-Cj-k02TA.js} +1 -1
  23. package/apps/web/dist/assets/{HomePage-DPE_Z9HB.js → HomePage-bjdKkt84.js} +1 -1
  24. package/apps/web/dist/assets/{ImportWizard-Dz2uKznp.js → ImportWizard-DagFXYPM.js} +1 -1
  25. package/apps/web/dist/assets/{LocalRuntime-wMKUVZOu.js → LocalRuntime-Boi_zqQJ.js} +1 -1
  26. package/apps/web/dist/assets/NewWizardPage-DO5J8OpZ.js +2 -0
  27. package/apps/web/dist/assets/ProjectPage-DdG9wYsd.js +2 -0
  28. package/apps/web/dist/assets/{PublicRunner-BsrqVK0J.js → PublicRunner-B4ts9rT6.js} +1 -1
  29. package/apps/web/dist/assets/RunDrawer-LnVfCiYO.js +4 -0
  30. package/apps/web/dist/assets/RunnerView-DwffK7AA.js +70 -0
  31. package/apps/web/dist/assets/{SettingsPage-BkuUm-bf.js → SettingsPage-Cyx_jTlp.js} +1 -1
  32. package/apps/web/dist/assets/{SetupPage-CoyXpcvS.js → SetupPage-CBDD4rpC.js} +1 -1
  33. package/apps/web/dist/assets/{SharePage-BuR-he1L.js → SharePage-70niRKaO.js} +1 -1
  34. package/apps/web/dist/assets/{api-rJBQEu1G.js → api-C4M6cbpW.js} +1 -1
  35. package/apps/web/dist/assets/changelog-COyL1B5X.js +1 -0
  36. package/apps/web/dist/assets/definition-DoCLtZZL.js +55 -0
  37. package/apps/web/dist/assets/{esm-BrGYYg2h.js → esm-CfW8Rmtq.js} +1 -1
  38. package/apps/web/dist/assets/index-CJ0YJ5HC.css +1 -0
  39. package/apps/web/dist/assets/{index-Co5QOA9_.js → index-DMKXnSf-.js} +3 -3
  40. package/apps/web/dist/assets/meta-6jZygxJq.js +1 -0
  41. package/apps/web/dist/assets/{settings-menu-CCNUb0Fd.js → settings-menu-Dph4yXbO.js} +1 -1
  42. package/apps/web/dist/assets/{table-2-BdDuBcF2.js → star-UHmZXpjA.js} +1 -1
  43. package/apps/web/dist/assets/ui-BS348L4M.js +1 -0
  44. package/apps/web/dist/assets/wand-sparkles-IEd4jkyn.js +1 -0
  45. package/apps/web/dist/index.html +4 -4
  46. package/apps/web/dist/mcp/flow.html +60 -60
  47. package/node_modules/@engenty-wizards/shared/dist/decision.d.ts +76 -0
  48. package/node_modules/@engenty-wizards/shared/dist/decision.js +147 -0
  49. package/node_modules/@engenty-wizards/shared/dist/definition.d.ts +1048 -50
  50. package/node_modules/@engenty-wizards/shared/dist/definition.js +516 -43
  51. package/node_modules/@engenty-wizards/shared/dist/marketplace-entry.js +19 -2
  52. package/node_modules/@engenty-wizards/shared/dist/marketplace.d.ts +1 -1
  53. package/node_modules/@engenty-wizards/shared/dist/run.d.ts +55 -0
  54. package/node_modules/@engenty-wizards/shared/dist/run.js +3 -0
  55. package/node_modules/@engenty-wizards/shared/dist/surface.d.ts +89 -0
  56. package/node_modules/@engenty-wizards/shared/dist/surface.js +198 -0
  57. package/package.json +1 -1
  58. package/apps/web/dist/assets/EditorPage-BCagWjrz.js +0 -2
  59. package/apps/web/dist/assets/NewWizardPage-BOxU2jWq.js +0 -2
  60. package/apps/web/dist/assets/ProjectPage-BJp2wqIk.js +0 -5
  61. package/apps/web/dist/assets/RunDrawer-CLfJ0W_V.js +0 -1
  62. package/apps/web/dist/assets/RunnerView-BXMELFC4.js +0 -70
  63. package/apps/web/dist/assets/changelog-Bmw6dg-T.js +0 -1
  64. package/apps/web/dist/assets/definition-BznNdlhf.js +0 -55
  65. package/apps/web/dist/assets/index-BZs1IxAk.css +0 -1
  66. package/apps/web/dist/assets/meta-B3jzXP27.js +0 -1
  67. package/apps/web/dist/assets/ui-DJOZSSKo.js +0 -1
  68. package/apps/web/dist/assets/wand-sparkles-Clj3VRB5.js +0 -1
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { tableColumnsSchema } from "./engenty/data-tables/index.js";
3
3
  import { CONNECTION_KINDS } from "./store.js";
4
+ import { surfaceComponentsSchema, validateSurface } from "./surface.js";
4
5
  /** The engenty avatars a wizard can wear. Same cast as engenty's ui-core. */
5
6
  export const ENGENTY_KINDS = [
6
7
  "round",
@@ -117,6 +118,27 @@ export const itemColumnSchema = z.object({
117
118
  label: z.string().min(1),
118
119
  kind: z.enum(["text", "number", "money"]),
119
120
  });
121
+ export const CONDITION_OPS = [
122
+ "equals",
123
+ "notEquals",
124
+ "in",
125
+ "notEmpty",
126
+ "empty",
127
+ "gt",
128
+ "lt",
129
+ "contains",
130
+ ];
131
+ /**
132
+ * One test on what the run knows: an answer by field id, an output of an agent step as
133
+ * "steps.<step>.<field>", or how many rows a stored list has as "lists.<list>.count".
134
+ */
135
+ export const conditionSchema = z.object({
136
+ field: z.string(),
137
+ op: z.enum(CONDITION_OPS),
138
+ value: z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]).optional(),
139
+ });
140
+ /** One condition, or several that must all hold. */
141
+ export const whenSchema = z.union([conditionSchema, z.array(conditionSchema).min(1)]);
120
142
  export const fieldSchema = z.object({
121
143
  id,
122
144
  label: z.string().min(1),
@@ -157,15 +179,54 @@ export const fieldSchema = z.object({
157
179
  * `items` field takes a table whose columns are its column ids; the person corrects it.
158
180
  */
159
181
  prefill: z.string().optional(),
182
+ /**
183
+ * The field is shown only while this holds. It reads an answer of this page (decided as the
184
+ * person types) or anything known before the page. A hidden field is not asked and not kept.
185
+ */
186
+ when: whenSchema.optional(),
187
+ /**
188
+ * select / multiselect: the choices come from earlier data — a list output
189
+ * ("steps.<id>.<key>"), a column of a table output ("steps.<id>.<key>.<column>") or a column
190
+ * of a stored list ("lists.<id>.<column>"). `options` stand in while that data is empty.
191
+ */
192
+ optionsFrom: z.string().optional(),
193
+ /**
194
+ * The field is shown when a decision, made as the run reaches the page, finds this statement
195
+ * true of the run ("The complaint is about a damaged article").
196
+ */
197
+ ask: z.string().min(1).optional(),
198
+ /** The id of one of the page's `groups`: of a group's fields a decision shows one (or none). */
199
+ group: z.string().optional(),
160
200
  });
161
- export const nextRuleSchema = z.object({
162
- when: z.object({
163
- field: z.string(),
164
- op: z.enum(["equals", "notEquals", "in", "notEmpty", "empty"]),
165
- value: z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]).optional(),
166
- }),
167
- /** A step id, or "end". */
201
+ export const fieldGroupSchema = z.object({
202
+ id,
203
+ /** What the decision picks: "How should we reach the person?" */
204
+ instructions: z.string().min(1),
205
+ /** True when showing none of the group's fields is a valid answer. */
206
+ optional: z.boolean().optional(),
207
+ });
208
+ export const nextRuleSchema = z
209
+ .object({
210
+ /** The rule holds when this holds … */
211
+ when: whenSchema.optional(),
212
+ /**
213
+ * … or, instead, when a decision finds this statement true of the run ("The person wants a
214
+ * refund"). All `ask` rules of a step are decided together, after no `when` rule matched.
215
+ */
216
+ ask: z.string().min(1).optional(),
217
+ /**
218
+ * A step id, or "end". A rule with neither `when` nor `ask` is the step's "otherwise": where
219
+ * the run goes when no other rule holds, in place of the next step in the list.
220
+ */
168
221
  goto: z.string(),
222
+ /**
223
+ * How often this rule may send the run on in one run; afterwards it is skipped. Meant for a
224
+ * branch back to an earlier step (a loop), which without it is taken at most ten times.
225
+ */
226
+ max: z.number().int().min(1).max(50).optional(),
227
+ })
228
+ .refine((rule) => rule.when === undefined || rule.ask === undefined, {
229
+ message: 'A branch has "when" or "ask", not both.',
169
230
  });
170
231
  const stepBase = {
171
232
  id,
@@ -176,22 +237,25 @@ const stepBase = {
176
237
  export const outputFieldSchema = z
177
238
  .object({
178
239
  id,
179
- kind: z.enum(["text", "number", "list", "table", "yesno", "choice"]),
180
- /** What the field holds; for `yesno` and `choice` the question it answers. */
240
+ kind: z.enum(["text", "number", "list", "table", "yesno", "choice", "score"]),
241
+ /** What the field holds; for `yesno`, `choice` and `score` the question it answers. */
181
242
  description: z.string().optional(),
182
243
  /** Table only: the exact column keys every row has — what a widget reads. */
183
244
  columns: z.array(z.string().min(1)).optional(),
184
- /** Choice only: the options, one of which is the answer. */
245
+ /** Choice: the options, one of which is the answer. Score: the levels, lowest first. */
185
246
  options: z.array(z.string().min(1)).min(2).max(40).optional(),
186
247
  })
187
- .refine((field) => field.kind !== "choice" || Boolean(field.options?.length), {
188
- message: "A choice field lists its options.",
248
+ .refine((field) => (field.kind !== "choice" && field.kind !== "score") || Boolean(field.options?.length), { message: "A choice or score field lists its options.", path: ["options"] })
249
+ .refine((field) => field.kind !== "score" || (field.options?.length ?? 0) <= 10, {
250
+ message: "A score has at most ten levels.",
189
251
  path: ["options"],
190
252
  });
191
253
  export const pageStepSchema = z.object({
192
254
  ...stepBase,
193
255
  type: z.literal("page"),
194
256
  fields: z.array(fieldSchema).min(1),
257
+ /** Fields of which a decision shows one: see `group` on a field. */
258
+ groups: z.array(fieldGroupSchema).max(6).optional(),
195
259
  cta: z.string().optional(),
196
260
  });
197
261
  export const agentStepSchema = z.object({
@@ -213,12 +277,24 @@ export const agentStepSchema = z.object({
213
277
  .object({
214
278
  format: z.enum(["text", "markdown", "json"]),
215
279
  fields: z.array(outputFieldSchema).optional(),
280
+ /**
281
+ * json only: the step also composes a view of its result from the surface catalog, bound
282
+ * to its own fields; reviews and the result show that view instead of a plain table.
283
+ */
284
+ surface: z.boolean().optional(),
216
285
  })
217
286
  .default({ format: "markdown" }),
218
287
  /** The kind of model the step needs; default `high`. The model itself is bound elsewhere. */
219
288
  model: z.enum(TEXT_CLASSES).optional(),
220
289
  effort: z.enum(EFFORTS).optional(),
221
290
  /** Shown to the person while it works. */
291
+ /**
292
+ * One run of the step per entry, at most twenty: the rows of a table output
293
+ * ("steps.<id>.<key>"), the entries of a list output, or the rows of a stored list
294
+ * ("lists.<id>"). The instructions read the entry as {{item.<column>}}, {{index}}, {{count}}.
295
+ * The output is a table: the entry's columns and the step's output fields, one row per entry.
296
+ */
297
+ each: z.string().optional(),
222
298
  working: z.string().optional(),
223
299
  });
224
300
  export const generateStepSchema = z.object({
@@ -319,6 +395,48 @@ export const filmStepSchema = z.object({
319
395
  effort: z.enum(EFFORTS).optional(),
320
396
  working: z.string().optional(),
321
397
  });
398
+ /** A piece a decided surface may show: its components (the first has the piece's id) and what it shows. */
399
+ export const surfaceCandidateSchema = z.object({
400
+ id,
401
+ /** What this piece shows, for the decision: "A chart of prices per supplier". */
402
+ description: z.string().min(1),
403
+ components: surfaceComponentsSchema,
404
+ /** Pieces of a group exclude each other: the decision shows one (or none, if optional). */
405
+ group: z.string().optional(),
406
+ /** Always shown: a title, the main list. */
407
+ required: z.boolean().optional(),
408
+ });
409
+ export const surfaceStepSchema = z
410
+ .object({
411
+ ...stepBase,
412
+ type: z.literal("surface"),
413
+ /** A view from the surface catalog, written once like a widget; every run brings new data. */
414
+ components: surfaceComponentsSchema.optional(),
415
+ /**
416
+ * Instead of `components`: pieces a decision picks from per run (no chart when there is
417
+ * nothing to chart), drawn in this order under one column.
418
+ */
419
+ candidates: z.array(surfaceCandidateSchema).max(16).optional(),
420
+ groups: z.array(fieldGroupSchema).max(6).optional(),
421
+ /** What the surface reads as data: key → a field id, steps.id, steps.id.key, lists.id, brand.name or today. */
422
+ data: z.record(z.string(), z.string()).default({}),
423
+ working: z.string().optional(),
424
+ })
425
+ .refine((step) => Boolean(step.components) !== Boolean(step.candidates), {
426
+ message: 'A surface step has either "components" or "candidates".',
427
+ });
428
+ /**
429
+ * The components of a decided surface for the pieces kept: one root column holding them in
430
+ * their order. Every piece kept is the same as all of them for the validator.
431
+ */
432
+ export function assembleSurface(candidates, kept) {
433
+ const keep = new Set(kept);
434
+ const chosen = candidates.filter((c) => keep.has(c.id));
435
+ return [
436
+ { id: "root", component: "Column", children: chosen.map((c) => c.components[0]?.id ?? c.id) },
437
+ ...chosen.flatMap((c) => c.components),
438
+ ];
439
+ }
322
440
  export const reviewStepSchema = z.object({
323
441
  ...stepBase,
324
442
  type: z.literal("review"),
@@ -343,6 +461,7 @@ export const stepSchema = z.discriminatedUnion("type", [
343
461
  agentStepSchema,
344
462
  generateStepSchema,
345
463
  widgetStepSchema,
464
+ surfaceStepSchema,
346
465
  filmStepSchema,
347
466
  reviewStepSchema,
348
467
  resultStepSchema,
@@ -357,7 +476,8 @@ export function isDecisionStep(step) {
357
476
  return (step.model === "classifier" &&
358
477
  step.output.format === "json" &&
359
478
  fields.length > 0 &&
360
- fields.every((f) => f.kind === "yesno" || f.kind === "choice") &&
479
+ fields.every((f) => f.kind === "yesno" || f.kind === "choice" || f.kind === "score") &&
480
+ !step.each &&
361
481
  step.tools.length === 0 &&
362
482
  !step.mcp?.length &&
363
483
  !step.connections?.length);
@@ -444,6 +564,9 @@ export function formatsFor(step) {
444
564
  if (step.type === "widget") {
445
565
  return step.video ? ["mp4", "png"] : ["html", "png", "pdf", "mp4", "json"];
446
566
  }
567
+ if (step.type === "surface") {
568
+ return ["json"];
569
+ }
447
570
  if (step.type === "agent") {
448
571
  if (step.output.format === "json") {
449
572
  return ["json", "csv", "xlsx", "md", "zip"];
@@ -536,7 +659,9 @@ export function validateWizard(def, files) {
536
659
  issues.push({ stepId: step.id, message: `Duplicate field id "${field.id}".` });
537
660
  }
538
661
  fieldIds.add(field.id);
539
- if ((field.kind === "select" || field.kind === "multiselect") && !field.options?.length) {
662
+ if ((field.kind === "select" || field.kind === "multiselect") &&
663
+ !field.options?.length &&
664
+ !field.optionsFrom) {
540
665
  issues.push({ stepId: step.id, message: `Field "${field.id}" needs options.` });
541
666
  }
542
667
  if (field.kind === "items" && !field.columns?.length) {
@@ -614,10 +739,10 @@ export function validateWizard(def, files) {
614
739
  // provided by the runner
615
740
  }
616
741
  else if (head === "item" || head === "index" || head === "count") {
617
- if (step.type !== "generate" || !step.each) {
742
+ if ((step.type !== "generate" && step.type !== "agent") || !step.each) {
618
743
  issues.push({
619
744
  stepId: step.id,
620
- message: `"{{${ref}}}" only exists in a generate step with "each".`,
745
+ message: `"{{${ref}}}" only exists in a generate or agent step with "each".`,
621
746
  });
622
747
  }
623
748
  }
@@ -634,10 +759,134 @@ export function validateWizard(def, files) {
634
759
  }
635
760
  }
636
761
  };
762
+ /**
763
+ * What a condition may read before `step`: an answer asked earlier, an output field of an
764
+ * earlier agent step ("steps.<step>.<field>", the step itself for a branch), or how many rows
765
+ * a stored list has ("lists.<list>.count").
766
+ */
767
+ const knownBefore = (step, ref, self = false) => {
768
+ if (seenFields.has(ref)) {
769
+ return true;
770
+ }
771
+ const [head, from, key, ...more] = ref.split(".");
772
+ if (head === "lists") {
773
+ return key === "count" && more.length === 0 && listIds.has(from ?? "");
774
+ }
775
+ // `steps.<step>.<field>.p`: how probable the decision's answer is.
776
+ const probability = more.length === 1 && more[0] === "p";
777
+ if (head !== "steps" || !key || (more.length > 0 && !probability)) {
778
+ return false;
779
+ }
780
+ if (!(seenSteps.has(from) || (self && from === step.id))) {
781
+ return false;
782
+ }
783
+ const source = def.steps.find((s) => s.id === from);
784
+ return (source?.type === "agent" &&
785
+ (!probability || isDecisionStep(source)) &&
786
+ ((source.output.fields ?? []).some((f) => f.id === key) ||
787
+ (Boolean(source.each) && key === "rows" && !probability)));
788
+ };
789
+ const conditionIssues = (stepId, c) => {
790
+ if ((c.op === "gt" || c.op === "lt") && typeof c.value !== "number") {
791
+ return [{ stepId, message: `"${c.op}" on "${c.field}" compares with a number value.` }];
792
+ }
793
+ if (c.op === "in" && !Array.isArray(c.value)) {
794
+ return [{ stepId, message: `"in" on "${c.field}" needs a list of values.` }];
795
+ }
796
+ if (c.op !== "empty" && c.op !== "notEmpty" && c.value === undefined) {
797
+ return [{ stepId, message: `"${c.op}" on "${c.field}" needs a value.` }];
798
+ }
799
+ return [];
800
+ };
801
+ /** Why an `optionsFrom` reference finds nothing, or null when it is fine. */
802
+ const optionsFromProblem = (raw) => {
803
+ const [head, from, key, column, ...more] = dataRef(raw).split(".");
804
+ if (head === "lists" && from && key && !column) {
805
+ const list = def.lists?.find((l) => l.id === from);
806
+ if (!list) {
807
+ return `"optionsFrom" names an unknown list "${from}".`;
808
+ }
809
+ if (sharedLists.has(from)) {
810
+ return notShown(from);
811
+ }
812
+ return list.columns.some((c) => c.id === key)
813
+ ? null
814
+ : `"optionsFrom": list "${from}" has no column "${key}".`;
815
+ }
816
+ if (head === "steps" && from && key && more.length === 0) {
817
+ const source = seenSteps.has(from) ? def.steps.find((s) => s.id === from) : undefined;
818
+ // An agent step with `each` hands on one table, `rows`: the entry's columns and its fields.
819
+ if (source?.type === "agent" && source.each && key === "rows") {
820
+ return column ? null : `"optionsFrom": "${from}.rows" is a table; name one of its columns.`;
821
+ }
822
+ const out = source?.type === "agent"
823
+ ? (source.output.fields ?? []).find((f) => f.id === key)
824
+ : undefined;
825
+ if (!out) {
826
+ return `"optionsFrom" names "${from}.${key}", which is not an output of an earlier agent step.`;
827
+ }
828
+ if (column) {
829
+ return out.kind === "table" && (!out.columns?.length || out.columns.includes(column))
830
+ ? null
831
+ : `"optionsFrom": "${from}.${key}" is not a table with the column "${column}".`;
832
+ }
833
+ return out.kind === "list" ? null : `"optionsFrom": "${from}.${key}" is not a list.`;
834
+ }
835
+ return `"optionsFrom" is "steps.<id>.<key>", "steps.<id>.<key>.<column>" or "lists.<id>.<column>".`;
836
+ };
637
837
  for (const step of def.steps) {
638
838
  switch (step.type) {
639
- case "page":
839
+ case "page": {
840
+ const groups = new Set();
841
+ for (const g of step.groups ?? []) {
842
+ if (groups.has(g.id)) {
843
+ issues.push({ stepId: step.id, message: `Duplicate field group "${g.id}".` });
844
+ }
845
+ groups.add(g.id);
846
+ if (!step.fields.some((f) => f.group === g.id)) {
847
+ issues.push({ stepId: step.id, message: `Field group "${g.id}" has no fields.` });
848
+ }
849
+ }
850
+ const decided = step.fields.some((f) => f.ask || f.group);
851
+ if (decided && step.fields.length > MAX_DECIDED_FIELDS) {
852
+ issues.push({
853
+ stepId: step.id,
854
+ message: `A page whose fields are decided has at most ${MAX_DECIDED_FIELDS} fields.`,
855
+ });
856
+ }
640
857
  for (const field of step.fields) {
858
+ if (field.ask && field.group) {
859
+ issues.push({
860
+ stepId: step.id,
861
+ message: `Field "${field.id}" has "ask" or "group", not both.`,
862
+ });
863
+ }
864
+ if (field.group && !groups.has(field.group)) {
865
+ issues.push({
866
+ stepId: step.id,
867
+ message: `Field "${field.id}" names an unknown group "${field.group}".`,
868
+ });
869
+ }
870
+ }
871
+ for (const field of step.fields) {
872
+ for (const c of conditionsOf(field.when)) {
873
+ const own = step.fields.some((f) => f.id === c.field && f.id !== field.id);
874
+ if (!own && !knownBefore(step, c.field)) {
875
+ issues.push({
876
+ stepId: step.id,
877
+ message: `Field "${field.id}": "when" reads "${c.field}", which is neither on this page nor known before it.`,
878
+ });
879
+ }
880
+ issues.push(...conditionIssues(step.id, c));
881
+ }
882
+ if (field.optionsFrom) {
883
+ const problem = field.kind !== "select" && field.kind !== "multiselect"
884
+ ? `"optionsFrom" fills the choices of a select or multiselect; kind is "${field.kind}".`
885
+ : optionsFromProblem(field.optionsFrom);
886
+ if (problem) {
887
+ issues.push({ stepId: step.id, message: `Field "${field.id}": ${problem}` });
888
+ }
889
+ }
641
890
  if (field.prefill) {
642
891
  const ref = dataRef(field.prefill);
643
892
  if (ref.split(".")[0] !== "steps") {
@@ -653,6 +902,7 @@ export function validateWizard(def, files) {
653
902
  seenFields.add(field.id);
654
903
  }
655
904
  break;
905
+ }
656
906
  case "agent":
657
907
  checkTemplate(step, step.instructions);
658
908
  for (const c of step.connections ?? []) {
@@ -660,6 +910,30 @@ export function validateWizard(def, files) {
660
910
  issues.push({ stepId: step.id, message: `Step uses unknown connection "${c}".` });
661
911
  }
662
912
  }
913
+ if (step.output.surface && step.output.format !== "json") {
914
+ issues.push({
915
+ stepId: step.id,
916
+ message: 'A step composes a view ("surface") of json output only.',
917
+ });
918
+ }
919
+ if (step.each) {
920
+ const ref = dataRef(step.each);
921
+ const list = ref.match(/^lists\.([a-zA-Z][a-zA-Z0-9_]*)$/)?.[1];
922
+ if (list) {
923
+ if (!listIds.has(list)) {
924
+ issues.push({ stepId: step.id, message: `each names an unknown list "${list}".` });
925
+ }
926
+ }
927
+ else if (ref.startsWith("steps.") && ref.split(".").length === 3) {
928
+ checkTemplate(step, `{{${ref}}}`);
929
+ }
930
+ else {
931
+ issues.push({
932
+ stepId: step.id,
933
+ message: `each "${step.each}" is "steps.<id>.<key>" (a table or list output) or "lists.<id>".`,
934
+ });
935
+ }
936
+ }
663
937
  break;
664
938
  case "generate":
665
939
  checkTemplate(step, step.prompt);
@@ -711,6 +985,49 @@ export function validateWizard(def, files) {
711
985
  });
712
986
  }
713
987
  break;
988
+ case "surface": {
989
+ for (const [key, ref] of Object.entries(step.data)) {
990
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
991
+ issues.push({
992
+ stepId: step.id,
993
+ message: `Surface data key "${key}" is not an identifier.`,
994
+ });
995
+ }
996
+ const list = listRef(dataRef(ref));
997
+ if (list) {
998
+ if (!listIds.has(list)) {
999
+ issues.push({
1000
+ stepId: step.id,
1001
+ message: `Surface data reads an unknown list "${list}".`,
1002
+ });
1003
+ }
1004
+ }
1005
+ else {
1006
+ checkTemplate(step, `{{${dataRef(ref)}}}`);
1007
+ }
1008
+ }
1009
+ const groups = new Set((step.groups ?? []).map((g) => g.id));
1010
+ for (const c of step.candidates ?? []) {
1011
+ if (c.components[0]?.id !== c.id) {
1012
+ issues.push({
1013
+ stepId: step.id,
1014
+ message: `Surface piece "${c.id}": its first component carries the piece's id.`,
1015
+ });
1016
+ }
1017
+ if (c.group && !groups.has(c.group)) {
1018
+ issues.push({
1019
+ stepId: step.id,
1020
+ message: `Surface piece "${c.id}" names an unknown group "${c.group}".`,
1021
+ });
1022
+ }
1023
+ }
1024
+ const components = step.components ??
1025
+ assembleSurface(step.candidates ?? [], (step.candidates ?? []).map((c) => c.id));
1026
+ for (const issue of validateSurface(components, Object.keys(step.data))) {
1027
+ issues.push({ stepId: step.id, message: `Surface: ${issue.message}` });
1028
+ }
1029
+ break;
1030
+ }
714
1031
  case "film":
715
1032
  checkTemplate(step, step.brief);
716
1033
  for (const [name, ref] of Object.entries(step.inputs)) {
@@ -778,23 +1095,23 @@ export function validateWizard(def, files) {
778
1095
  }
779
1096
  break;
780
1097
  }
1098
+ if ((step.next ?? []).filter(isOtherwise).length > 1) {
1099
+ issues.push({
1100
+ stepId: step.id,
1101
+ message: 'A step has at most one "otherwise" branch (one without "when" and "ask").',
1102
+ });
1103
+ }
781
1104
  for (const rule of step.next ?? []) {
782
1105
  if (rule.goto !== "end" && !stepIds.has(rule.goto)) {
783
1106
  issues.push({ stepId: step.id, message: `Branch goes to unknown step "${rule.goto}".` });
784
1107
  }
785
- // A branch reads what the person answered, or an output field of this or an earlier
786
- // agent step as `steps.<step>.<field>`.
787
- const [head, from, key, ...more] = rule.when.field.split(".");
788
- const source = head === "steps" && key && more.length === 0 && (from === step.id || seenSteps.has(from))
789
- ? def.steps.find((s) => s.id === from)
790
- : undefined;
791
- const known = fieldIds.has(rule.when.field) ||
792
- (source?.type === "agent" && (source.output.fields ?? []).some((f) => f.id === key));
793
- if (!known) {
794
- issues.push({
795
- stepId: step.id,
796
- message: `Branch reads unknown field "${rule.when.field}".`,
797
- });
1108
+ // A branch reads what the person answered (on any page), an output field of this or an
1109
+ // earlier agent step, or the row count of a stored list.
1110
+ for (const c of conditionsOf(rule.when)) {
1111
+ if (!fieldIds.has(c.field) && !knownBefore(step, c.field, true)) {
1112
+ issues.push({ stepId: step.id, message: `Branch reads unknown field "${c.field}".` });
1113
+ }
1114
+ issues.push(...conditionIssues(step.id, c));
798
1115
  }
799
1116
  }
800
1117
  seenSteps.add(step.id);
@@ -817,48 +1134,204 @@ export function parseWizard(input, files) {
817
1134
  * What branch rules can read in a run: the person's answers by field id, and the structured
818
1135
  * output of the steps run so far as `steps.<step>.<field>`.
819
1136
  */
820
- export function branchValues(values, outputs) {
1137
+ export function branchValues(values, outputs,
1138
+ /** Rows per stored list, read as `lists.<list>.count`. */
1139
+ listCounts = {}) {
821
1140
  const out = { ...values };
1141
+ for (const [list, count] of Object.entries(listCounts)) {
1142
+ out[`lists.${list}.count`] = count;
1143
+ }
822
1144
  for (const [stepId, output] of Object.entries(outputs)) {
823
1145
  if (output.json && typeof output.json === "object" && !Array.isArray(output.json)) {
824
1146
  for (const [key, value] of Object.entries(output.json)) {
825
1147
  out[`steps.${stepId}.${key}`] = value;
826
1148
  }
827
1149
  }
1150
+ for (const [key, p] of Object.entries(output.decided ?? {})) {
1151
+ out[`steps.${stepId}.${key}.p`] = p;
1152
+ }
828
1153
  }
829
1154
  return out;
830
1155
  }
831
- /** The step after `step`, honouring its branch rules. `null` = the end. */
832
- export function nextStepId(def, stepId, values) {
1156
+ /** A branch back to an earlier step without `max` is taken at most this often in one run. */
1157
+ export const DEFAULT_LOOP_MAX = 10;
1158
+ /** How a run counts the times it took a branch: "<step>→<goto>". */
1159
+ export function loopKey(stepId, goto) {
1160
+ return `${stepId}→${goto}`;
1161
+ }
1162
+ /** Whether a rule may still send the run on: one with `max`, or one back, stops after so many. */
1163
+ export function ruleOpen(def, stepId, rule, loops = {}) {
1164
+ const from = def.steps.findIndex((s) => s.id === stepId);
1165
+ const to = rule.goto === "end" ? def.steps.length : def.steps.findIndex((s) => s.id === rule.goto);
1166
+ const max = rule.max ?? (to >= 0 && to <= from ? DEFAULT_LOOP_MAX : Number.POSITIVE_INFINITY);
1167
+ return (loops[loopKey(stepId, rule.goto)] ?? 0) < max;
1168
+ }
1169
+ export function followRules(def, stepId, values, loops = {}) {
833
1170
  const index = def.steps.findIndex((s) => s.id === stepId);
834
1171
  const step = def.steps[index];
835
1172
  if (!step) {
836
- return null;
1173
+ return { kind: "go", cursor: null, rule: null };
837
1174
  }
838
- for (const rule of step.next ?? []) {
839
- if (ruleMatches(rule, values)) {
840
- return rule.goto === "end" ? null : rule.goto;
1175
+ const rules = step.next ?? [];
1176
+ for (const [i, rule] of rules.entries()) {
1177
+ if (ruleOpen(def, stepId, rule, loops) && ruleMatches(rule, values)) {
1178
+ return { kind: "go", cursor: rule.goto === "end" ? null : rule.goto, rule: i };
841
1179
  }
842
1180
  }
1181
+ const asks = rules.flatMap((rule, i) => rule.ask && ruleOpen(def, stepId, rule, loops) ? [i] : []);
1182
+ if (asks.length) {
1183
+ return { kind: "decide", rules: asks };
1184
+ }
1185
+ return { kind: "go", ...otherwiseOf(def, stepId, loops) };
1186
+ }
1187
+ /** A rule with neither a condition nor a statement: where the run goes when no other rule holds. */
1188
+ export function isOtherwise(rule) {
1189
+ return rule.when === undefined && rule.ask === undefined;
1190
+ }
1191
+ /**
1192
+ * Where a step goes when none of its rules holds: its "otherwise" rule (`rule` is its index in
1193
+ * `next`), else the next step in the list. `cursor` null = the end.
1194
+ */
1195
+ export function otherwiseOf(def, stepId, loops = {}) {
1196
+ const index = def.steps.findIndex((s) => s.id === stepId);
1197
+ const rules = def.steps[index]?.next ?? [];
1198
+ const i = rules.findIndex((r) => isOtherwise(r) && ruleOpen(def, stepId, r, loops));
1199
+ if (i >= 0) {
1200
+ return { cursor: rules[i].goto === "end" ? null : rules[i].goto, rule: i };
1201
+ }
1202
+ return { cursor: def.steps[index + 1]?.id ?? null, rule: null };
1203
+ }
1204
+ /** The step after `step` by its `when` rules alone. `null` = the end. */
1205
+ export function nextStepId(def, stepId, values) {
1206
+ const outcome = followRules(def, stepId, values);
1207
+ if (outcome.kind === "go") {
1208
+ return outcome.cursor;
1209
+ }
1210
+ const index = def.steps.findIndex((s) => s.id === stepId);
843
1211
  return def.steps[index + 1]?.id ?? null;
844
1212
  }
845
1213
  function isEmptyValue(v) {
846
1214
  return v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
847
1215
  }
848
- export function ruleMatches(rule, values) {
849
- const v = values[rule.when.field];
850
- switch (rule.when.op) {
1216
+ /** The conditions of a rule or a field, as a list: one, several, or none. */
1217
+ export function conditionsOf(when) {
1218
+ return when === undefined ? [] : Array.isArray(when) ? when : [when];
1219
+ }
1220
+ /** A typed number as a number: "89,90" from a German number pad counts as 89.9. */
1221
+ function numberOf(v) {
1222
+ if (typeof v === "number") {
1223
+ return Number.isFinite(v) ? v : null;
1224
+ }
1225
+ if (typeof v !== "string" || !v.trim()) {
1226
+ return null;
1227
+ }
1228
+ const n = Number(v.replace(/\s/g, "").replace(",", "."));
1229
+ return Number.isFinite(n) ? n : null;
1230
+ }
1231
+ export function conditionMatches(c, values) {
1232
+ const v = values[c.field];
1233
+ switch (c.op) {
851
1234
  case "empty":
852
1235
  return isEmptyValue(v);
853
1236
  case "notEmpty":
854
1237
  return !isEmptyValue(v);
855
1238
  case "equals":
856
- return String(v) === String(rule.when.value);
1239
+ return String(v) === String(c.value);
857
1240
  case "notEquals":
858
- return String(v) !== String(rule.when.value);
1241
+ return String(v) !== String(c.value);
859
1242
  case "in":
860
- return Array.isArray(rule.when.value) && rule.when.value.map(String).includes(String(v));
1243
+ return Array.isArray(c.value) && c.value.map(String).includes(String(v));
1244
+ case "gt":
1245
+ case "lt": {
1246
+ const a = numberOf(v);
1247
+ const b = numberOf(c.value);
1248
+ return a !== null && b !== null && (c.op === "gt" ? a > b : a < b);
1249
+ }
1250
+ case "contains":
1251
+ if (Array.isArray(v)) {
1252
+ return v.map(String).includes(String(c.value));
1253
+ }
1254
+ return (typeof v === "string" &&
1255
+ c.value !== undefined &&
1256
+ v.toLowerCase().includes(String(c.value).toLowerCase()));
1257
+ }
1258
+ }
1259
+ /** Whether every condition holds; no conditions hold trivially. */
1260
+ export function whenHolds(when, values) {
1261
+ return conditionsOf(when).every((c) => conditionMatches(c, values));
1262
+ }
1263
+ /** Whether a `when` rule holds. An `ask` rule never holds here: a decision takes it (see decide). */
1264
+ export function ruleMatches(rule, values) {
1265
+ return rule.when !== undefined && whenHolds(rule.when, values);
1266
+ }
1267
+ /** A page with decided fields has at most this many; it shows at most MAX_SHOWN_FIELDS of them. */
1268
+ export const MAX_DECIDED_FIELDS = 12;
1269
+ export const MAX_SHOWN_FIELDS = 5;
1270
+ /** Whether a field is shown only when a decision keeps it. */
1271
+ export function isDecidedField(field) {
1272
+ return Boolean(field.ask || field.group);
1273
+ }
1274
+ /**
1275
+ * The fields of a page that are shown: those whose `when` holds on what was known before the
1276
+ * page (`known`) and what the page holds so far (`page`). A hidden field's value does not count
1277
+ * for the fields after it.
1278
+ */
1279
+ export function shownFields(fields, known, page) {
1280
+ const values = { ...known, ...page };
1281
+ const out = [];
1282
+ for (const field of fields) {
1283
+ if (whenHolds(field.when, values)) {
1284
+ out.push(field);
1285
+ }
1286
+ else {
1287
+ delete values[field.id];
1288
+ }
1289
+ }
1290
+ return out;
1291
+ }
1292
+ /** The references the `when` of a page's fields read outside the page: what the page must know. */
1293
+ export function pageNeeds(fields) {
1294
+ const own = new Set(fields.map((f) => f.id));
1295
+ return [
1296
+ ...new Set(fields.flatMap((f) => conditionsOf(f.when).map((c) => c.field)).filter((r) => !own.has(r))),
1297
+ ];
1298
+ }
1299
+ /** At most this many choices come from data. */
1300
+ export const MAX_DATA_OPTIONS = 100;
1301
+ /**
1302
+ * The choices an `optionsFrom` reference finds in a run: the entries of a list output, one
1303
+ * column of a table output, or one column of a stored list — as text, without duplicates and
1304
+ * empties. Empty when the data is not there (yet).
1305
+ */
1306
+ export function optionsFromData(ref, outputs, lists = {}) {
1307
+ const [head, id, key, column, ...more] = dataRef(ref).split(".");
1308
+ let raw = [];
1309
+ if (head === "lists" && id && key && !column) {
1310
+ raw = (lists[id]?.rows ?? []).map((r) => r.cells[key]);
1311
+ }
1312
+ else if (head === "steps" && id && key && more.length === 0) {
1313
+ const json = outputs[id]?.json;
1314
+ const value = json && typeof json === "object" ? json[key] : undefined;
1315
+ if (Array.isArray(value)) {
1316
+ raw = column
1317
+ ? value.map((row) => row && typeof row === "object" ? row[column] : undefined)
1318
+ : value;
1319
+ }
1320
+ }
1321
+ const seen = new Set();
1322
+ for (const v of raw) {
1323
+ if (v === null || v === undefined || typeof v === "object") {
1324
+ continue;
1325
+ }
1326
+ const text = String(v).trim().slice(0, 200);
1327
+ if (text) {
1328
+ seen.add(text);
1329
+ }
1330
+ if (seen.size >= MAX_DATA_OPTIONS) {
1331
+ break;
1332
+ }
861
1333
  }
1334
+ return [...seen];
862
1335
  }
863
1336
  export function itemsTotals(field, value, values) {
864
1337
  const rows = Array.isArray(value) ? value : [];