@elizaos/capacitor-bun-runtime 2.0.3-beta.2

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 (61) hide show
  1. package/ElizaosCapacitorBunRuntime.podspec +54 -0
  2. package/LICENSE +21 -0
  3. package/README.md +127 -0
  4. package/dist/esm/definitions.d.ts +136 -0
  5. package/dist/esm/definitions.d.ts.map +1 -0
  6. package/dist/esm/definitions.js +14 -0
  7. package/dist/esm/definitions.js.map +1 -0
  8. package/dist/esm/index.d.ts +9 -0
  9. package/dist/esm/index.d.ts.map +1 -0
  10. package/dist/esm/index.js +11 -0
  11. package/dist/esm/index.js.map +1 -0
  12. package/dist/esm/web.d.ts +19 -0
  13. package/dist/esm/web.d.ts.map +1 -0
  14. package/dist/esm/web.js +44 -0
  15. package/dist/esm/web.js.map +1 -0
  16. package/dist/plugin.cjs.js +63 -0
  17. package/dist/plugin.cjs.js.map +1 -0
  18. package/dist/plugin.js +66 -0
  19. package/dist/plugin.js.map +1 -0
  20. package/ios/Sources/ElizaBunRuntimePlugin/BridgeInstaller.swift +94 -0
  21. package/ios/Sources/ElizaBunRuntimePlugin/ElizaBunRuntime.swift +705 -0
  22. package/ios/Sources/ElizaBunRuntimePlugin/ElizaBunRuntimePlugin.swift +1109 -0
  23. package/ios/Sources/ElizaBunRuntimePlugin/FullBunEngineHost.swift +677 -0
  24. package/ios/Sources/ElizaBunRuntimePlugin/JSContextHelpers.swift +226 -0
  25. package/ios/Sources/ElizaBunRuntimePlugin/SandboxPaths.swift +46 -0
  26. package/ios/Sources/ElizaBunRuntimePlugin/bridge/CryptoBridge.swift +238 -0
  27. package/ios/Sources/ElizaBunRuntimePlugin/bridge/ElizaSqliteVecBridge.m +28 -0
  28. package/ios/Sources/ElizaBunRuntimePlugin/bridge/FSBridge.swift +270 -0
  29. package/ios/Sources/ElizaBunRuntimePlugin/bridge/HTTPBridge.swift +153 -0
  30. package/ios/Sources/ElizaBunRuntimePlugin/bridge/HTTPServerBridge.swift +32 -0
  31. package/ios/Sources/ElizaBunRuntimePlugin/bridge/LlamaBridge.swift +233 -0
  32. package/ios/Sources/ElizaBunRuntimePlugin/bridge/LlamaBridgeImpl.swift +1863 -0
  33. package/ios/Sources/ElizaBunRuntimePlugin/bridge/LogBridge.swift +36 -0
  34. package/ios/Sources/ElizaBunRuntimePlugin/bridge/PathsBridge.swift +41 -0
  35. package/ios/Sources/ElizaBunRuntimePlugin/bridge/ProcessBridge.swift +80 -0
  36. package/ios/Sources/ElizaBunRuntimePlugin/bridge/SqliteBridge.swift +406 -0
  37. package/ios/Sources/ElizaBunRuntimePlugin/bridge/SqliteBridgeInstaller.swift +17 -0
  38. package/ios/Sources/ElizaBunRuntimePlugin/bridge/SqliteVecLoader.swift +66 -0
  39. package/ios/Sources/ElizaBunRuntimePlugin/bridge/UIBridge.swift +72 -0
  40. package/ios/Sources/ElizaBunRuntimePlugin/kokoro/KokoroCoreMlChinesePhonemizer.swift +313 -0
  41. package/ios/Sources/ElizaBunRuntimePlugin/kokoro/KokoroCoreMlConfiguration.swift +28 -0
  42. package/ios/Sources/ElizaBunRuntimePlugin/kokoro/KokoroCoreMlEngine.swift +325 -0
  43. package/ios/Sources/ElizaBunRuntimePlugin/kokoro/KokoroCoreMlHindiPhonemizer.swift +150 -0
  44. package/ios/Sources/ElizaBunRuntimePlugin/kokoro/KokoroCoreMlJapanesePhonemizer.swift +209 -0
  45. package/ios/Sources/ElizaBunRuntimePlugin/kokoro/KokoroCoreMlLatinPhonemizer.swift +374 -0
  46. package/ios/Sources/ElizaBunRuntimePlugin/kokoro/KokoroCoreMlModel.swift +87 -0
  47. package/ios/Sources/ElizaBunRuntimePlugin/kokoro/KokoroCoreMlPhonemizer.swift +679 -0
  48. package/ios/Sources/ElizaBunRuntimePlugin/kokoro/KokoroCoreMlPronunciationDicts.swift +131 -0
  49. package/ios/Sources/ElizaBunRuntimePlugin/kokoro/KokoroCoreMlSupport.swift +24 -0
  50. package/ios/Tests/llama-bridge-smoke-main.swift +92 -0
  51. package/package.json +68 -0
  52. package/src/bridge-contract.test.ts +127 -0
  53. package/src/definitions.d.ts +136 -0
  54. package/src/definitions.d.ts.map +1 -0
  55. package/src/definitions.ts +152 -0
  56. package/src/index.d.ts +9 -0
  57. package/src/index.d.ts.map +1 -0
  58. package/src/index.ts +16 -0
  59. package/src/web.d.ts +19 -0
  60. package/src/web.d.ts.map +1 -0
  61. package/src/web.ts +80 -0
