eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2
3// πŸ€– The Swift twin of web/static/flow.js: the interpreter for the
4// conversation scripts (docs/conversation-scripts.md) β€” the JSON documents
5// that ARE Eleven's onboarding conversations (setup.flow.js, welcome.flow.js).
6// The docs promised "the document the server will ship to native
7// interpreters"; this is that interpreter. Byte-identical copies of the flow
8// data are bundled in Flows/ (drift-gated by test/flow.test.mjs), and the two
9// engines are held together by mirrored tests (FlowEngineTests ↔
10// test/flow.test.mjs) β€” change semantics on one side and the other changes in
11// the same PR.
12//
13// Same shape as flow.js: a pure-data walker over steps (entries, skipIf,
14// bubbles, chips, inputs, effects with result branching, settle polls), with
15// ALL side effects behind the io surface β€” say, ask, verbs, records, delays.
16// The engine never touches the network, crypto, or UI.
17
18// MARK: - FlowJSON: a minimal dynamic JSON value for the flow documents
19
20public indirect enum FlowJSON: Equatable, Sendable {
21 case null
22 case bool(Bool)
23 case number(Double)
24 case string(String)
25 case array([FlowJSON])
26 case object([String: FlowJSON])
27
28 public init(any: Any?) {
29 switch any {
30 case nil, is NSNull: self = .null
31 case let n as NSNumber:
32 if CFGetTypeID(n as CFTypeRef) == CFBooleanGetTypeID() { self = .bool(n.boolValue) }
33 else { self = .number(n.doubleValue) }
34 case let s as String: self = .string(s)
35 case let a as [Any]: self = .array(a.map { FlowJSON(any: $0) })
36 case let o as [String: Any]: self = .object(o.mapValues { FlowJSON(any: $0) })
37 default: self = .null
38 }
39 }
40
41 public static func parse(_ data: Data) -> FlowJSON? {
42 guard let any = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) else { return nil }
43 return FlowJSON(any: any)
44 }
45
46 public subscript(key: String) -> FlowJSON {
47 if case .object(let o) = self { return o[key] ?? .null }
48 return .null
49 }
50
51 public var arrayValue: [FlowJSON] {
52 if case .array(let a) = self { return a }
53 return []
54 }
55
56 public var stringValue: String? {
57 if case .string(let s) = self { return s }
58 return nil
59 }
60
61 public var isNull: Bool { self == .null }
62
63 var truthy: Bool {
64 switch self {
65 case .null: return false
66 case .bool(let b): return b
67 case .number(let n): return n != 0 && !n.isNaN
68 case .string(let s): return !s.isEmpty
69 case .array(let a): return !a.isEmpty
70 case .object: return true
71 }
72 }
73
74 var display: String {
75 switch self {
76 case .string(let s): return s
77 case .number(let n):
78 if n.isFinite, n == n.rounded(), abs(n) < 9e15 { return String(Int64(n)) }
79 return String(n)
80 case .bool(let b): return b ? "true" : "false"
81 default: return ""
82 }
83 }
84}
85
86// MARK: - the environment
87
88// The flow's env: template/condition state plus the bridge closures. Dotted
89// paths resolve exactly as flow.js does β€” "done"/"done.X" through the io,
90// "vars.*" and "space.*" into their dictionaries, "bridge.*" through closures
91// (called), everything else through `flags` ("native", "suggested",
92// "inviterName", …; closures allowed for live reads like accessBackedUp).
93@MainActor
94public final class FlowEnv {
95 public var vars: [String: FlowJSON] = [:]
96 public var space: [String: FlowJSON] = [:]
97 public var flags: [String: FlowJSON] = [:]
98 /// Live lookups (bridge.myName, accessBackedUp, …) β€” resolved per read.
99 public var live: [String: () -> FlowJSON] = [:]
100 var ack = ""
101 var resumed = false
102 /// Set by the engine on entry to each step ("done" resolves through it).
103 var currentStepDone: () -> Bool = { false }
104 var doneOf: (String) -> Bool = { _ in false }
105 public var onEnd: () -> Void = {}
106
107 public init() {}
108
109 func resolve(_ path: String) -> FlowJSON {
110 if path == "done" { return .bool(currentStepDone()) }
111 if path.hasPrefix("done.") { return .bool(doneOf(String(path.dropFirst(5)))) }
112 if path == "resumed" { return .bool(resumed) }
113 let parts = path.split(separator: ".").map(String.init)
114 guard let first = parts.first else { return .null }
115 var cur: FlowJSON
116 if first == "vars" {
117 cur = .object(vars)
118 } else if first == "space" {
119 cur = .object(space)
120 } else if first == "bridge", parts.count > 1, let fn = live["bridge." + parts[1]] {
121 return fn()
122 } else if let fn = live[path] {
123 return fn()
124 } else if let fn = live[first] {
125 cur = fn()
126 for p in parts.dropFirst() { cur = cur[p] }
127 return cur
128 } else if let v = flags[first] {
129 cur = v
130 } else {
131 return .null
132 }
133 for p in parts.dropFirst() { cur = cur[p] }
134 return cur
135 }
136}
137
138// MARK: - input.confirm: an answer that reads as something else
139
140// A name step accepts anything β€” which is right (people are called all sorts)
141// but means an email address typed into "what should everyone here call you?"
142// sails through and becomes the name the whole space sees. `input.confirm`
143// asks first: nothing is recorded and no effect runs until the person says
144// yes, and the flow author writes the confirming step like any other.
145// Whole-string only, so "Ada @ the shop" stays a name.
146// Twin: looksLikeEmail in web/static/flow.js.
147func looksLikeEmail(_ raw: String) -> Bool {
148 let s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
149 guard let at = s.firstIndex(of: "@"), at != s.startIndex else { return false }
150 let local = s[s.startIndex..<at]
151 let domain = s[s.index(after: at)...]
152 guard !domain.isEmpty, !local.contains(" "), !domain.contains(" "), !domain.contains("@") else { return false }
153 guard let dot = domain.lastIndex(of: "."), dot != domain.startIndex else { return false }
154 return domain[domain.index(after: dot)...].count >= 2
155}
156
157// confirmNeeded: does this answer trip the step's confirm rule? An unknown
158// `when` never fires β€” a flow document from a newer build must degrade to
159// today's behaviour, not to a step nobody can leave.
160func confirmNeeded(_ confirm: FlowJSON, answer: String) -> Bool {
161 guard let target = confirm["goto"].stringValue, !target.isEmpty else { return false }
162 return confirm["when"].stringValue == "email" && looksLikeEmail(answer)
163}
164
165// MARK: - the name-step answer cleaner (input.clean == "name")
166
167// People answer a conversational "what should folks here call you?"
168// conversationally β€” "I'm Dermot", "hi, my name is Ana." β€” and the raw reply
169// used to become their screen name verbatim (a real member spent days as
170// "I'm Dermot"). Strip one leading greeting, one leading preamble, and any
171// trailing punctuation; if stripping eats everything, keep the trimmed
172// original β€” never clean a name to nothing. Prefix lists are matched on a
173// lowercased, straight-apostrophe copy (iOS keyboards type ’), longest
174// preamble first so "i'm called" wins over "i'm".
175// Twin: cleanNameAnswer in web/static/flow.js.
176private let nameGreetings = ["hiya", "howdy", "hello", "hey", "hi"]
177private let namePreambles = [
178 "you can call me", "everyone calls me", "people call me", "they call me",
179 "i'm called", "i am called", "my name is", "my name's", "the name's",
180 "name is", "name's", "call me", "i am", "i'm", "im", "it's", "its",
181]
182
183func cleanNameAnswer(_ raw: String) -> String {
184 let original = raw.trimmingCharacters(in: .whitespacesAndNewlines)
185 var s = original
186 func lower() -> String { s.lowercased().replacingOccurrences(of: "\u{2019}", with: "'") }
187 for g in nameGreetings {
188 guard lower().hasPrefix(g) else { continue }
189 // The greeting must end at punctuation-or-space ("hi," yes, "hilary" no).
190 var i = s.index(s.startIndex, offsetBy: g.count)
191 if i < s.endIndex, ",.!".contains(s[i]) { i = s.index(after: i) }
192 if i < s.endIndex, s[i] == " " {
193 s = String(s[s.index(after: i)...]).trimmingCharacters(in: .whitespaces)
194 }
195 break
196 }
197 let l = lower()
198 for p in namePreambles where l.hasPrefix(p + " ") {
199 s = String(s.dropFirst(p.count + 1)).trimmingCharacters(in: .whitespaces)
200 break
201 }
202 while let last = s.last, ".!,;:".contains(last) { s.removeLast() }
203 s = s.trimmingCharacters(in: .whitespaces)
204 return s.isEmpty ? original : s
205}
206
207// MARK: - the io surface
208
209/// One tappable chip on a bubble. `spentLabel` is its past-tense form once
210/// tapped; `isStale` ties liveness to the step (finish-line chips stay live).
211/// `trailing` right-aligns the chip β€” the quiet alternative path (the quick
212/// form) sits apart from the step's primary answers.
213@MainActor
214public final class FlowChip: Identifiable {
215 public let id = UUID()
216 public let label: String
217 public let spentLabel: String?
218 public let trailing: Bool
219 public let isStale: () -> Bool
220 public let run: () -> Void
221 init(label: String, spentLabel: String?, trailing: Bool = false, isStale: @escaping () -> Bool, run: @escaping () -> Void) {
222 self.label = label
223 self.spentLabel = spentLabel
224 self.trailing = trailing
225 self.isStale = isStale
226 self.run = run
227 }
228}
229
230/// Extra presentation riding a said line (mirrors makeSay's extra).
231public struct FlowSayExtra: Sendable {
232 public var key: String?
233 public var hint: String?
234 public var attachHint: String?
235 /// The composer's inset field label ("Your name:") β€” the "To:" pattern.
236 public var label: String?
237 public var local = false
238 public var imgToken: String?
239 public var imgAlt = ""
240 public var imgRound = false
241 public init() {}
242}
243
244/// A composer answer: the typed text plus any image attachment (photo steps).
245public struct FlowAnswer: Sendable {
246 public var text: String
247 public var imageAttachment: [String: String]? // {id, type} when an image rode along
248 public init(text: String, imageAttachment: [String: String]? = nil) {
249 self.text = text
250 self.imageAttachment = imageAttachment
251 }
252}
253
254/// What the engine needs from its host β€” the exact io contract flow.js takes.
255/// All methods are MainActor; say/verb/record are async (pacing, network).
256@MainActor
257public protocol FlowIO: AnyObject {
258 /// Speak one line (persist unless extra.local), with optional chips.
259 func say(_ text: String, chips: [FlowChip]?, extra: FlowSayExtra) async
260 /// Arm the one-shot answer waiter: the next composer send is the answer.
261 func ask(_ handler: @escaping (FlowAnswer) -> Void)
262 func cancelAnswer()
263 /// Run a bridge verb; nil/true β†’ "ok", false β†’ "error", string verbatim.
264 func verb(_ name: String, args: [FlowJSON]) async -> FlowJSON
265 /// Persist a completed step (the βœ“ line): the app's `.step` event.
266 func record(step: String, label: String, value: String) async
267 /// Whether a step already recorded (folded from the thread's history).
268 func done(_ step: String) -> Bool
269 /// Schedule fn after ms (settle polls, chip probes).
270 func delay(ms: Double, _ fn: @escaping () -> Void)
271}
272
273// MARK: - the engine
274
275@MainActor
276public final class FlowEngine {
277 let flow: FlowJSON
278 let env: FlowEnv
279 weak var ioRef: AnyObject?
280 let io: FlowIO
281 private var steps: [String: (step: FlowJSON, next: String?)] = [:]
282 private var currentGo = OnceStep()
283
284 public init(flow: FlowJSON, env: FlowEnv, io: FlowIO) {
285 self.flow = flow
286 self.env = env
287 self.io = io
288 let list = flow["steps"].arrayValue
289 for (i, s) in list.enumerated() {
290 guard let id = s["id"].stringValue else { continue }
291 let next = i + 1 < list.count ? list[i + 1]["id"].stringValue : nil
292 steps[id] = (s, next)
293 }
294 env.doneOf = { [weak self] id in self?.io.done(id) ?? false }
295 }
296
297 // One-tap-wins step guard (flow.js onceStep).
298 final class OnceStep {
299 var taken = false
300 func callAsFunction(_ fn: () -> Void) {
301 if taken { return }
302 taken = true
303 fn()
304 }
305 }
306
307 // MARK: conditions & templates (mirrors evalCond/template/renderText)
308
309 public func evalCond(_ expr: FlowJSON) -> Bool {
310 switch expr {
311 case .null: return true
312 case .bool(let b): return b
313 case .object(let o):
314 if let all = o["all"] { return all.arrayValue.allSatisfy { evalCond($0) } }
315 if let any = o["any"] { return any.arrayValue.contains { evalCond($0) } }
316 return false
317 case .string(let s):
318 let neg = s.hasPrefix("!")
319 let v = env.resolve(neg ? String(s.dropFirst()) : s)
320 return neg ? !v.truthy : v.truthy
321 default:
322 return false
323 }
324 }
325
326 public func template(_ s: String) -> String {
327 var out = ""
328 var i = s.startIndex
329 while i < s.endIndex {
330 if s[i] == "{" , let close = s[i...].firstIndex(of: "}") {
331 let body = String(s[s.index(after: i)..<close])
332 if body == "ack?" {
333 let a = env.ack
334 env.ack = ""
335 out += a.isEmpty ? "" : a + "\n\n"
336 } else {
337 let parts = body.split(separator: "|", maxSplits: 1).map(String.init)
338 let v = env.resolve(parts[0].trimmingCharacters(in: .whitespaces))
339 if v.isNull || v.display.isEmpty {
340 out += parts.count > 1 ? parts[1] : ""
341 } else {
342 out += v.display
343 }
344 }
345 i = s.index(after: close)
346 } else {
347 out.append(s[i])
348 i = s.index(after: i)
349 }
350 }
351 return out
352 }
353
354 public func renderText(_ t: FlowJSON) -> String {
355 switch t {
356 case .string(let s): return template(s)
357 case .array(let segs):
358 return segs.map { seg -> String in
359 if let str = seg.stringValue { return template(str) }
360 if !seg["if"].isNull || !seg["text"].isNull {
361 return evalCond(seg["if"]) ? template(seg["text"].stringValue ?? "") : ""
362 }
363 return ""
364 }.joined()
365 case .object(let o):
366 if o["if"] != nil {
367 let branch = evalCond(o["if"] ?? .null) ? o["then"] : o["else"]
368 return template(branch?.stringValue ?? "")
369 }
370 return ""
371 default:
372 return ""
373 }
374 }
375
376 // "$answer"/"$answerText" read captured input; anything else templates.
377 func valueOf(_ v: FlowJSON) -> FlowJSON {
378 if let s = v.stringValue {
379 if s == "$answer" { return env.vars["answer"] ?? .null }
380 if s == "$answerText" { return env.vars["answerText"] ?? .null }
381 return .string(template(s))
382 }
383 return v
384 }
385
386 func setPaths(_ sets: FlowJSON) {
387 for (path, value) in sets.arrayPairs {
388 let parts = path.split(separator: ".").map(String.init)
389 let resolved: FlowJSON = value.stringValue != nil ? valueOf(value) : value
390 if parts.first == "vars", parts.count == 2 {
391 env.vars[parts[1]] = resolved
392 } else if parts.count == 1 {
393 env.flags[parts[0]] = resolved
394 }
395 }
396 }
397
398 // Map a verb result onto a branch key (flow.js resultKey).
399 static func resultKey(_ r: FlowJSON) -> String {
400 if r.isNull { return "ok" }
401 if case .bool(let b) = r { return b ? "ok" : "error" }
402 return r.display
403 }
404
405 // MARK: run
406
407 public func start() {
408 env.resumed = evalCond(flow["resumedIf"].isNull ? .bool(false) : flow["resumedIf"])
409 // Strong self: the initial walk must survive even before the owner
410 // stores the engine (asks/chips re-anchor through the owner after).
411 if env.resumed,
412 !(flow["resumeUnless"] != .null && evalCond(flow["resumeUnless"])),
413 let line = flow["resumeSay"].stringValue {
414 Task {
415 var extra = FlowSayExtra()
416 extra.local = true
417 await self.io.say(line, chips: nil, extra: extra)
418 await self.enter(self.flow["entry"].stringValue ?? "")
419 }
420 } else {
421 Task {
422 await self.enter(self.flow["entry"].stringValue ?? "")
423 }
424 }
425 }
426
427 // Actions shared by chips, `on` branches, and entries. True = we moved.
428 @discardableResult
429 func runActions(_ a: FlowJSON, stepId: String) async -> Bool {
430 if a.isNull { return false }
431 if !a["set"].isNull { setPaths(a["set"]) }
432 if !a["record"].isNull {
433 let rec = a["record"]
434 let step = rec["step"].stringValue ?? stepId
435 let value = rec["value"].isNull ? "" : valueOf(rec["value"]).display
436 await io.record(step: step, label: renderText(rec["label"]), value: value)
437 }
438 if let ack = a["ack"].stringValue { env.ack = renderText(.string(ack)) }
439 if let sayText = a["say"].stringValue {
440 var extra = FlowSayExtra()
441 extra.local = a["local"].truthy
442 await io.say(renderText(.string(sayText)), chips: nil, extra: extra)
443 }
444 if !a["ask"].isNull {
445 let text = a["ask"].stringValue ?? a["ask"]["text"].stringValue ?? ""
446 await askIn(stepId: stepId, text: renderText(.string(text)))
447 return true
448 }
449 if !a["gotoAsk"].isNull {
450 let target = a["gotoAsk"]["step"].stringValue ?? stepId
451 await askIn(stepId: target, text: renderText(.string(a["gotoAsk"]["text"].stringValue ?? "")))
452 return true
453 }
454 if a["retry"].stringValue == "step" || a["retry"].truthy {
455 await enter(stepId)
456 return true
457 }
458 if let goto = a["goto"].stringValue {
459 await enter(goto)
460 return true
461 }
462 return false
463 }
464
465 func runEffect(stepId: String, effect: FlowJSON, argsOverride: [FlowJSON]? = nil) async {
466 guard let step = steps[stepId]?.step else { return }
467 let args = argsOverride ?? effect["args"].arrayValue.map { valueOf($0) }
468 let r = await io.verb(effect["verb"].stringValue ?? "", args: args)
469 let branch = step["on"][Self.resultKey(r)]
470 if branch.isNull { return } // fire-and-forget verb
471 let moved = await runActions(branch, stepId: stepId)
472 if !moved { armInput(stepId: stepId) } // rejected input re-asks in place
473 }
474
475 func buildChips(stepId: String, go: OnceStep) -> [FlowChip]? {
476 guard let step = steps[stepId]?.step else { return nil }
477 let chips = step["chips"].arrayValue.filter { evalCond($0["if"]) }.map { c -> FlowChip in
478 let advancing = !(c["advancing"] == .bool(false))
479 let hasProbe = !c["probe"].isNull
480 return FlowChip(
481 label: renderText(c["label"]),
482 spentLabel: c["spent"].stringValue,
483 trailing: c["trailing"].truthy,
484 isStale: (!advancing && !hasProbe) ? { false } : { go.taken },
485 run: { [weak self] in
486 guard let self else { return }
487 let act: () -> Void = {
488 Task { @MainActor in
489 if c["cancelInput"].truthy { self.io.cancelAnswer() }
490 if !c["set"].isNull { self.setPaths(c["set"]) }
491 if !c["effect"].isNull {
492 _ = await self.io.verb(c["effect"]["verb"].stringValue ?? "",
493 args: c["effect"]["args"].arrayValue.map { self.valueOf($0) })
494 }
495 await self.runActions(c, stepId: stepId)
496 }
497 }
498 if !advancing {
499 act()
500 if hasProbe {
501 let after = c["probe"]["afterMs"].isNull ? 1500 : c["probe"]["afterMs"].numberValue
502 self.io.delay(ms: after) { [weak self] in
503 guard let self else { return }
504 if !self.evalCond(c["probe"]["unless"]) {
505 go { Task { await self.enter(c["probe"]["goto"].stringValue ?? stepId) } }
506 }
507 }
508 }
509 } else {
510 go(act)
511 }
512 })
513 }
514 return chips.isEmpty ? nil : chips
515 }
516
517 func armInput(stepId: String) {
518 guard let step = steps[stepId]?.step, !step["input"].isNull else { return }
519 let input = step["input"]
520 let go = currentGo
521 if input["kind"].stringValue == "text" {
522 io.ask { [weak self] answer in
523 guard let self else { return }
524 go {
525 Task { @MainActor in
526 var text = answer.text.trimmingCharacters(in: .whitespacesAndNewlines)
527 // Clean BEFORE the cap so a chatty preamble can't eat
528 // into it ("my name is " + 25 chars keeps the name).
529 if input["clean"].stringValue == "name" { text = cleanNameAnswer(text) }
530 if case .number(let maxLen) = input["maxLen"] { text = String(text.prefix(Int(maxLen))) }
531 self.env.vars["answer"] = .string(text)
532 if input["required"].truthy && text.isEmpty {
533 await self.enter(stepId)
534 return
535 }
536 // Confirm before accepting (input.confirm): the answer
537 // stays in vars.answer for the confirming step's chips.
538 if confirmNeeded(input["confirm"], answer: text),
539 let target = input["confirm"]["goto"].stringValue {
540 await self.enter(target)
541 return
542 }
543 if !step["effect"].isNull {
544 await self.runEffect(stepId: stepId, effect: step["effect"])
545 }
546 }
547 }
548 }
549 } else if input["kind"].stringValue == "photo" {
550 armPhoto(stepId: stepId, step: step, go: go)
551 }
552 }
553
554 private func armPhoto(stepId: String, step: FlowJSON, go: OnceStep) {
555 io.ask { [weak self] answer in
556 guard let self else { return }
557 guard let img = answer.imageAttachment else {
558 Task { @MainActor in
559 var extra = FlowSayExtra()
560 extra.local = step["input"]["reject"]["local"].truthy
561 await self.io.say(self.renderText(step["input"]["reject"]["text"]), chips: nil, extra: extra)
562 self.armPhoto(stepId: stepId, step: step, go: go)
563 }
564 return
565 }
566 go {
567 Task { @MainActor in
568 let text = answer.text.trimmingCharacters(in: .whitespacesAndNewlines)
569 self.env.vars["answerText"] = .string(text)
570 await self.runEffect(stepId: stepId, effect: step["effect"],
571 argsOverride: [.object(img.mapValues { .string($0) }), .string(text)])
572 }
573 }
574 }
575 }
576
577 public func enter(_ id: String) async {
578 guard let ctx = steps[id] else { return }
579 let step = ctx.step
580 env.currentStepDone = { [weak self] in self?.io.done(id) ?? false }
581
582 // Entry branches: state that satisfies the step on arrival.
583 for e in step["entries"].arrayValue {
584 guard evalCond(e["if"]) else { continue }
585 if !e["set"].isNull { setPaths(e["set"]) }
586 if !io.done(id) {
587 if !e["recordIfNew"].isNull {
588 await io.record(step: id, label: renderText(e["recordIfNew"]["label"]),
589 value: e["recordIfNew"]["value"].stringValue ?? "")
590 }
591 if let line = e["sayIfNew"].stringValue {
592 await io.say(renderText(.string(line)), chips: nil, extra: FlowSayExtra())
593 }
594 }
595 await enter(e["goto"].stringValue ?? ctx.next ?? "")
596 return
597 }
598 if step["skipIf"].arrayValue.contains(where: { evalCond($0) }) {
599 await enter(step["skipTo"].stringValue ?? ctx.next ?? "")
600 return
601 }
602
603 // A step may persist itself on arrival (finish's completion marker).
604 if !step["record"].isNull, !io.done(id) {
605 await io.record(step: id, label: renderText(step["record"]["label"]),
606 value: step["record"]["value"].stringValue ?? "")
607 }
608
609 let go = OnceStep()
610 currentGo = go
611
612 if !step["effects"].isNull {
613 for eff in step["effects"].arrayValue where evalCond(eff["if"]) {
614 _ = await io.verb(eff["verb"].stringValue ?? "", args: eff["args"].arrayValue.map { valueOf($0) })
615 }
616 }
617
618 let bubbles = step["bubbles"].arrayValue.filter { evalCond($0["if"]) }
619 for (i, b) in bubbles.enumerated() {
620 let last = i == bubbles.count - 1
621 var extra = FlowSayExtra()
622 if let key = b["key"].stringValue { extra.key = template(key) }
623 extra.hint = b["hint"].stringValue
624 extra.attachHint = b["attachHint"].stringValue
625 extra.label = b["label"].stringValue
626 extra.local = b["local"].truthy
627 if let img = b["img"].stringValue {
628 extra.imgToken = img
629 extra.imgAlt = renderText(b["imgAlt"])
630 extra.imgRound = b["imgRound"].truthy
631 }
632 await io.say(renderText(b["text"]), chips: last ? buildChips(stepId: id, go: go) : nil, extra: extra)
633 }
634 if bubbles.isEmpty, !step["chips"].isNull {
635 await io.say("", chips: buildChips(stepId: id, go: go), extra: FlowSayExtra())
636 }
637 armInput(stepId: id)
638
639 // Settle: wait for a condition to become true on its own, then re-enter.
640 if !step["settle"].isNull, !evalCond(step["settle"]["when"]) {
641 let period = step["settle"]["everyMs"].isNull ? 700 : step["settle"]["everyMs"].numberValue
642 func tick() {
643 if go.taken { return }
644 if evalCond(step["settle"]["when"]) {
645 go { Task { await self.enter(step["settle"]["goto"].stringValue ?? id) } }
646 return
647 }
648 io.delay(ms: period, tick)
649 }
650 io.delay(ms: period, tick)
651 }
652
653 if let auto = step["auto"].stringValue {
654 await enter(auto)
655 return
656 }
657 if step["end"].truthy { env.onEnd() }
658 }
659
660 // A retry that re-asks with new text: one fresh line carrying the step's
661 // chips + hint, then the input re-arms.
662 func askIn(stepId: String, text: String) async {
663 guard let step = steps[stepId]?.step else { return }
664 let go = OnceStep()
665 currentGo = go
666 var extra = FlowSayExtra()
667 if let src = step["bubbles"].arrayValue.last {
668 extra.hint = src["hint"].stringValue
669 extra.attachHint = src["attachHint"].stringValue
670 extra.label = src["label"].stringValue
671 }
672 await io.say(text, chips: buildChips(stepId: stepId, go: go), extra: extra)
673 armInput(stepId: stepId)
674 }
675}
676
677private extension FlowJSON {
678 var numberValue: Double {
679 if case .number(let n) = self { return n }
680 return 0
681 }
682 /// Ordered key/value pairs of an object (order not semantic for `set`).
683 var arrayPairs: [(String, FlowJSON)] {
684 if case .object(let o) = self { return o.map { ($0.key, $0.value) } }
685 return []
686 }
687}
688