eleven / messenger Public

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

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: - the name-step answer cleaner (input.clean == "name")
139
140// People answer a conversational "what should folks here call you?"
141// conversationally β€” "I'm Dermot", "hi, my name is Ana." β€” and the raw reply
142// used to become their screen name verbatim (a real member spent days as
143// "I'm Dermot"). Strip one leading greeting, one leading preamble, and any
144// trailing punctuation; if stripping eats everything, keep the trimmed
145// original β€” never clean a name to nothing. Prefix lists are matched on a
146// lowercased, straight-apostrophe copy (iOS keyboards type ’), longest
147// preamble first so "i'm called" wins over "i'm".
148// Twin: cleanNameAnswer in web/static/flow.js.
149private let nameGreetings = ["hiya", "howdy", "hello", "hey", "hi"]
150private let namePreambles = [
151 "you can call me", "everyone calls me", "people call me", "they call me",
152 "i'm called", "i am called", "my name is", "my name's", "the name's",
153 "name is", "name's", "call me", "i am", "i'm", "im", "it's", "its",
154]
155
156func cleanNameAnswer(_ raw: String) -> String {
157 let original = raw.trimmingCharacters(in: .whitespacesAndNewlines)
158 var s = original
159 func lower() -> String { s.lowercased().replacingOccurrences(of: "\u{2019}", with: "'") }
160 for g in nameGreetings {
161 guard lower().hasPrefix(g) else { continue }
162 // The greeting must end at punctuation-or-space ("hi," yes, "hilary" no).
163 var i = s.index(s.startIndex, offsetBy: g.count)
164 if i < s.endIndex, ",.!".contains(s[i]) { i = s.index(after: i) }
165 if i < s.endIndex, s[i] == " " {
166 s = String(s[s.index(after: i)...]).trimmingCharacters(in: .whitespaces)
167 }
168 break
169 }
170 let l = lower()
171 for p in namePreambles where l.hasPrefix(p + " ") {
172 s = String(s.dropFirst(p.count + 1)).trimmingCharacters(in: .whitespaces)
173 break
174 }
175 while let last = s.last, ".!,;:".contains(last) { s.removeLast() }
176 s = s.trimmingCharacters(in: .whitespaces)
177 return s.isEmpty ? original : s
178}
179
180// MARK: - the io surface
181
182/// One tappable chip on a bubble. `spentLabel` is its past-tense form once
183/// tapped; `isStale` ties liveness to the step (finish-line chips stay live).
184/// `trailing` right-aligns the chip β€” the quiet alternative path (the quick
185/// form) sits apart from the step's primary answers.
186@MainActor
187public final class FlowChip: Identifiable {
188 public let id = UUID()
189 public let label: String
190 public let spentLabel: String?
191 public let trailing: Bool
192 public let isStale: () -> Bool
193 public let run: () -> Void
194 init(label: String, spentLabel: String?, trailing: Bool = false, isStale: @escaping () -> Bool, run: @escaping () -> Void) {
195 self.label = label
196 self.spentLabel = spentLabel
197 self.trailing = trailing
198 self.isStale = isStale
199 self.run = run
200 }
201}
202
203/// Extra presentation riding a said line (mirrors makeSay's extra).
204public struct FlowSayExtra: Sendable {
205 public var key: String?
206 public var hint: String?
207 public var attachHint: String?
208 /// The composer's inset field label ("Your name:") β€” the "To:" pattern.
209 public var label: String?
210 public var local = false
211 public var imgToken: String?
212 public var imgAlt = ""
213 public var imgRound = false
214 public init() {}
215}
216
217/// A composer answer: the typed text plus any image attachment (photo steps).
218public struct FlowAnswer: Sendable {
219 public var text: String
220 public var imageAttachment: [String: String]? // {id, type} when an image rode along
221 public init(text: String, imageAttachment: [String: String]? = nil) {
222 self.text = text
223 self.imageAttachment = imageAttachment
224 }
225}
226
227/// What the engine needs from its host β€” the exact io contract flow.js takes.
228/// All methods are MainActor; say/verb/record are async (pacing, network).
229@MainActor
230public protocol FlowIO: AnyObject {
231 /// Speak one line (persist unless extra.local), with optional chips.
232 func say(_ text: String, chips: [FlowChip]?, extra: FlowSayExtra) async
233 /// Arm the one-shot answer waiter: the next composer send is the answer.
234 func ask(_ handler: @escaping (FlowAnswer) -> Void)
235 func cancelAnswer()
236 /// Run a bridge verb; nil/true β†’ "ok", false β†’ "error", string verbatim.
237 func verb(_ name: String, args: [FlowJSON]) async -> FlowJSON
238 /// Persist a completed step (the βœ“ line): the app's `.step` event.
239 func record(step: String, label: String, value: String) async
240 /// Whether a step already recorded (folded from the thread's history).
241 func done(_ step: String) -> Bool
242 /// Schedule fn after ms (settle polls, chip probes).
243 func delay(ms: Double, _ fn: @escaping () -> Void)
244}
245
246// MARK: - the engine
247
248@MainActor
249public final class FlowEngine {
250 let flow: FlowJSON
251 let env: FlowEnv
252 weak var ioRef: AnyObject?
253 let io: FlowIO
254 private var steps: [String: (step: FlowJSON, next: String?)] = [:]
255 private var currentGo = OnceStep()
256
257 public init(flow: FlowJSON, env: FlowEnv, io: FlowIO) {
258 self.flow = flow
259 self.env = env
260 self.io = io
261 let list = flow["steps"].arrayValue
262 for (i, s) in list.enumerated() {
263 guard let id = s["id"].stringValue else { continue }
264 let next = i + 1 < list.count ? list[i + 1]["id"].stringValue : nil
265 steps[id] = (s, next)
266 }
267 env.doneOf = { [weak self] id in self?.io.done(id) ?? false }
268 }
269
270 // One-tap-wins step guard (flow.js onceStep).
271 final class OnceStep {
272 var taken = false
273 func callAsFunction(_ fn: () -> Void) {
274 if taken { return }
275 taken = true
276 fn()
277 }
278 }
279
280 // MARK: conditions & templates (mirrors evalCond/template/renderText)
281
282 public func evalCond(_ expr: FlowJSON) -> Bool {
283 switch expr {
284 case .null: return true
285 case .bool(let b): return b
286 case .object(let o):
287 if let all = o["all"] { return all.arrayValue.allSatisfy { evalCond($0) } }
288 if let any = o["any"] { return any.arrayValue.contains { evalCond($0) } }
289 return false
290 case .string(let s):
291 let neg = s.hasPrefix("!")
292 let v = env.resolve(neg ? String(s.dropFirst()) : s)
293 return neg ? !v.truthy : v.truthy
294 default:
295 return false
296 }
297 }
298
299 public func template(_ s: String) -> String {
300 var out = ""
301 var i = s.startIndex
302 while i < s.endIndex {
303 if s[i] == "{" , let close = s[i...].firstIndex(of: "}") {
304 let body = String(s[s.index(after: i)..<close])
305 if body == "ack?" {
306 let a = env.ack
307 env.ack = ""
308 out += a.isEmpty ? "" : a + "\n\n"
309 } else {
310 let parts = body.split(separator: "|", maxSplits: 1).map(String.init)
311 let v = env.resolve(parts[0].trimmingCharacters(in: .whitespaces))
312 if v.isNull || v.display.isEmpty {
313 out += parts.count > 1 ? parts[1] : ""
314 } else {
315 out += v.display
316 }
317 }
318 i = s.index(after: close)
319 } else {
320 out.append(s[i])
321 i = s.index(after: i)
322 }
323 }
324 return out
325 }
326
327 public func renderText(_ t: FlowJSON) -> String {
328 switch t {
329 case .string(let s): return template(s)
330 case .array(let segs):
331 return segs.map { seg -> String in
332 if let str = seg.stringValue { return template(str) }
333 if !seg["if"].isNull || !seg["text"].isNull {
334 return evalCond(seg["if"]) ? template(seg["text"].stringValue ?? "") : ""
335 }
336 return ""
337 }.joined()
338 case .object(let o):
339 if o["if"] != nil {
340 let branch = evalCond(o["if"] ?? .null) ? o["then"] : o["else"]
341 return template(branch?.stringValue ?? "")
342 }
343 return ""
344 default:
345 return ""
346 }
347 }
348
349 // "$answer"/"$answerText" read captured input; anything else templates.
350 func valueOf(_ v: FlowJSON) -> FlowJSON {
351 if let s = v.stringValue {
352 if s == "$answer" { return env.vars["answer"] ?? .null }
353 if s == "$answerText" { return env.vars["answerText"] ?? .null }
354 return .string(template(s))
355 }
356 return v
357 }
358
359 func setPaths(_ sets: FlowJSON) {
360 for (path, value) in sets.arrayPairs {
361 let parts = path.split(separator: ".").map(String.init)
362 let resolved: FlowJSON = value.stringValue != nil ? valueOf(value) : value
363 if parts.first == "vars", parts.count == 2 {
364 env.vars[parts[1]] = resolved
365 } else if parts.count == 1 {
366 env.flags[parts[0]] = resolved
367 }
368 }
369 }
370
371 // Map a verb result onto a branch key (flow.js resultKey).
372 static func resultKey(_ r: FlowJSON) -> String {
373 if r.isNull { return "ok" }
374 if case .bool(let b) = r { return b ? "ok" : "error" }
375 return r.display
376 }
377
378 // MARK: run
379
380 public func start() {
381 env.resumed = evalCond(flow["resumedIf"].isNull ? .bool(false) : flow["resumedIf"])
382 // Strong self: the initial walk must survive even before the owner
383 // stores the engine (asks/chips re-anchor through the owner after).
384 if env.resumed,
385 !(flow["resumeUnless"] != .null && evalCond(flow["resumeUnless"])),
386 let line = flow["resumeSay"].stringValue {
387 Task {
388 var extra = FlowSayExtra()
389 extra.local = true
390 await self.io.say(line, chips: nil, extra: extra)
391 await self.enter(self.flow["entry"].stringValue ?? "")
392 }
393 } else {
394 Task {
395 await self.enter(self.flow["entry"].stringValue ?? "")
396 }
397 }
398 }
399
400 // Actions shared by chips, `on` branches, and entries. True = we moved.
401 @discardableResult
402 func runActions(_ a: FlowJSON, stepId: String) async -> Bool {
403 if a.isNull { return false }
404 if !a["set"].isNull { setPaths(a["set"]) }
405 if !a["record"].isNull {
406 let rec = a["record"]
407 let step = rec["step"].stringValue ?? stepId
408 let value = rec["value"].isNull ? "" : valueOf(rec["value"]).display
409 await io.record(step: step, label: renderText(rec["label"]), value: value)
410 }
411 if let ack = a["ack"].stringValue { env.ack = renderText(.string(ack)) }
412 if let sayText = a["say"].stringValue {
413 var extra = FlowSayExtra()
414 extra.local = a["local"].truthy
415 await io.say(renderText(.string(sayText)), chips: nil, extra: extra)
416 }
417 if !a["ask"].isNull {
418 let text = a["ask"].stringValue ?? a["ask"]["text"].stringValue ?? ""
419 await askIn(stepId: stepId, text: renderText(.string(text)))
420 return true
421 }
422 if !a["gotoAsk"].isNull {
423 let target = a["gotoAsk"]["step"].stringValue ?? stepId
424 await askIn(stepId: target, text: renderText(.string(a["gotoAsk"]["text"].stringValue ?? "")))
425 return true
426 }
427 if a["retry"].stringValue == "step" || a["retry"].truthy {
428 await enter(stepId)
429 return true
430 }
431 if let goto = a["goto"].stringValue {
432 await enter(goto)
433 return true
434 }
435 return false
436 }
437
438 func runEffect(stepId: String, effect: FlowJSON, argsOverride: [FlowJSON]? = nil) async {
439 guard let step = steps[stepId]?.step else { return }
440 let args = argsOverride ?? effect["args"].arrayValue.map { valueOf($0) }
441 let r = await io.verb(effect["verb"].stringValue ?? "", args: args)
442 let branch = step["on"][Self.resultKey(r)]
443 if branch.isNull { return } // fire-and-forget verb
444 let moved = await runActions(branch, stepId: stepId)
445 if !moved { armInput(stepId: stepId) } // rejected input re-asks in place
446 }
447
448 func buildChips(stepId: String, go: OnceStep) -> [FlowChip]? {
449 guard let step = steps[stepId]?.step else { return nil }
450 let chips = step["chips"].arrayValue.filter { evalCond($0["if"]) }.map { c -> FlowChip in
451 let advancing = !(c["advancing"] == .bool(false))
452 let hasProbe = !c["probe"].isNull
453 return FlowChip(
454 label: renderText(c["label"]),
455 spentLabel: c["spent"].stringValue,
456 trailing: c["trailing"].truthy,
457 isStale: (!advancing && !hasProbe) ? { false } : { go.taken },
458 run: { [weak self] in
459 guard let self else { return }
460 let act: () -> Void = {
461 Task { @MainActor in
462 if c["cancelInput"].truthy { self.io.cancelAnswer() }
463 if !c["set"].isNull { self.setPaths(c["set"]) }
464 if !c["effect"].isNull {
465 _ = await self.io.verb(c["effect"]["verb"].stringValue ?? "",
466 args: c["effect"]["args"].arrayValue.map { self.valueOf($0) })
467 }
468 await self.runActions(c, stepId: stepId)
469 }
470 }
471 if !advancing {
472 act()
473 if hasProbe {
474 let after = c["probe"]["afterMs"].isNull ? 1500 : c["probe"]["afterMs"].numberValue
475 self.io.delay(ms: after) { [weak self] in
476 guard let self else { return }
477 if !self.evalCond(c["probe"]["unless"]) {
478 go { Task { await self.enter(c["probe"]["goto"].stringValue ?? stepId) } }
479 }
480 }
481 }
482 } else {
483 go(act)
484 }
485 })
486 }
487 return chips.isEmpty ? nil : chips
488 }
489
490 func armInput(stepId: String) {
491 guard let step = steps[stepId]?.step, !step["input"].isNull else { return }
492 let input = step["input"]
493 let go = currentGo
494 if input["kind"].stringValue == "text" {
495 io.ask { [weak self] answer in
496 guard let self else { return }
497 go {
498 Task { @MainActor in
499 var text = answer.text.trimmingCharacters(in: .whitespacesAndNewlines)
500 // Clean BEFORE the cap so a chatty preamble can't eat
501 // into it ("my name is " + 25 chars keeps the name).
502 if input["clean"].stringValue == "name" { text = cleanNameAnswer(text) }
503 if case .number(let maxLen) = input["maxLen"] { text = String(text.prefix(Int(maxLen))) }
504 self.env.vars["answer"] = .string(text)
505 if input["required"].truthy && text.isEmpty {
506 await self.enter(stepId)
507 return
508 }
509 if !step["effect"].isNull {
510 await self.runEffect(stepId: stepId, effect: step["effect"])
511 }
512 }
513 }
514 }
515 } else if input["kind"].stringValue == "photo" {
516 armPhoto(stepId: stepId, step: step, go: go)
517 }
518 }
519
520 private func armPhoto(stepId: String, step: FlowJSON, go: OnceStep) {
521 io.ask { [weak self] answer in
522 guard let self else { return }
523 guard let img = answer.imageAttachment else {
524 Task { @MainActor in
525 var extra = FlowSayExtra()
526 extra.local = step["input"]["reject"]["local"].truthy
527 await self.io.say(self.renderText(step["input"]["reject"]["text"]), chips: nil, extra: extra)
528 self.armPhoto(stepId: stepId, step: step, go: go)
529 }
530 return
531 }
532 go {
533 Task { @MainActor in
534 let text = answer.text.trimmingCharacters(in: .whitespacesAndNewlines)
535 self.env.vars["answerText"] = .string(text)
536 await self.runEffect(stepId: stepId, effect: step["effect"],
537 argsOverride: [.object(img.mapValues { .string($0) }), .string(text)])
538 }
539 }
540 }
541 }
542
543 public func enter(_ id: String) async {
544 guard let ctx = steps[id] else { return }
545 let step = ctx.step
546 env.currentStepDone = { [weak self] in self?.io.done(id) ?? false }
547
548 // Entry branches: state that satisfies the step on arrival.
549 for e in step["entries"].arrayValue {
550 guard evalCond(e["if"]) else { continue }
551 if !e["set"].isNull { setPaths(e["set"]) }
552 if !io.done(id) {
553 if !e["recordIfNew"].isNull {
554 await io.record(step: id, label: renderText(e["recordIfNew"]["label"]),
555 value: e["recordIfNew"]["value"].stringValue ?? "")
556 }
557 if let line = e["sayIfNew"].stringValue {
558 await io.say(renderText(.string(line)), chips: nil, extra: FlowSayExtra())
559 }
560 }
561 await enter(e["goto"].stringValue ?? ctx.next ?? "")
562 return
563 }
564 if step["skipIf"].arrayValue.contains(where: { evalCond($0) }) {
565 await enter(step["skipTo"].stringValue ?? ctx.next ?? "")
566 return
567 }
568
569 // A step may persist itself on arrival (finish's completion marker).
570 if !step["record"].isNull, !io.done(id) {
571 await io.record(step: id, label: renderText(step["record"]["label"]),
572 value: step["record"]["value"].stringValue ?? "")
573 }
574
575 let go = OnceStep()
576 currentGo = go
577
578 if !step["effects"].isNull {
579 for eff in step["effects"].arrayValue where evalCond(eff["if"]) {
580 _ = await io.verb(eff["verb"].stringValue ?? "", args: eff["args"].arrayValue.map { valueOf($0) })
581 }
582 }
583
584 let bubbles = step["bubbles"].arrayValue.filter { evalCond($0["if"]) }
585 for (i, b) in bubbles.enumerated() {
586 let last = i == bubbles.count - 1
587 var extra = FlowSayExtra()
588 if let key = b["key"].stringValue { extra.key = template(key) }
589 extra.hint = b["hint"].stringValue
590 extra.attachHint = b["attachHint"].stringValue
591 extra.label = b["label"].stringValue
592 extra.local = b["local"].truthy
593 if let img = b["img"].stringValue {
594 extra.imgToken = img
595 extra.imgAlt = renderText(b["imgAlt"])
596 extra.imgRound = b["imgRound"].truthy
597 }
598 await io.say(renderText(b["text"]), chips: last ? buildChips(stepId: id, go: go) : nil, extra: extra)
599 }
600 if bubbles.isEmpty, !step["chips"].isNull {
601 await io.say("", chips: buildChips(stepId: id, go: go), extra: FlowSayExtra())
602 }
603 armInput(stepId: id)
604
605 // Settle: wait for a condition to become true on its own, then re-enter.
606 if !step["settle"].isNull, !evalCond(step["settle"]["when"]) {
607 let period = step["settle"]["everyMs"].isNull ? 700 : step["settle"]["everyMs"].numberValue
608 func tick() {
609 if go.taken { return }
610 if evalCond(step["settle"]["when"]) {
611 go { Task { await self.enter(step["settle"]["goto"].stringValue ?? id) } }
612 return
613 }
614 io.delay(ms: period, tick)
615 }
616 io.delay(ms: period, tick)
617 }
618
619 if let auto = step["auto"].stringValue {
620 await enter(auto)
621 return
622 }
623 if step["end"].truthy { env.onEnd() }
624 }
625
626 // A retry that re-asks with new text: one fresh line carrying the step's
627 // chips + hint, then the input re-arms.
628 func askIn(stepId: String, text: String) async {
629 guard let step = steps[stepId]?.step else { return }
630 let go = OnceStep()
631 currentGo = go
632 var extra = FlowSayExtra()
633 if let src = step["bubbles"].arrayValue.last {
634 extra.hint = src["hint"].stringValue
635 extra.attachHint = src["attachHint"].stringValue
636 extra.label = src["label"].stringValue
637 }
638 await io.say(text, chips: buildChips(stepId: stepId, go: go), extra: extra)
639 armInput(stepId: stepId)
640 }
641}
642
643private extension FlowJSON {
644 var numberValue: Double {
645 if case .number(let n) = self { return n }
646 return 0
647 }
648 /// Ordered key/value pairs of an object (order not semantic for `set`).
649 var arrayPairs: [(String, FlowJSON)] {
650 if case .object(let o) = self { return o.map { ($0.key, $0.value) } }
651 return []
652 }
653}
654