@@ -0,0 +1,1109 @@
1
+ import Foundation
2
+ import Capacitor
3
+ import AVFoundation
4
+
5
+ /// Capacitor plugin shell.
6
+ ///
7
+ /// Exposes the JS surface declared in `src/definitions.ts`:
8
+ /// - `start(opts)` — boot the runtime and load the agent bundle
9
+ /// - `sendMessage(opts)` — round-trip a chat message through the agent
10
+ /// - `getStatus()` — return ready / model / tokensPerSecond
11
+ /// - `stop()` — tear down the runtime
12
+ /// - `call({ method, args })` — invoke any `ui_register_handler` handler
13
+ ///
14
+ /// The plugin delegates everything to `ElizaBunRuntime`, which owns the
15
+ /// JSContext on its dedicated serial dispatch queue.
16
+ @objc(ElizaBunRuntimePlugin)
17
+ public class ElizaBunRuntimePlugin: CAPPlugin, CAPBridgedPlugin {
18
+ public let identifier = "ElizaBunRuntimePlugin"
19
+ public let jsName = "ElizaBunRuntime"
20
+ public let pluginMethods: [CAPPluginMethod] = [
21
+ CAPPluginMethod(name: "start", returnType: CAPPluginReturnPromise),
22
+ CAPPluginMethod(name: "sendMessage", returnType: CAPPluginReturnPromise),
23
+ CAPPluginMethod(name: "getStatus", returnType: CAPPluginReturnPromise),
24
+ CAPPluginMethod(name: "stop", returnType: CAPPluginReturnPromise),
25
+ CAPPluginMethod(name: "getLocalTtsStatus", returnType: CAPPluginReturnPromise),
26
+ CAPPluginMethod(name: "getLocalTtsDiagnostics", returnType: CAPPluginReturnPromise),
27
+ CAPPluginMethod(name: "synthesizeLocalTts", returnType: CAPPluginReturnPromise),
28
+ CAPPluginMethod(name: "call", returnType: CAPPluginReturnPromise),
29
+ ]
30
+
31
+ // Native-only smoke is intentionally separate from the WebView smoke key.
32
+ // The WebView smoke exercises the production Capacitor call path; using the
33
+ // same key here races two full Bun runtimes and PGlite rejects the duplicate
34
+ // database owner.
35
+ private static let fullBunSmokeRequestKey = "CapacitorStorage.eliza:ios-full-bun-native-smoke:request"
36
+ private static let fullBunSmokeResultKey = "CapacitorStorage.eliza:ios-full-bun-native-smoke:result"
37
+ private static let webFullBunSmokeRequestKey = "CapacitorStorage.eliza:ios-full-bun-smoke:request"
38
+ private static let webFullBunSmokeResultKey = "CapacitorStorage.eliza:ios-full-bun-smoke:result"
39
+ private static let webFullBunPrewarmResultKey = "CapacitorStorage.eliza:ios-full-bun-prewarm:result"
40
+ private static let mobileRuntimeModeKey = "CapacitorStorage.eliza:mobile-runtime-mode"
41
+ private static let fullBunSmokeEnvKey = "ELIZA_IOS_FULL_BUN_NATIVE_SMOKE"
42
+ private static let webFullBunSmokeEnvKey = "ELIZA_IOS_FULL_BUN_WEB_SMOKE"
43
+ private var runtime: ElizaBunRuntime?
44
+ private var nativeSmokeStarted = false
45
+ private var fullBunPrewarmStarted = false
46
+ private var localTtsPlayers: [String: AVAudioPlayer] = [:]
47
+
48
+ override public func load() {
49
+ // Construct lazily on first start to avoid holding the JSVirtualMachine
50
+ // when the app launches without the runtime.
51
+ runtime = nil
52
+ runNativeFullBunSmokeIfRequested()
53
+ prewarmFullBunRuntimeIfRequested()
54
+ }
55
+
56
+ // MARK: - start
57
+
58
+ @objc func start(_ call: CAPPluginCall) {
59
+ let bundlePath = call.getString("bundlePath")
60
+ let polyfillPath = call.getString("polyfillPath")
61
+ let engine = call.getString("engine") ?? IosRuntimePolicy.defaultEngine
62
+ let argv = call.getArray("argv", String.self) ?? ["bun", "public/agent/agent-bundle.js"]
63
+ let env: [String: String]
64
+ if let raw = call.getObject("env") {
65
+ env = raw.compactMapValues { $0 as? String }
66
+ } else {
67
+ env = [:]
68
+ }
69
+
70
+ let startedAt = Date()
71
+ NSLog("[ElizaBunRuntimePlugin] start requested engine=\(engine) bundlePath=\(bundlePath ?? "default") argv=\(argv) envKeys=\(env.keys.sorted())")
72
+ let runtime = ensureRuntime()
73
+ runtime.start(
74
+ bundlePath: bundlePath,
75
+ polyfillPath: polyfillPath,
76
+ engine: engine,
77
+ argv: argv,
78
+ env: env
79
+ ) { result in
80
+ let durationMs = Int(Date().timeIntervalSince(startedAt) * 1000)
81
+ switch result {
82
+ case .success(let outcome):
83
+ NSLog("[ElizaBunRuntimePlugin] start succeeded engine=\(engine) bridgeVersion=\(outcome.bridgeVersion) durationMs=\(durationMs)")
84
+ self.runNativeFullBunSmokeAfterSuccessfulStartIfRequested(runtime: runtime)
85
+ DispatchQueue.main.async {
86
+ call.resolve([
87
+ "ok": true,
88
+ "bridgeVersion": outcome.bridgeVersion,
89
+ ])
90
+ }
91
+ case .failure(let error):
92
+ NSLog("[ElizaBunRuntimePlugin] start failed engine=\(engine) durationMs=\(durationMs) error=\(error)")
93
+ DispatchQueue.main.async {
94
+ call.resolve([
95
+ "ok": false,
96
+ "error": "\(error)",
97
+ ])
98
+ }
99
+ }
100
+ }
101
+ }
102
+
103
+ // MARK: - sendMessage
104
+
105
+ @objc func sendMessage(_ call: CAPPluginCall) {
106
+ guard let runtime = runtime else {
107
+ call.reject("ElizaBunRuntime is not started")
108
+ return
109
+ }
110
+ guard let message = call.getString("message") else {
111
+ call.reject("sendMessage requires a message string")
112
+ return
113
+ }
114
+ let conversationId = call.getString("conversationId")
115
+ runtime.sendMessage(text: message, conversationId: conversationId) { result in
116
+ DispatchQueue.main.async {
117
+ switch result {
118
+ case .success(let reply):
119
+ call.resolve(["reply": reply])
120
+ case .failure(let error):
121
+ call.reject("\(error)")
122
+ }
123
+ }
124
+ }
125
+ }
126
+
127
+ // MARK: - getStatus
128
+
129
+ @objc func getStatus(_ call: CAPPluginCall) {
130
+ guard let runtime = runtime else {
131
+ call.resolve(["ready": false])
132
+ return
133
+ }
134
+ runtime.currentStatus { status in
135
+ DispatchQueue.main.async {
136
+ var payload: JSObject = [
137
+ "ready": status.ready,
138
+ "engine": status.engine,
139
+ ]
140
+ if let v = status.bridgeVersion { payload["bridgeVersion"] = v }
141
+ if let m = status.model { payload["model"] = m }
142
+ if let tps = status.tokensPerSecond { payload["tokensPerSecond"] = tps }
143
+ call.resolve(payload)
144
+ }
145
+ }
146
+ }
147
+
148
+ // MARK: - stop
149
+
150
+ @objc func stop(_ call: CAPPluginCall) {
151
+ guard let runtime = runtime else {
152
+ call.resolve()
153
+ return
154
+ }
155
+ runtime.stop {
156
+ DispatchQueue.main.async {
157
+ call.resolve()
158
+ }
159
+ }
160
+ }
161
+
162
+ @objc func getLocalTtsStatus(_ call: CAPPluginCall) {
163
+ #if ELIZA_IOS_INCLUDE_LLAMA
164
+ guard let bundle = resolveLocalTtsBundleDir(override: call.getString("bundleDir")) else {
165
+ call.resolve([
166
+ "ready": false,
167
+ "status": "missing",
168
+ "message": "Eliza-1 voice assets are not installed in this iOS build.",
169
+ ])
170
+ return
171
+ }
172
+ call.resolve([
173
+ "ready": true,
174
+ "status": "assets-ready",
175
+ "message": "Local voice assets are installed. Voice engine will warm on first playback.",
176
+ "bundleDir": bundle.path,
177
+ "modelId": modelId(for: bundle),
178
+ ])
179
+ #else
180
+ call.resolve([
181
+ "ready": false,
182
+ "status": "unavailable",
183
+ "message": "This build is missing the iOS local voice playback engine.",
184
+ ])
185
+ #endif
186
+ }
187
+
188
+ @objc func getLocalTtsDiagnostics(_ call: CAPPluginCall) {
189
+ #if ELIZA_IOS_INCLUDE_LLAMA
190
+ let probe = call.getBool("probe") ?? false
191
+ let playProbe = call.getBool("play") ?? Self.envFlag("ELIZA_IOS_TTS_PLAY_SMOKE")
192
+ let keepProbeAudio = call.getBool("keepAudio") ?? Self.envFlag("ELIZA_IOS_TTS_KEEP_PROBE_AUDIO")
193
+ let text = call.getString("text") ?? "Hi from Eliza."
194
+ let bundleOverride = call.getString("bundleDir")
195
+ let baseDiagnostics = buildLocalTtsDiagnostics(bundleOverride: bundleOverride)
196
+ guard probe, let bundlePath = baseDiagnostics["selectedBundleDir"] as? String else {
197
+ NSLog("[ElizaBunRuntimePlugin] Local TTS diagnostics \(baseDiagnostics)")
198
+ call.resolve(baseDiagnostics)
199
+ return
200
+ }
201
+ DispatchQueue.global(qos: .userInitiated).async {
202
+ var diagnostics = baseDiagnostics
203
+ let result = LlamaBridgeImpl.shared.synthesizeSpeech(
204
+ bundleDir: bundlePath,
205
+ text: text,
206
+ speakerPresetId: nil,
207
+ maxSamples: 24_000 * 20
208
+ )
209
+ var probePayload: JSObject = [
210
+ "ok": result.error == nil,
211
+ "sampleRate": result.sampleRate,
212
+ "samples": result.samples,
213
+ "durationMs": result.durationMs,
214
+ ]
215
+ if let error = result.error {
216
+ probePayload["error"] = error
217
+ }
218
+ if let audioFilePath = result.audioFilePath, !audioFilePath.isEmpty {
219
+ probePayload["audioFilePath"] = audioFilePath
220
+ let audioFileUrl = URL(fileURLWithPath: audioFilePath)
221
+ if playProbe {
222
+ do {
223
+ let audioData = try Data(contentsOf: audioFileUrl)
224
+ try self.playLocalTtsAudio(audioData)
225
+ probePayload["played"] = true
226
+ } catch {
227
+ probePayload["played"] = false
228
+ probePayload["playError"] = error.localizedDescription
229
+ }
230
+ }
231
+ if !keepProbeAudio {
232
+ try? FileManager.default.removeItem(at: audioFileUrl)
233
+ }
234
+ }
235
+ diagnostics["probe"] = probePayload
236
+ diagnostics["engine"] = self.jsObject(LlamaBridgeImpl.shared.ttsEngineDiagnostics(bundleDir: bundlePath))
237
+ NSLog("[ElizaBunRuntimePlugin] Local TTS diagnostics \(diagnostics)")
238
+ DispatchQueue.main.async {
239
+ call.resolve(diagnostics)
240
+ }
241
+ }
242
+ #else
243
+ call.resolve([
244
+ "available": false,
245
+ "message": "This build is missing the iOS local voice playback engine.",
246
+ ])
247
+ #endif
248
+ }
249
+
250
+ @objc func synthesizeLocalTts(_ call: CAPPluginCall) {
251
+ guard let text = call.getString("text"), !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
252
+ call.reject("synthesizeLocalTts requires text")
253
+ return
254
+ }
255
+ #if ELIZA_IOS_INCLUDE_LLAMA
256
+ guard let bundle = resolveLocalTtsBundleDir(override: call.getString("bundleDir")) else {
257
+ call.reject("Eliza-1 voice assets are not installed in this iOS build.")
258
+ return
259
+ }
260
+ let speakerPresetId = call.getString("speakerPresetId")
261
+ ?? call.getString("voice")
262
+ ?? call.getString("voiceId")
263
+ let maxSamples = call.getDouble("maxSamples").map { Int($0) } ?? 24_000 * 60
264
+ let playImmediately = call.getBool("play") ?? false
265
+ DispatchQueue.global(qos: .userInitiated).async {
266
+ let result = LlamaBridgeImpl.shared.synthesizeSpeech(
267
+ bundleDir: bundle.path,
268
+ text: text,
269
+ speakerPresetId: speakerPresetId,
270
+ maxSamples: maxSamples
271
+ )
272
+ if let error = result.error {
273
+ DispatchQueue.main.async {
274
+ call.reject(error)
275
+ }
276
+ return
277
+ }
278
+ let audioBase64: String
279
+ var audioDataForPlayback: Data?
280
+ if let audioFilePath = result.audioFilePath, !audioFilePath.isEmpty {
281
+ let audioFileUrl = URL(fileURLWithPath: audioFilePath)
282
+ do {
283
+ let audioData = try Data(contentsOf: audioFileUrl)
284
+ audioDataForPlayback = audioData
285
+ audioBase64 = playImmediately ? "" : audioData.base64EncodedString()
286
+ try? FileManager.default.removeItem(at: audioFileUrl)
287
+ } catch {
288
+ DispatchQueue.main.async {
289
+ call.reject("Failed to read synthesized audio: \(error.localizedDescription)")
290
+ }
291
+ return
292
+ }
293
+ } else {
294
+ audioBase64 = result.audioBase64
295
+ if playImmediately, let decoded = Data(base64Encoded: result.audioBase64) {
296
+ audioDataForPlayback = decoded
297
+ }
298
+ }
299
+ DispatchQueue.main.async {
300
+ if playImmediately {
301
+ do {
302
+ try self.playLocalTtsAudio(audioDataForPlayback)
303
+ } catch {
304
+ call.reject("Failed to play synthesized audio: \(error.localizedDescription)")
305
+ return
306
+ }
307
+ }
308
+ call.resolve([
309
+ "audioBase64": audioBase64,
310
+ "contentType": result.contentType,
311
+ "sampleRate": result.sampleRate,
312
+ "samples": result.samples,
313
+ "durationMs": result.durationMs,
314
+ "modelId": self.modelId(for: bundle),
315
+ "played": playImmediately,
316
+ ])
317
+ }
318
+ }
319
+ #else
320
+ call.reject("This build is missing the iOS local voice playback engine.")
321
+ #endif
322
+ }
323
+
324
+ private func playLocalTtsAudio(_ audioData: Data?) throws {
325
+ guard let audioData, !audioData.isEmpty else {
326
+ throw NSError(
327
+ domain: "ElizaBunRuntime",
328
+ code: -40,
329
+ userInfo: [NSLocalizedDescriptionKey: "No synthesized audio data was available."]
330
+ )
331
+ }
332
+ let session = AVAudioSession.sharedInstance()
333
+ try session.setCategory(.playback, mode: .spokenAudio, options: [.duckOthers])
334
+ try session.setActive(true)
335
+ let player = try AVAudioPlayer(data: audioData)
336
+ player.prepareToPlay()
337
+ player.volume = 1.0
338
+ let playerId = UUID().uuidString
339
+ localTtsPlayers[playerId] = player
340
+ guard player.play() else {
341
+ localTtsPlayers.removeValue(forKey: playerId)
342
+ throw NSError(
343
+ domain: "ElizaBunRuntime",
344
+ code: -41,
345
+ userInfo: [NSLocalizedDescriptionKey: "AVAudioPlayer refused to start playback."]
346
+ )
347
+ }
348
+ let cleanupDelay = max(1.0, player.duration + 1.0)
349
+ DispatchQueue.main.asyncAfter(deadline: .now() + cleanupDelay) { [weak self] in
350
+ self?.localTtsPlayers.removeValue(forKey: playerId)
351
+ }
352
+ }
353
+
354
+ // MARK: - call
355
+
356
+ @objc func call(_ pluginCall: CAPPluginCall) {
357
+ guard let runtime = runtime else {
358
+ pluginCall.reject("ElizaBunRuntime is not started")
359
+ return
360
+ }
361
+ guard let method = pluginCall.getString("method") else {
362
+ pluginCall.reject("call requires a method name")
363
+ return
364
+ }
365
+ let args: Any? = pluginCall.getValue("args")
366
+ runtime.dispatchHandler(method: method, args: args) { (result: Result<Any?, Error>) in
367
+ DispatchQueue.main.async {
368
+ switch result {
369
+ case .success(let value):
370
+ pluginCall.resolve(["result": Self.jsonSafe(value)])
371
+ case .failure(let error):
372
+ pluginCall.reject("\(error)")
373
+ }
374
+ }
375
+ }
376
+ }
377
+
378
+ // MARK: - Helpers
379
+
380
+ private func ensureRuntime() -> ElizaBunRuntime {
381
+ if let existing = runtime { return existing }
382
+ NSLog("[ElizaBunRuntimePlugin] creating ElizaBunRuntime")
383
+ let new = ElizaBunRuntime(plugin: self)
384
+ runtime = new
385
+ return new
386
+ }
387
+
388
+ private struct LocalTtsBundleResolution {
389
+ let override: URL?
390
+ let roots: [URL]
391
+ let selectedBundle: URL?
392
+ }
393
+
394
+ private func resolveLocalTtsBundleDir(override: String?) -> URL? {
395
+ resolveLocalTtsBundleResolution(override: override).selectedBundle
396
+ }
397
+
398
+ private func resolveLocalTtsBundleResolution(override: String?) -> LocalTtsBundleResolution {
399
+ if let override, !override.isEmpty {
400
+ let url = URL(fileURLWithPath: override, isDirectory: true)
401
+ if hasKokoroBundle(url) {
402
+ return LocalTtsBundleResolution(override: url, roots: localTtsSearchRoots(), selectedBundle: url)
403
+ }
404
+ }
405
+ let fm = FileManager.default
406
+ let roots = localTtsSearchRoots()
407
+ for root in roots where fm.fileExists(atPath: root.path) {
408
+ if let bundle = findKokoroBundle(in: root) {
409
+ return LocalTtsBundleResolution(
410
+ override: override.map { URL(fileURLWithPath: $0, isDirectory: true) },
411
+ roots: roots,
412
+ selectedBundle: bundle
413
+ )
414
+ }
415
+ }
416
+ return LocalTtsBundleResolution(
417
+ override: override.map { URL(fileURLWithPath: $0, isDirectory: true) },
418
+ roots: roots,
419
+ selectedBundle: nil
420
+ )
421
+ }
422
+
423
+ private func localTtsSearchRoots() -> [URL] {
424
+ let paths = SandboxPaths()
425
+ return [
426
+ paths.appSupport
427
+ .appendingPathComponent("local-inference", isDirectory: true)
428
+ .appendingPathComponent("models", isDirectory: true),
429
+ paths.bundle
430
+ .appendingPathComponent("public", isDirectory: true)
431
+ .appendingPathComponent("agent", isDirectory: true)
432
+ .appendingPathComponent("models", isDirectory: true),
433
+ ]
434
+ }
435
+
436
+ private func buildLocalTtsDiagnostics(bundleOverride: String?) -> JSObject {
437
+ let fm = FileManager.default
438
+ let resolution = resolveLocalTtsBundleResolution(override: bundleOverride)
439
+ var payload: JSObject = [
440
+ "available": resolution.selectedBundle != nil,
441
+ "roots": resolution.roots.map { describeDirectory($0) },
442
+ "engine": jsObject(LlamaBridgeImpl.shared.ttsEngineDiagnostics(bundleDir: resolution.selectedBundle?.path)),
443
+ ]
444
+ if let override = resolution.override {
445
+ payload["override"] = describeDirectory(override)
446
+ }
447
+ guard let bundle = resolution.selectedBundle else {
448
+ payload["message"] = "No bundled Kokoro local TTS assets were found."
449
+ return payload
450
+ }
451
+
452
+ payload["selectedBundleDir"] = bundle.path
453
+ payload["modelId"] = modelId(for: bundle)
454
+ payload["ttsDir"] = describeDirectory(bundle.appendingPathComponent("tts", isDirectory: true))
455
+ payload["files"] = [
456
+ "kokoroCoreMlModel": describeFile(bundle.appendingPathComponent("tts/kokoro-coreml/kokoro_5s.mlmodelc", isDirectory: true)),
457
+ "kokoroCoreMlVoice": describeFile(bundle.appendingPathComponent("tts/kokoro-coreml/voices/af_heart.json")),
458
+ "kokoroGgufModel": describeFile(bundle.appendingPathComponent("tts/kokoro/kokoro-82m-v1_0-Q4_K_M.gguf")),
459
+ "kokoroGgufVoice": describeFile(bundle.appendingPathComponent("tts/kokoro/voices/af_bella.bin")),
460
+ "legacyOmniVoiceBase": describeFile(bundle.appendingPathComponent("tts/omnivoice-base-Q4_K_M.gguf")),
461
+ "legacyOmniVoiceTokenizer": describeFile(bundle.appendingPathComponent("tts/omnivoice-tokenizer-Q4_K_M.gguf")),
462
+ "text": describeFile(bundle.appendingPathComponent("text/eliza-1-0_8b-128k.gguf")),
463
+ "asr": describeFile(bundle.appendingPathComponent("asr/eliza-1-asr.gguf")),
464
+ "manifest": describeFile(bundle.appendingPathComponent("eliza-1.manifest.json")),
465
+ ]
466
+ let ttsDir = bundle.appendingPathComponent("tts", isDirectory: true)
467
+ if let enumerator = fm.enumerator(
468
+ at: ttsDir,
469
+ includingPropertiesForKeys: [.isRegularFileKey],
470
+ options: [.skipsHiddenFiles]
471
+ ) {
472
+ var ggufFiles: [JSObject] = []
473
+ for case let url as URL in enumerator {
474
+ let values = try? url.resourceValues(forKeys: [.isRegularFileKey])
475
+ guard values?.isRegularFile == true, url.pathExtension.lowercased() == "gguf" else {
476
+ continue
477
+ }
478
+ ggufFiles.append(describeFile(url))
479
+ }
480
+ payload["ttsGgufFiles"] = ggufFiles
481
+ }
482
+ return payload
483
+ }
484
+
485
+ private func describeDirectory(_ url: URL) -> JSObject {
486
+ let fm = FileManager.default
487
+ var isDirectory: ObjCBool = false
488
+ let exists = fm.fileExists(atPath: url.path, isDirectory: &isDirectory)
489
+ return [
490
+ "path": url.path,
491
+ "exists": exists,
492
+ "isDirectory": exists && isDirectory.boolValue,
493
+ "readable": fm.isReadableFile(atPath: url.path),
494
+ ]
495
+ }
496
+
497
+ private func describeFile(_ url: URL) -> JSObject {
498
+ let fm = FileManager.default
499
+ var payload: JSObject = [
500
+ "path": url.path,
501
+ "name": url.lastPathComponent,
502
+ "exists": fm.fileExists(atPath: url.path),
503
+ "readable": fm.isReadableFile(atPath: url.path),
504
+ ]
505
+ if let attrs = try? fm.attributesOfItem(atPath: url.path),
506
+ let size = attrs[.size] as? NSNumber {
507
+ payload["bytes"] = size
508
+ }
509
+ return payload
510
+ }
511
+
512
+ private static func envFlag(_ name: String) -> Bool {
513
+ guard let raw = ProcessInfo.processInfo.environment[name]?.lowercased() else {
514
+ return false
515
+ }
516
+ return raw == "1" || raw == "true" || raw == "yes" || raw == "on"
517
+ }
518
+
519
+ private func jsObject(_ dict: [String: Any]) -> JSObject {
520
+ var payload: JSObject = [:]
521
+ for (key, value) in dict {
522
+ payload[key] = jsValue(value)
523
+ }
524
+ return payload
525
+ }
526
+
527
+ private func jsValue(_ value: Any) -> JSValue {
528
+ switch value {
529
+ case let value as String:
530
+ return value
531
+ case let value as Bool:
532
+ return value
533
+ case let value as Int:
534
+ return value
535
+ case let value as Int64:
536
+ return NSNumber(value: value)
537
+ case let value as UInt64:
538
+ return NSNumber(value: value)
539
+ case let value as Float:
540
+ return value
541
+ case let value as Double:
542
+ return value
543
+ case let value as NSNumber:
544
+ return value
545
+ case let value as [String: Any]:
546
+ return jsObject(value)
547
+ case let value as [Any]:
548
+ return value.map { jsValue($0) }
549
+ default:
550
+ return String(describing: value)
551
+ }
552
+ }
553
+
554
+ private func findKokoroBundle(in root: URL) -> URL? {
555
+ let fm = FileManager.default
556
+ guard let enumerator = fm.enumerator(
557
+ at: root,
558
+ includingPropertiesForKeys: [.isDirectoryKey],
559
+ options: [.skipsHiddenFiles]
560
+ ) else {
561
+ return nil
562
+ }
563
+ for case let url as URL in enumerator {
564
+ let values = try? url.resourceValues(forKeys: [.isDirectoryKey])
565
+ guard values?.isDirectory == true, url.pathExtension == "bundle" else {
566
+ continue
567
+ }
568
+ if hasKokoroBundle(url) { return url }
569
+ }
570
+ return nil
571
+ }
572
+
573
+ private func hasKokoroBundle(_ bundle: URL) -> Bool {
574
+ let coreMlDir = bundle
575
+ .appendingPathComponent("tts", isDirectory: true)
576
+ .appendingPathComponent("kokoro-coreml", isDirectory: true)
577
+ let fm = FileManager.default
578
+ let model = coreMlDir.appendingPathComponent("kokoro_5s.mlmodelc", isDirectory: true)
579
+ let voice = coreMlDir
580
+ .appendingPathComponent("voices", isDirectory: true)
581
+ .appendingPathComponent("af_heart.json")
582
+ return fm.fileExists(atPath: model.path) && fm.fileExists(atPath: voice.path)
583
+ }
584
+
585
+ private func modelId(for bundle: URL) -> String {
586
+ bundle.deletingPathExtension().lastPathComponent
587
+ }
588
+
589
+ // MARK: - Simulator full Bun smoke
590
+
591
+ private func fullBunLaunchEnvironment(isSmoke: Bool) -> [String: String] {
592
+ var env: [String: String] = [
593
+ "ELIZA_PLATFORM": "ios",
594
+ "ELIZA_MOBILE_PLATFORM": "ios",
595
+ "ELIZA_RUNTIME_MODE": IosRuntimePolicy.safeLocalExecutionMode,
596
+ "RUNTIME_MODE": IosRuntimePolicy.safeLocalExecutionMode,
597
+ "LOCAL_RUNTIME_MODE": IosRuntimePolicy.safeLocalExecutionMode,
598
+ "ELIZA_IOS_LOCAL_BACKEND": "1",
599
+ "ELIZA_IOS_BUN_STARTUP_TIMEOUT_MS": "60000",
600
+ "ELIZA_PGLITE_DISABLE_EXTENSIONS": "0",
601
+ "ELIZA_VAULT_BACKEND": "file",
602
+ "ELIZA_DISABLE_VAULT_PROFILE_RESOLVER": "1",
603
+ "ELIZA_DISABLE_AGENT_WALLET_BOOTSTRAP": "1",
604
+ "ELIZA_HEADLESS": "1",
605
+ "ELIZA_IOS_BRIDGE_TRANSPORT": "bun-host-ipc",
606
+ "LOG_LEVEL": "error",
607
+ ]
608
+ if isSmoke {
609
+ env["ELIZA_IOS_FULL_BUN_SMOKE"] = "1"
610
+ }
611
+ // Pass through opt-in diagnostic toggles set on the launch environment
612
+ // (e.g. via `devicectl process launch --environment-variables`) so a
613
+ // headless prewarm boot can run the on-device model-grind self-test.
614
+ for key in ["ELIZA_IOS_RUN_MODEL_GRIND"] {
615
+ if let value = ProcessInfo.processInfo.environment[key], !value.isEmpty {
616
+ env[key] = value
617
+ }
618
+ }
619
+ return IosRuntimePolicy.sanitizeEnvironment(env)
620
+ }
621
+
622
+ private func prewarmFullBunRuntimeIfRequested() {
623
+ guard !nativeSmokeStarted, !fullBunPrewarmStarted else { return }
624
+
625
+ let defaults = UserDefaults.standard
626
+ let webSmokeRequested =
627
+ ProcessInfo.processInfo.environment[Self.webFullBunSmokeEnvKey] == "1" ||
628
+ defaults.string(forKey: Self.webFullBunSmokeRequestKey) == "1"
629
+ guard webSmokeRequested else { return }
630
+
631
+ fullBunPrewarmStarted = true
632
+ let runtime = ensureRuntime()
633
+ if webSmokeRequested {
634
+ writeWebFullBunSmokeProgress([
635
+ "phase": "native-prewarm-starting",
636
+ "nativePrewarm": true,
637
+ ])
638
+ }
639
+ runtime.start(
640
+ bundlePath: nil,
641
+ polyfillPath: nil,
642
+ engine: "bun",
643
+ argv: ["bun", "--no-install", "public/agent/agent-bundle.js", "ios-bridge", "--stdio"],
644
+ env: fullBunLaunchEnvironment(isSmoke: webSmokeRequested)
645
+ ) { [weak self] result in
646
+ guard let self = self else { return }
647
+ switch result {
648
+ case .success:
649
+ if webSmokeRequested {
650
+ self.writeWebFullBunSmokeProgress([
651
+ "phase": "native-prewarm-started",
652
+ "nativePrewarm": true,
653
+ ])
654
+ self.pollWebFullBunPrewarmReady(runtime: runtime, startedAt: Date(), attempt: 0)
655
+ }
656
+ case .failure(let error):
657
+ self.fullBunPrewarmStarted = false
658
+ if webSmokeRequested {
659
+ self.writeWebFullBunSmokeProgress([
660
+ "ok": false,
661
+ "phase": "failed",
662
+ "nativePrewarm": true,
663
+ "error": "\(error)",
664
+ ])
665
+ } else {
666
+ NSLog("[ElizaBunRuntime] iOS full Bun prewarm failed: \(error)")
667
+ }
668
+ }
669
+ }
670
+ }
671
+
672
+ private func runNativeFullBunSmokeIfRequested() {
673
+ guard shouldRunNativeFullBunSmoke() else { return }
674
+ nativeSmokeStarted = true
675
+
676
+ let runtime = ensureRuntime()
677
+ writeFullBunSmokeResult([
678
+ "phase": "native-starting",
679
+ "nativeOnly": true,
680
+ ])
681
+ runtime.start(
682
+ bundlePath: nil,
683
+ polyfillPath: nil,
684
+ engine: "bun",
685
+ argv: ["bun", "--no-install", "public/agent/agent-bundle.js", "ios-bridge", "--stdio"],
686
+ env: fullBunLaunchEnvironment(isSmoke: true)
687
+ ) { [weak self, weak runtime] result in
688
+ guard let self = self, let runtime = runtime else { return }
689
+ switch result {
690
+ case .success:
691
+ self.runNativeFullBunRouteSmoke(runtime: runtime)
692
+ case .failure(let error):
693
+ self.writeFullBunSmokeFailure(error)
694
+ }
695
+ }
696
+ }
697
+
698
+ private func pollWebFullBunPrewarmReady(
699
+ runtime: ElizaBunRuntime,
700
+ startedAt: Date,
701
+ attempt: Int
702
+ ) {
703
+ dispatchSmokeCall(runtime: runtime, method: "status", args: ["timeoutMs": 5_000]) { [weak self, weak runtime] statusResult in
704
+ guard let self = self, let runtime = runtime else { return }
705
+ let elapsedMs = Int(Date().timeIntervalSince(startedAt) * 1000)
706
+ switch statusResult {
707
+ case .failure(let error):
708
+ if elapsedMs >= 300_000 {
709
+ self.writeWebFullBunSmokeProgress([
710
+ "ok": false,
711
+ "phase": "failed",
712
+ "nativePrewarm": true,
713
+ "error": error.localizedDescription,
714
+ "finishedAt": self.isoTimestamp(),
715
+ ])
716
+ return
717
+ }
718
+ self.writeWebFullBunSmokeProgress([
719
+ "phase": "native-prewarm-waiting-backend",
720
+ "nativePrewarm": true,
721
+ "elapsedMs": elapsedMs,
722
+ "attempt": attempt,
723
+ "lastStatusError": error.localizedDescription,
724
+ ])
725
+ self.scheduleWebFullBunPrewarmReadyPoll(runtime: runtime, startedAt: startedAt, attempt: attempt + 1)
726
+ case .success(let bridgeStatus):
727
+ if self.isBridgeStatusReady(bridgeStatus) {
728
+ self.writeWebFullBunSmokeProgress([
729
+ "phase": "native-prewarm-ready",
730
+ "nativePrewarm": true,
731
+ "elapsedMs": elapsedMs,
732
+ "attempt": attempt,
733
+ "engine": runtime.engineMode,
734
+ "bridgeVersion": runtime.bridgeVersion ?? NSNull(),
735
+ "bridgeStatus": Self.jsonSafe(bridgeStatus),
736
+ ])
737
+ return
738
+ }
739
+ if self.isBridgeStatusError(bridgeStatus) {
740
+ self.writeWebFullBunSmokeProgress([
741
+ "ok": false,
742
+ "phase": "failed",
743
+ "nativePrewarm": true,
744
+ "error": "iOS full Bun backend failed to boot: \(bridgeStatus ?? NSNull())",
745
+ "finishedAt": self.isoTimestamp(),
746
+ ])
747
+ return
748
+ }
749
+ if elapsedMs >= 300_000 {
750
+ self.writeWebFullBunSmokeProgress([
751
+ "ok": false,
752
+ "phase": "failed",
753
+ "nativePrewarm": true,
754
+ "error": "iOS full Bun backend did not become ready within 60000ms; last status: \(bridgeStatus ?? NSNull())",
755
+ "finishedAt": self.isoTimestamp(),
756
+ ])
757
+ return
758
+ }
759
+ self.writeWebFullBunSmokeProgress([
760
+ "phase": "native-prewarm-waiting-backend",
761
+ "nativePrewarm": true,
762
+ "elapsedMs": elapsedMs,
763
+ "attempt": attempt,
764
+ "bridgeStatus": Self.jsonSafe(bridgeStatus),
765
+ ])
766
+ self.scheduleWebFullBunPrewarmReadyPoll(runtime: runtime, startedAt: startedAt, attempt: attempt + 1)
767
+ }
768
+ }
769
+ }
770
+
771
+ private func scheduleWebFullBunPrewarmReadyPoll(
772
+ runtime: ElizaBunRuntime,
773
+ startedAt: Date,
774
+ attempt: Int
775
+ ) {
776
+ DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 2.0) { [weak self, weak runtime] in
777
+ guard let self = self, let runtime = runtime else { return }
778
+ self.pollWebFullBunPrewarmReady(runtime: runtime, startedAt: startedAt, attempt: attempt)
779
+ }
780
+ }
781
+
782
+ private func runNativeFullBunSmokeAfterSuccessfulStartIfRequested(runtime: ElizaBunRuntime) {
783
+ guard shouldRunNativeFullBunSmoke() else { return }
784
+ nativeSmokeStarted = true
785
+ writeFullBunSmokeProgress([
786
+ "phase": "native-route-smoke-starting",
787
+ "nativeOnly": true,
788
+ ])
789
+ runNativeFullBunRouteSmoke(runtime: runtime)
790
+ }
791
+
792
+ private func shouldRunNativeFullBunSmoke() -> Bool {
793
+ guard !nativeSmokeStarted else { return false }
794
+ if ProcessInfo.processInfo.environment[Self.fullBunSmokeEnvKey] == "1" {
795
+ return true
796
+ }
797
+ return UserDefaults.standard.string(forKey: Self.fullBunSmokeRequestKey) == "1"
798
+ }
799
+
800
+ private func runNativeFullBunRouteSmoke(runtime: ElizaBunRuntime) {
801
+ pollNativeFullBunBridgeReady(runtime: runtime, startedAt: Date(), attempt: 0)
802
+ }
803
+
804
+ private func pollNativeFullBunBridgeReady(
805
+ runtime: ElizaBunRuntime,
806
+ startedAt: Date,
807
+ attempt: Int
808
+ ) {
809
+ dispatchSmokeCall(runtime: runtime, method: "status", args: ["timeoutMs": 5_000]) { [weak self, weak runtime] statusResult in
810
+ guard let self = self, let runtime = runtime else { return }
811
+ let elapsedMs = Int(Date().timeIntervalSince(startedAt) * 1000)
812
+ switch statusResult {
813
+ case .failure(let error):
814
+ if elapsedMs >= 300_000 {
815
+ self.writeFullBunSmokeFailure(error)
816
+ return
817
+ }
818
+ self.writeFullBunSmokeProgress([
819
+ "phase": "native-waiting-backend",
820
+ "nativeOnly": true,
821
+ "elapsedMs": elapsedMs,
822
+ "attempt": attempt,
823
+ "lastStatusError": error.localizedDescription,
824
+ ])
825
+ self.scheduleNativeBridgeReadyPoll(runtime: runtime, startedAt: startedAt, attempt: attempt + 1)
826
+ case .success(let bridgeStatus):
827
+ if self.isBridgeStatusReady(bridgeStatus) {
828
+ self.runNativeFullBunHealthSmoke(runtime: runtime, bridgeStatus: bridgeStatus)
829
+ return
830
+ }
831
+ if self.isBridgeStatusError(bridgeStatus) {
832
+ self.writeFullBunSmokeFailure(
833
+ self.makeSmokeError("native full Bun backend failed to boot: \(bridgeStatus ?? NSNull())")
834
+ )
835
+ return
836
+ }
837
+ if elapsedMs >= 300_000 {
838
+ self.writeFullBunSmokeFailure(
839
+ self.makeSmokeError("native full Bun backend did not become ready within 60000ms; last status: \(bridgeStatus ?? NSNull())")
840
+ )
841
+ return
842
+ }
843
+ self.writeFullBunSmokeProgress([
844
+ "phase": "native-waiting-backend",
845
+ "nativeOnly": true,
846
+ "elapsedMs": elapsedMs,
847
+ "attempt": attempt,
848
+ "bridgeStatus": Self.jsonSafe(bridgeStatus),
849
+ ])
850
+ self.scheduleNativeBridgeReadyPoll(runtime: runtime, startedAt: startedAt, attempt: attempt + 1)
851
+ }
852
+ }
853
+ }
854
+
855
+ private func scheduleNativeBridgeReadyPoll(
856
+ runtime: ElizaBunRuntime,
857
+ startedAt: Date,
858
+ attempt: Int
859
+ ) {
860
+ DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 2.0) { [weak self, weak runtime] in
861
+ guard let self = self, let runtime = runtime else { return }
862
+ self.pollNativeFullBunBridgeReady(runtime: runtime, startedAt: startedAt, attempt: attempt)
863
+ }
864
+ }
865
+
866
+ private func runNativeFullBunHealthSmoke(runtime: ElizaBunRuntime, bridgeStatus: Any?) {
867
+ let healthArgs: [String: Any] = [
868
+ "method": "GET",
869
+ "path": "/api/health",
870
+ "headers": ["accept": "application/json"],
871
+ "timeoutMs": 120_000,
872
+ ]
873
+ dispatchSmokeCall(runtime: runtime, method: "http_request", args: healthArgs) { [weak self, weak runtime] healthResult in
874
+ guard let self = self, let runtime = runtime else { return }
875
+ switch healthResult {
876
+ case .failure(let error):
877
+ self.writeFullBunSmokeFailure(error)
878
+ case .success(let healthResponse):
879
+ do {
880
+ let healthJson = try self.parseSmokeHttpJSON(
881
+ label: "native full Bun /api/health",
882
+ value: healthResponse
883
+ )
884
+ guard healthJson["ready"] as? Bool == true,
885
+ healthJson["runtime"] as? String == "ok" else {
886
+ throw self.makeSmokeError(
887
+ "native full Bun /api/health returned unexpected body: \(healthJson)"
888
+ )
889
+ }
890
+ self.createNativeSmokeConversation(
891
+ runtime: runtime,
892
+ bridgeStatus: bridgeStatus,
893
+ health: healthJson
894
+ )
895
+ } catch {
896
+ self.writeFullBunSmokeFailure(error)
897
+ }
898
+ }
899
+ }
900
+ }
901
+
902
+ private func isBridgeStatusReady(_ value: Any?) -> Bool {
903
+ guard let dict = value as? [String: Any] else { return false }
904
+ if let ready = dict["ready"] as? Bool { return ready }
905
+ if let ready = dict["ready"] as? NSNumber { return ready.boolValue }
906
+ return false
907
+ }
908
+
909
+ private func isBridgeStatusError(_ value: Any?) -> Bool {
910
+ guard let dict = value as? [String: Any] else { return false }
911
+ return dict["phase"] as? String == "error"
912
+ }
913
+
914
+ private func createNativeSmokeConversation(
915
+ runtime: ElizaBunRuntime,
916
+ bridgeStatus: Any?,
917
+ health: [String: Any]
918
+ ) {
919
+ let createArgs: [String: Any] = [
920
+ "method": "POST",
921
+ "path": "/api/conversations",
922
+ "headers": [
923
+ "accept": "application/json",
924
+ "content-type": "application/json",
925
+ ],
926
+ "body": "{\"title\":\"iOS Full Bun Native Smoke\"}",
927
+ "timeoutMs": 120_000,
928
+ ]
929
+ dispatchSmokeCall(runtime: runtime, method: "http_request", args: createArgs) { [weak self, weak runtime] createResult in
930
+ guard let self = self, let runtime = runtime else { return }
931
+ switch createResult {
932
+ case .failure(let error):
933
+ self.writeFullBunSmokeFailure(error)
934
+ case .success(let createResponse):
935
+ do {
936
+ let createJson = try self.parseSmokeHttpJSON(
937
+ label: "native full Bun POST /api/conversations",
938
+ value: createResponse
939
+ )
940
+ guard let conversation = createJson["conversation"] as? [String: Any],
941
+ let conversationId = conversation["id"] as? String,
942
+ !conversationId.isEmpty else {
943
+ throw self.makeSmokeError("native full Bun conversation create did not return an id")
944
+ }
945
+ self.sendNativeSmokeMessage(
946
+ runtime: runtime,
947
+ bridgeStatus: bridgeStatus,
948
+ health: health,
949
+ conversationId: conversationId
950
+ )
951
+ } catch {
952
+ self.writeFullBunSmokeFailure(error)
953
+ }
954
+ }
955
+ }
956
+ }
957
+
958
+ private func sendNativeSmokeMessage(
959
+ runtime: ElizaBunRuntime,
960
+ bridgeStatus: Any?,
961
+ health: [String: Any],
962
+ conversationId: String
963
+ ) {
964
+ let messageArgs: [String: Any] = [
965
+ "message": "iOS full Bun native smoke",
966
+ "conversationId": conversationId,
967
+ "metadata": ["smoke": "ios-full-bun-native"],
968
+ "timeoutMs": 600_000,
969
+ ]
970
+ dispatchSmokeCall(runtime: runtime, method: "send_message", args: messageArgs) { [weak self] messageResult in
971
+ guard let self = self else { return }
972
+ switch messageResult {
973
+ case .failure(let error):
974
+ self.writeFullBunSmokeFailure(error)
975
+ case .success(let sendMessage):
976
+ self.writeFullBunSmokeProgress([
977
+ "ok": true,
978
+ "phase": "native-complete",
979
+ "nativeOnly": true,
980
+ "finishedAt": self.isoTimestamp(),
981
+ "engine": runtime.engineMode,
982
+ "bridgeVersion": runtime.bridgeVersion ?? NSNull(),
983
+ "bridgeStatus": Self.jsonSafe(bridgeStatus),
984
+ "health": Self.jsonSafe(health),
985
+ "conversationId": conversationId,
986
+ "sendMessage": Self.jsonSafe(sendMessage),
987
+ ])
988
+ }
989
+ }
990
+ }
991
+
992
+ private func dispatchSmokeCall(
993
+ runtime: ElizaBunRuntime,
994
+ method: String,
995
+ args: Any?,
996
+ completion: @escaping (Result<Any?, Error>) -> Void
997
+ ) {
998
+ runtime.dispatchHandler(method: method, args: args) { result in
999
+ completion(result)
1000
+ }
1001
+ }
1002
+
1003
+ private func parseSmokeHttpJSON(label: String, value: Any?) throws -> [String: Any] {
1004
+ guard let response = value as? [String: Any] else {
1005
+ throw makeSmokeError("\(label) did not return an object")
1006
+ }
1007
+ let status = (response["status"] as? NSNumber)?.intValue ?? response["status"] as? Int
1008
+ guard let status = status, status >= 200, status < 300 else {
1009
+ throw makeSmokeError("\(label) returned HTTP \(String(describing: response["status"]))")
1010
+ }
1011
+ guard let body = response["body"] as? String,
1012
+ let data = body.data(using: .utf8),
1013
+ let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
1014
+ throw makeSmokeError("\(label) returned invalid JSON body")
1015
+ }
1016
+ return json
1017
+ }
1018
+
1019
+ private func writeFullBunSmokeFailure(_ error: Error) {
1020
+ writeFullBunSmokeResult([
1021
+ "ok": false,
1022
+ "phase": "failed",
1023
+ "nativeOnly": true,
1024
+ "error": error.localizedDescription,
1025
+ "finishedAt": isoTimestamp(),
1026
+ ])
1027
+ UserDefaults.standard.removeObject(forKey: Self.fullBunSmokeRequestKey)
1028
+ UserDefaults.standard.synchronize()
1029
+ }
1030
+
1031
+ private func writeFullBunSmokeProgress(_ result: [String: Any]) {
1032
+ if let existing = UserDefaults.standard.string(forKey: Self.fullBunSmokeResultKey),
1033
+ let data = existing.data(using: .utf8),
1034
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
1035
+ json["ok"] as? Bool == true {
1036
+ return
1037
+ }
1038
+ writeFullBunSmokeResult(result)
1039
+ if result["ok"] as? Bool == true {
1040
+ UserDefaults.standard.removeObject(forKey: Self.fullBunSmokeRequestKey)
1041
+ UserDefaults.standard.synchronize()
1042
+ }
1043
+ }
1044
+
1045
+ private func writeFullBunSmokeResult(_ result: [String: Any]) {
1046
+ var payload = result
1047
+ payload["updatedAt"] = isoTimestamp()
1048
+ let safePayload = Self.jsonSafe(payload)
1049
+ guard JSONSerialization.isValidJSONObject(safePayload),
1050
+ let data = try? JSONSerialization.data(withJSONObject: safePayload),
1051
+ let json = String(data: data, encoding: .utf8) else {
1052
+ return
1053
+ }
1054
+ UserDefaults.standard.set(json, forKey: Self.fullBunSmokeResultKey)
1055
+ UserDefaults.standard.synchronize()
1056
+ }
1057
+
1058
+ private func writeWebFullBunSmokeProgress(_ result: [String: Any]) {
1059
+ var payload = result
1060
+ payload["updatedAt"] = isoTimestamp()
1061
+ let safePayload = Self.jsonSafe(payload)
1062
+ guard JSONSerialization.isValidJSONObject(safePayload),
1063
+ let data = try? JSONSerialization.data(withJSONObject: safePayload),
1064
+ let json = String(data: data, encoding: .utf8) else {
1065
+ return
1066
+ }
1067
+ UserDefaults.standard.set(json, forKey: Self.webFullBunPrewarmResultKey)
1068
+ UserDefaults.standard.synchronize()
1069
+ }
1070
+
1071
+ private func makeSmokeError(_ message: String) -> NSError {
1072
+ NSError(
1073
+ domain: "ElizaBunRuntimeSmoke",
1074
+ code: -1,
1075
+ userInfo: [NSLocalizedDescriptionKey: message]
1076
+ )
1077
+ }
1078
+
1079
+ private func isoTimestamp() -> String {
1080
+ ISO8601DateFormatter().string(from: Date())
1081
+ }
1082
+
1083
+ /// Capacitor's bridge serializes a known set of Foundation types
1084
+ /// (`NSString`, `NSNumber`, `NSArray`, `NSDictionary`, `NSNull`). Other
1085
+ /// types get coerced to their string description so the React side
1086
+ /// always sees something.
1087
+ private static func jsonSafe(_ value: Any?) -> Any {
1088
+ guard let value = value else { return NSNull() }
1089
+ if value is NSNull { return NSNull() }
1090
+ if let s = value as? String { return s }
1091
+ if let n = value as? NSNumber { return n }
1092
+ if let arr = value as? [Any] { return arr.map { jsonSafe($0) } }
1093
+ if let dict = value as? [String: Any] {
1094
+ var out: [String: Any] = [:]
1095
+ for (k, v) in dict { out[k] = jsonSafe(v) }
1096
+ return out
1097
+ }
1098
+ return String(describing: value)
1099
+ }
1100
+ }
1101
+
1102
+ // Compatibility helper for `call.getArray<T>` typed access on older Capacitor
1103
+ // builds that don't expose the generic form.
1104
+ extension CAPPluginCall {
1105
+ func getArray<T>(_ key: String, _: T.Type) -> [T]? {
1106
+ guard let raw = self.getArray(key) else { return nil }
1107
+ return raw.compactMap { $0 as? T }
1108
+ }
1109
+ }