@glassly/engine 0.3.3-dev.670 → 0.3.4-dev.675

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 (115) hide show
  1. package/build/engine.d.ts +38 -14
  2. package/build/engine.d.ts.map +1 -1
  3. package/build/engine.js +3 -0
  4. package/build/engine.js.map +1 -1
  5. package/build/facades/ai.d.ts +32 -0
  6. package/build/facades/ai.d.ts.map +1 -0
  7. package/build/facades/ai.js +38 -0
  8. package/build/facades/ai.js.map +1 -0
  9. package/build/facades/glasses.d.ts +30 -24
  10. package/build/facades/glasses.d.ts.map +1 -1
  11. package/build/facades/glasses.js +49 -30
  12. package/build/facades/glasses.js.map +1 -1
  13. package/build/facades/glassesSettings.d.ts.map +1 -1
  14. package/build/facades/glassesSettings.js +1 -0
  15. package/build/facades/glassesSettings.js.map +1 -1
  16. package/build/facades/pairing.d.ts +6 -5
  17. package/build/facades/pairing.d.ts.map +1 -1
  18. package/build/facades/pairing.js +14 -14
  19. package/build/facades/pairing.js.map +1 -1
  20. package/build/generated/releaseMetadata.js +4 -4
  21. package/build/generated/releaseMetadata.js.map +1 -1
  22. package/build/index.d.ts +5 -2
  23. package/build/index.d.ts.map +1 -1
  24. package/build/index.js +2 -0
  25. package/build/index.js.map +1 -1
  26. package/build/services/DeviceEventRouter.d.ts.map +1 -1
  27. package/build/services/DeviceEventRouter.js +9 -1
  28. package/build/services/DeviceEventRouter.js.map +1 -1
  29. package/build/services/GestureDotsService.d.ts +4 -3
  30. package/build/services/GestureDotsService.d.ts.map +1 -1
  31. package/build/services/GestureDotsService.js +17 -12
  32. package/build/services/GestureDotsService.js.map +1 -1
  33. package/build/services/GlassesTouchInput.d.ts.map +1 -1
  34. package/build/services/GlassesTouchInput.js +11 -9
  35. package/build/services/GlassesTouchInput.js.map +1 -1
  36. package/build/services/LlmRouting.d.ts +35 -0
  37. package/build/services/LlmRouting.d.ts.map +1 -0
  38. package/build/services/LlmRouting.js +25 -0
  39. package/build/services/LlmRouting.js.map +1 -0
  40. package/build/services/LocalLlmModelManager.d.ts +79 -0
  41. package/build/services/LocalLlmModelManager.d.ts.map +1 -0
  42. package/build/services/LocalLlmModelManager.js +278 -0
  43. package/build/services/LocalLlmModelManager.js.map +1 -0
  44. package/build/services/LocalLlmPrompt.d.ts +90 -0
  45. package/build/services/LocalLlmPrompt.d.ts.map +1 -0
  46. package/build/services/LocalLlmPrompt.js +189 -0
  47. package/build/services/LocalLlmPrompt.js.map +1 -0
  48. package/build/services/LocalLlmProvider.d.ts +35 -0
  49. package/build/services/LocalLlmProvider.d.ts.map +1 -0
  50. package/build/services/LocalLlmProvider.js +218 -0
  51. package/build/services/LocalLlmProvider.js.map +1 -0
  52. package/build/services/LocalMiniappRuntime.d.ts +9 -1
  53. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  54. package/build/services/LocalMiniappRuntime.js +91 -17
  55. package/build/services/LocalMiniappRuntime.js.map +1 -1
  56. package/build/services/STTModelManager.d.ts +25 -2
  57. package/build/services/STTModelManager.d.ts.map +1 -1
  58. package/build/services/STTModelManager.js +137 -6
  59. package/build/services/STTModelManager.js.map +1 -1
  60. package/build/services/controllers.d.ts +21 -0
  61. package/build/services/controllers.d.ts.map +1 -0
  62. package/build/services/controllers.js +62 -0
  63. package/build/services/controllers.js.map +1 -0
  64. package/build/services/effectiveCapabilities.d.ts.map +1 -1
  65. package/build/services/effectiveCapabilities.js +18 -24
  66. package/build/services/effectiveCapabilities.js.map +1 -1
  67. package/build/services/neuralBand/NeuralBandInput.d.ts +17 -6
  68. package/build/services/neuralBand/NeuralBandInput.d.ts.map +1 -1
  69. package/build/services/neuralBand/NeuralBandInput.js +40 -23
  70. package/build/services/neuralBand/NeuralBandInput.js.map +1 -1
  71. package/build/services/neuralBand/NeuralBandSettingsSync.d.ts.map +1 -1
  72. package/build/services/neuralBand/NeuralBandSettingsSync.js +23 -26
  73. package/build/services/neuralBand/NeuralBandSettingsSync.js.map +1 -1
  74. package/build/services/settingsBridge.d.ts +26 -18
  75. package/build/services/settingsBridge.d.ts.map +1 -1
  76. package/build/services/settingsBridge.js +36 -15
  77. package/build/services/settingsBridge.js.map +1 -1
  78. package/build/stores/bluetoothSettingKeys.d.ts.map +1 -1
  79. package/build/stores/bluetoothSettingKeys.js +3 -1
  80. package/build/stores/bluetoothSettingKeys.js.map +1 -1
  81. package/build/stores/core.d.ts.map +1 -1
  82. package/build/stores/core.js +0 -1
  83. package/build/stores/core.js.map +1 -1
  84. package/build/stores/glasses.d.ts.map +1 -1
  85. package/build/stores/glasses.js +2 -6
  86. package/build/stores/glasses.js.map +1 -1
  87. package/build/stores/settings.d.ts.map +1 -1
  88. package/build/stores/settings.js +28 -2
  89. package/build/stores/settings.js.map +1 -1
  90. package/package.json +12 -8
  91. package/src/engine.ts +3 -0
  92. package/src/facades/ai.ts +43 -0
  93. package/src/facades/glasses.ts +60 -31
  94. package/src/facades/glassesSettings.ts +1 -0
  95. package/src/facades/pairing.ts +18 -14
  96. package/src/generated/releaseMetadata.ts +4 -4
  97. package/src/index.ts +16 -2
  98. package/src/services/DeviceEventRouter.ts +8 -1
  99. package/src/services/GestureDotsService.ts +20 -14
  100. package/src/services/GlassesTouchInput.ts +11 -9
  101. package/src/services/LlmRouting.ts +49 -0
  102. package/src/services/LocalLlmModelManager.ts +327 -0
  103. package/src/services/LocalLlmPrompt.ts +246 -0
  104. package/src/services/LocalLlmProvider.ts +290 -0
  105. package/src/services/LocalMiniappRuntime.ts +137 -21
  106. package/src/services/STTModelManager.ts +140 -8
  107. package/src/services/controllers.ts +77 -0
  108. package/src/services/effectiveCapabilities.ts +16 -32
  109. package/src/services/neuralBand/NeuralBandInput.ts +50 -26
  110. package/src/services/neuralBand/NeuralBandSettingsSync.ts +28 -26
  111. package/src/services/settingsBridge.ts +60 -22
  112. package/src/stores/bluetoothSettingKeys.ts +3 -1
  113. package/src/stores/core.ts +0 -1
  114. package/src/stores/glasses.ts +2 -6
  115. package/src/stores/settings.ts +28 -2
@@ -0,0 +1,290 @@
1
+ /**
2
+ * LocalLlmProvider — session.ai.complete served by the phone itself: an
3
+ * ExecuTorch runner (react-native-executorch) over the Qwen3 export that
4
+ * LocalLlmModelManager downloaded. Stateless per request like the vendor
5
+ * providers: every call renders the whole conversation through the model's
6
+ * chat template, resets the KV cache, and generates on ExecuTorch's worklet
7
+ * thread so the JS thread stays free.
8
+ *
9
+ * The runner is loaded on the first request and kept resident while calls
10
+ * keep coming (loading a 1–3 GB model is seconds, not milliseconds); it is
11
+ * dropped after an idle period and whenever the selected model changes.
12
+ * Requests are serialized — one model, one KV cache.
13
+ *
14
+ * react-native-executorch is imported lazily so the engine module graph
15
+ * loads without the native runtime (bun tests, hosts without the dependency).
16
+ */
17
+ import * as RNFS from "@dr.pogodin/react-native-fs"
18
+ import {Template} from "@huggingface/jinja"
19
+
20
+ import type {LlmCompleteRequest} from "@glassly/cloud-protocol/llm"
21
+ import {BgTimer} from "../utils/timers"
22
+ import localLlmModelManager, {type LocalLlmModelId} from "./LocalLlmModelManager"
23
+ import {
24
+ buildTemplateMessages,
25
+ estimateTokens,
26
+ finalizeLocalResult,
27
+ fitToBudget,
28
+ toTemplateTools,
29
+ type LocalCompletion,
30
+ } from "./LocalLlmPrompt"
31
+ import {SETTINGS, useSettingsStore} from "../stores/settings"
32
+
33
+ type Rne = typeof import("react-native-executorch")
34
+ // The runner API lives on the `llm` namespace; derive its types from there so
35
+ // the import stays a type-only reference (no native module at load time).
36
+ type LLMRunner = ReturnType<Rne["llm"]["createLLMRunner"]>
37
+ type LLMGenerationStats = ReturnType<LLMRunner["generate"]>
38
+ type LLMGenerationConfig = NonNullable<Parameters<LLMRunner["generate"]>[1]>
39
+
40
+ const LOG_TAG = "LOCAL_LLM"
41
+
42
+ /** Drop the resident model after this long without a request. */
43
+ const IDLE_UNLOAD_MS = 5 * 60_000
44
+ /** Hard stop on one generation — the SDK request times out at 120 s anyway. */
45
+ const GENERATION_DEADLINE_MS = 100_000
46
+ const DEFAULT_MAX_NEW_TOKENS = 512
47
+ const MAX_NEW_TOKENS_CEILING = 1_024
48
+ /** Headroom between the estimated prompt and the context end; estimates are rough. */
49
+ const CONTEXT_MARGIN_TOKENS = 96
50
+
51
+ interface LoadedModel {
52
+ modelId: LocalLlmModelId
53
+ runner: LLMRunner
54
+ template: Template
55
+ stopTokens: string[]
56
+ maxSeqLen: number
57
+ generate: (
58
+ runner: LLMRunner,
59
+ prompt: string,
60
+ config: LLMGenerationConfig,
61
+ stopTokens: string[],
62
+ ) => Promise<{text: string; stats: LLMGenerationStats}>
63
+ }
64
+
65
+ export interface LocalLlmCompleteResult extends LocalCompletion {
66
+ modelId: LocalLlmModelId
67
+ }
68
+
69
+ /**
70
+ * Runs on ExecuTorch's worklet runtime: `runner.generate` blocks its thread
71
+ * until EOS, `maxNewTokens`, or `runner.stop()` from the JS thread.
72
+ */
73
+ function generateWorklet(
74
+ runner: LLMRunner,
75
+ prompt: string,
76
+ config: LLMGenerationConfig,
77
+ stopTokens: string[],
78
+ ): {text: string; stats: LLMGenerationStats} {
79
+ "worklet"
80
+ let text = ""
81
+ const stats = runner.generate(prompt, config, (token: string) => {
82
+ if (stopTokens.includes(token)) return
83
+ text += token
84
+ })
85
+ return {text, stats}
86
+ }
87
+
88
+ class LocalLlmProvider {
89
+ private static instance: LocalLlmProvider
90
+
91
+ private loaded: LoadedModel | null = null
92
+ private loading: Promise<LoadedModel> | null = null
93
+ private queue: Promise<unknown> = Promise.resolve()
94
+ private idleTimer: number | null = null
95
+ private rne: Rne | null = null
96
+
97
+ private constructor() {
98
+ // A different model picked in Settings invalidates the resident runner.
99
+ useSettingsStore.subscribe(
100
+ (state) => state.settings[SETTINGS.local_llm_model.key],
101
+ () => {
102
+ void this.unload()
103
+ },
104
+ )
105
+ }
106
+
107
+ static getInstance(): LocalLlmProvider {
108
+ if (!LocalLlmProvider.instance) LocalLlmProvider.instance = new LocalLlmProvider()
109
+ return LocalLlmProvider.instance
110
+ }
111
+
112
+ /** Whether a runner is resident right now (diagnostics; not a readiness check). */
113
+ isLoaded(): boolean {
114
+ return this.loaded !== null
115
+ }
116
+
117
+ loadedModelId(): LocalLlmModelId | null {
118
+ return this.loaded?.modelId ?? null
119
+ }
120
+
121
+ /** Serve one neutral completion on-device. Throws when the model is missing or generation fails. */
122
+ complete(request: LlmCompleteRequest): Promise<LocalLlmCompleteResult> {
123
+ const run = this.queue.then(() => this.completeInner(request))
124
+ this.queue = run.catch(() => undefined)
125
+ return run
126
+ }
127
+
128
+ /** Release the resident runner (idle, model switch, memory pressure). Safe to call repeatedly. */
129
+ async unload(): Promise<void> {
130
+ this.clearIdleTimer()
131
+ // Never dispose under a running generation: let the queue drain first.
132
+ await this.queue
133
+ const pending = this.loading
134
+ if (pending) {
135
+ try {
136
+ await pending
137
+ } catch {
138
+ // A failed load left nothing to release.
139
+ }
140
+ }
141
+ this.releaseRunner()
142
+ }
143
+
144
+ /** Synchronous dispose; callers guarantee no generation is in flight. */
145
+ private releaseRunner(): void {
146
+ const loaded = this.loaded
147
+ this.loaded = null
148
+ if (!loaded) return
149
+ try {
150
+ loaded.runner.dispose()
151
+ } catch (error) {
152
+ console.warn(`${LOG_TAG}: runner dispose failed:`, error)
153
+ }
154
+ }
155
+
156
+ private async completeInner(request: LlmCompleteRequest): Promise<LocalLlmCompleteResult> {
157
+ this.clearIdleTimer()
158
+ try {
159
+ const model = await this.ensureLoaded()
160
+ const jsonMode = request.jsonMode === true
161
+ const tools = toTemplateTools(request.tools)
162
+ const toolTokens = tools ? estimateTokens(JSON.stringify(tools)) + 64 : 0
163
+
164
+ const maxNewTokens = Math.max(
165
+ 16,
166
+ Math.min(request.maxTokens ?? DEFAULT_MAX_NEW_TOKENS, MAX_NEW_TOKENS_CEILING, Math.floor(model.maxSeqLen / 4)),
167
+ )
168
+ const promptBudget = model.maxSeqLen - maxNewTokens - CONTEXT_MARGIN_TOKENS
169
+ const messages = fitToBudget(buildTemplateMessages({...request, jsonMode}), promptBudget, toolTokens)
170
+
171
+ const prompt = model.template.render({
172
+ messages,
173
+ tools,
174
+ add_generation_prompt: true,
175
+ enable_thinking: false,
176
+ })
177
+
178
+ model.runner.reset(0)
179
+ const deadline = BgTimer.setTimeout(() => {
180
+ console.warn(`${LOG_TAG}: generation deadline hit, stopping`)
181
+ model.runner.stop()
182
+ }, GENERATION_DEADLINE_MS)
183
+ let generated: {text: string; stats: LLMGenerationStats}
184
+ const startedAt = Date.now()
185
+ try {
186
+ generated = await model.generate(
187
+ model.runner,
188
+ prompt,
189
+ {maxNewTokens, temperature: request.temperature ?? 0.7},
190
+ model.stopTokens,
191
+ )
192
+ } finally {
193
+ BgTimer.clearTimeout(deadline)
194
+ }
195
+
196
+ const elapsedMs = Date.now() - startedAt
197
+ console.log(
198
+ `${LOG_TAG}: ${model.modelId} prompt=${generated.stats.numPromptTokens} out=${generated.stats.numGeneratedTokens} tokens in ${elapsedMs} ms`,
199
+ )
200
+
201
+ const seed = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
202
+ const completion = finalizeLocalResult(
203
+ {
204
+ text: generated.text,
205
+ promptTokens: generated.stats.numPromptTokens,
206
+ generatedTokens: generated.stats.numGeneratedTokens,
207
+ maxNewTokens,
208
+ },
209
+ {jsonMode, toolsOffered: Boolean(tools), makeToolCallId: (index) => `local_${seed}_${index}`},
210
+ )
211
+ return {...completion, modelId: model.modelId}
212
+ } finally {
213
+ this.armIdleTimer()
214
+ }
215
+ }
216
+
217
+ private async ensureLoaded(): Promise<LoadedModel> {
218
+ const modelId = localLlmModelManager.selectedModelId()
219
+ if (this.loaded?.modelId === modelId) return this.loaded
220
+ if (this.loading) return this.loading
221
+
222
+ this.loading = this.load(modelId)
223
+ .then((model) => {
224
+ this.loaded = model
225
+ return model
226
+ })
227
+ .finally(() => {
228
+ this.loading = null
229
+ })
230
+ return this.loading
231
+ }
232
+
233
+ private async load(modelId: LocalLlmModelId): Promise<LoadedModel> {
234
+ // Runs inside the request queue, so nothing is generating; unload() would
235
+ // wait on that same queue.
236
+ this.releaseRunner()
237
+ if (!(await localLlmModelManager.isModelAvailable(modelId))) {
238
+ throw new Error(`on-device model ${modelId} is not downloaded`)
239
+ }
240
+ const rne = await this.runtime()
241
+ const paths = localLlmModelManager.paths(modelId)
242
+
243
+ const tokenizerConfig = rne.llm.parseTokenizerConfig(JSON.parse(await RNFS.readFile(paths.tokenizerConfig, "utf8")))
244
+ const template = new Template(tokenizerConfig.chatTemplate)
245
+
246
+ const startedAt = Date.now()
247
+ const createRunner = rne.wrapAsync(rne.llm.createLLMRunner)
248
+ const runner = await createRunner(paths.model, paths.tokenizer, [])
249
+ const maxSeqLen = runner.getKVCacheState().maxSeqLen
250
+ console.log(`${LOG_TAG}: loaded ${modelId} (context ${maxSeqLen}) in ${Date.now() - startedAt} ms`)
251
+
252
+ return {
253
+ modelId,
254
+ runner,
255
+ template,
256
+ stopTokens: [...tokenizerConfig.stopTokens],
257
+ maxSeqLen,
258
+ generate: rne.wrapAsync(generateWorklet),
259
+ }
260
+ }
261
+
262
+ private async runtime(): Promise<Rne> {
263
+ if (this.rne) return this.rne
264
+ const rne: Rne = await import("react-native-executorch")
265
+ // Software Mansion's anonymous download analytics; the host has its own telemetry.
266
+ rne.setTelemetryEnabled(false)
267
+ this.rne = rne
268
+ return rne
269
+ }
270
+
271
+ private armIdleTimer(): void {
272
+ this.clearIdleTimer()
273
+ this.idleTimer = BgTimer.setTimeout(() => {
274
+ this.idleTimer = null
275
+ console.log(`${LOG_TAG}: idle, unloading ${this.loaded?.modelId ?? "nothing"}`)
276
+ void this.unload()
277
+ }, IDLE_UNLOAD_MS)
278
+ }
279
+
280
+ private clearIdleTimer(): void {
281
+ if (this.idleTimer) {
282
+ BgTimer.clearTimeout(this.idleTimer)
283
+ this.idleTimer = null
284
+ }
285
+ }
286
+ }
287
+
288
+ const localLlmProvider = LocalLlmProvider.getInstance()
289
+ export {LocalLlmProvider}
290
+ export default localLlmProvider
@@ -53,8 +53,17 @@ import {
53
53
 
54
54
  import {normalizeAiTier, resolveAiTier} from "./AiTierResolution"
55
55
  import type {AiTier, AiTierProvider, SavedLlmKeys} from "./AiTierResolution"
56
+ import {decideLlmRoute, localLlmReason, type LocalLlmReason} from "./LlmRouting"
57
+ import localLlmModelManager from "./LocalLlmModelManager"
58
+ import localLlmProvider from "./LocalLlmProvider"
56
59
  import {HttpError} from "@glassly/cloud-client"
57
- import type {QuotaStatusPayload} from "@glassly/cloud-protocol"
60
+ import type {
61
+ QuotaStatusPayload,
62
+ AudioSubscription,
63
+ LanguageSource,
64
+ TranscriptionData,
65
+ TranslationData,
66
+ } from "@glassly/cloud-protocol"
58
67
  import localDisplayManager from "./LocalDisplayManager"
59
68
  import sttModelManager from "./STTModelManager"
60
69
  import localTranslationService, {type LocalTranslationSubscription} from "./LocalTranslationService"
@@ -73,12 +82,12 @@ import dashboardOpenTrace from "./debug/DashboardOpenTrace"
73
82
  import {isAppForegrounded, isPhoneActive, onAppForegroundChange, onPhoneActivityChange} from "./PhoneActivity"
74
83
  import {installedNotificationApps} from "./PhoneNotificationsSync"
75
84
  import {MiniappIcons} from "./MiniappIcons"
85
+ import {pairedControllersFrom, readyControllersKey} from "./controllers"
76
86
  import {
77
87
  DERIVED_SETTINGS_KEYS,
78
88
  SETTINGS_BRIDGE_KEYS,
79
- input2dFrom,
89
+ controllersSettingFrom,
80
90
  isDerivedSettingsKey,
81
- ringBatteryFrom,
82
91
  selectSettingsBridgeKeys,
83
92
  validateSettingsSet,
84
93
  type DerivedSettingsKey,
@@ -136,7 +145,6 @@ import {invokeScanQrSeam} from "../runtime/scanQrSeam"
136
145
  import {normalizeStreamAudioConfig, normalizeStreamVideoConfig} from "../runtime/streamConfig"
137
146
  import {visibleActionRows} from "./manifestActions"
138
147
  import {toLanguageHint} from "@glassly/cloud-protocol/languages"
139
- import type {AudioSubscription, LanguageSource, TranscriptionData, TranslationData} from "@glassly/cloud-protocol"
140
148
  import {buildMiniappManifestSnapshot, type MiniappRuntimeDiagnosticSnapshot} from "../utils/miniappDiagnostics"
141
149
  import ttsModelManager from "./TTSModelManager"
142
150
  import {NavigationHandlers} from "./NavigationHandlers"
@@ -416,6 +424,9 @@ function directLlmProvider(name: AiTierProvider): LlmProviderImpl {
416
424
  return built
417
425
  }
418
426
 
427
+ const QUOTA_EXCEEDED_MESSAGE =
428
+ "Monthly AI credit is used up. Upgrade in Settings > Membership, or add your own API key in Settings > API Keys."
429
+
419
430
  /** The user's saved key (Settings > API Keys) for an LLM provider, "" if none. */
420
431
  function userLlmKey(provider: AiTierProvider): string {
421
432
  const raw = useSettingsStore.getState().getSetting(MINIAPP_READABLE_API_KEYS[provider])
@@ -2560,11 +2571,10 @@ class LocalMiniappRuntime {
2560
2571
  return isPhoneActive()
2561
2572
  case "foreground_app_on_screen":
2562
2573
  return localDisplayManager.foregroundAppOnScreen()
2563
- case "ring_battery":
2564
- return ringBatteryFrom(useGlassesStore.getState())
2565
- case "input_2d": {
2566
- const controller = useSettingsStore.getState().getSetting(SETTINGS.default_controller.key)
2567
- return input2dFrom(useGlassesStore.getState(), controller)
2574
+ case "controllers": {
2575
+ const glasses = useGlassesStore.getState()
2576
+ const records = pairedControllersFrom(useSettingsStore.getState().getSetting(SETTINGS.controllers.key))
2577
+ return controllersSettingFrom(records, glasses.controllers, glasses)
2568
2578
  }
2569
2579
  }
2570
2580
  }
@@ -2619,7 +2629,7 @@ class LocalMiniappRuntime {
2619
2629
  /**
2620
2630
  * Push bridged settings as they change: stored keys from the store, the
2621
2631
  * derived keys whenever their inputs move (phone activity, the display,
2622
- * the apps store, the ring, the dashboard role). Subscribers declared
2632
+ * the apps store, the controllers, the dashboard role). Subscribers declared
2623
2633
  * PLATFORM_SETTINGS (gated at SUBSCRIBE), so this is a no-op fan-out for
2624
2634
  * everyone else.
2625
2635
  */
@@ -2659,10 +2669,10 @@ class LocalMiniappRuntime {
2659
2669
  useAppStatusStore.subscribe(pushDerived)
2660
2670
  onOverlayAppsChange(pushDerived)
2661
2671
  useGlassesStore.subscribe(
2662
- (s) => `${s.ringConnected}:${s.ringBatteryLevel}:${s.controllerConnected}:${s.controllerBatteryLevel}`,
2672
+ (s) => `${s.ringConnected}:${s.ringBatteryLevel}:${s.ringCharging}:${JSON.stringify(s.controllers ?? {})}`,
2663
2673
  pushDerived,
2664
2674
  )
2665
- useSettingsStore.subscribe((s) => s.getSetting(SETTINGS.default_controller.key), pushDerived)
2675
+ useSettingsStore.subscribe((s) => JSON.stringify(s.getSetting(SETTINGS.controllers.key) ?? null), pushDerived)
2666
2676
  }
2667
2677
 
2668
2678
  /**
@@ -4063,16 +4073,39 @@ class LocalMiniappRuntime {
4063
4073
  // here (the user's Settings > AI Models dropdowns, or "auto" from which
4064
4074
  // keys are saved — see AiTierResolution). Legacy provider/model pins from
4065
4075
  // pre-tier miniapps are deliberately stripped, not honored.
4066
- const {tier: rawTier, provider: _legacyProvider, model: _legacyModel, ...neutralRequest} = request
4076
+ const {
4077
+ tier: rawTier,
4078
+ provider: _legacyProvider,
4079
+ model: _legacyModel,
4080
+ forceLocal: rawForceLocal,
4081
+ localFallback: rawLocalFallback,
4082
+ ...neutralRequest
4083
+ } = request
4067
4084
  const tier = normalizeAiTier(rawTier)
4068
4085
  const resolved = resolveAiTier(tier, tierSettingValue(tier), savedLlmKeys())
4069
4086
 
4087
+ // ON-DEVICE ROUTING — the request's forceLocal / localFallback flags
4088
+ // against the primary path's health (see LlmRouting). The primary is the
4089
+ // vendor-direct call when a user key is saved, else the cloud relay.
4090
+ const directKey = resolved.provider ? userLlmKey(resolved.provider) : ""
4091
+ const forceLocal = rawForceLocal === true
4092
+ const localFallback = rawLocalFallback === true
4093
+ const cloudConnected = cloudClientService.isConnected()
4094
+ const cloudDisabled = cloudClientService.isCloudDisabled()
4095
+ const primaryUp = directKey.length > 0 || (cloudConnected && !cloudDisabled && !this.llmQuotaBlocked)
4096
+ const route = decideLlmRoute({forceLocal, localFallback, primaryUp})
4097
+
4098
+ if (route === "local") {
4099
+ const reason = localLlmReason({forceLocal, cloudDisabled, cloudConnected, quotaBlocked: this.llmQuotaBlocked})
4100
+ await this.serveLocalAi(packageName, requestId, neutralRequest, reason, null)
4101
+ return
4102
+ }
4103
+
4070
4104
  try {
4071
4105
  // Direct when the resolved provider has a user key (BYO bill, unmetered,
4072
4106
  // works with the cloud disabled); otherwise relay to the cloud LLM
4073
4107
  // service, forwarding the resolved pin so platform-funded calls land on
4074
4108
  // the same model the user chose.
4075
- const directKey = resolved.provider ? userLlmKey(resolved.provider) : ""
4076
4109
  if (resolved.provider && directKey) {
4077
4110
  const result = await directLlmProvider(resolved.provider).complete({
4078
4111
  ...neutralRequest,
@@ -4094,18 +4127,38 @@ class LocalMiniappRuntime {
4094
4127
  // is spent. Distinct code (miniapps may fall back to a local path) and
4095
4128
  // a host hook so the app can offer the upgrade paywall — the miniapp
4096
4129
  // itself must never own that conversation.
4097
- if (err instanceof HttpError && (err.code === "quota_exceeded" || err.status === 402)) {
4130
+ const quotaExceeded = err instanceof HttpError && (err.code === "quota_exceeded" || err.status === 402)
4131
+ if (quotaExceeded && !this.llmQuotaBlocked) {
4098
4132
  console.warn(`${LOG_TAG}: ai.complete quota exceeded for ${packageName}`)
4099
4133
  this.llmQuotaBlocked = true
4100
4134
  this.onQuotaExceeded?.("llm")
4135
+ }
4136
+ const message = err instanceof Error ? err.message : "ai.complete failed"
4137
+
4138
+ // localFallback: the same request, on-device. The primary's error
4139
+ // stands only when the phone cannot serve it either.
4140
+ if (route === "primary_then_local") {
4141
+ console.warn(`${LOG_TAG}: ai.complete primary failed for ${packageName} (${message}); trying on-device`)
4142
+ await this.serveLocalAi(
4143
+ packageName,
4144
+ requestId,
4145
+ neutralRequest,
4146
+ quotaExceeded ? "llm_quota" : "primary_failed",
4147
+ {
4148
+ code: quotaExceeded ? MiniappErrorCode.QUOTA_EXCEEDED : MiniappErrorCode.INTERNAL,
4149
+ message: quotaExceeded ? QUOTA_EXCEEDED_MESSAGE : message,
4150
+ },
4151
+ )
4152
+ return
4153
+ }
4154
+
4155
+ if (quotaExceeded) {
4101
4156
  this.sendResult(packageName, requestId, false, undefined, {
4102
4157
  code: MiniappErrorCode.QUOTA_EXCEEDED,
4103
- message:
4104
- "Monthly AI credit is used up. Upgrade in Settings > Membership, or add your own API key in Settings > API Keys.",
4158
+ message: QUOTA_EXCEEDED_MESSAGE,
4105
4159
  })
4106
4160
  return
4107
4161
  }
4108
- const message = err instanceof Error ? err.message : "ai.complete failed"
4109
4162
  console.warn(`${LOG_TAG}: ai.complete failed for ${packageName}: ${message}`)
4110
4163
  this.sendResult(packageName, requestId, false, undefined, {
4111
4164
  code: MiniappErrorCode.INTERNAL,
@@ -4114,6 +4167,70 @@ class LocalMiniappRuntime {
4114
4167
  }
4115
4168
  }
4116
4169
 
4170
+ /**
4171
+ * Serve one ai.complete on the phone's own model (LocalLlmProvider). With
4172
+ * no model on disk the download starts (or continues) in the background
4173
+ * and the miniapp gets AI_LOCAL_UNAVAILABLE naming the state — unless
4174
+ * `primaryError` is given (a failed fallback), in which case the primary's
4175
+ * own error is what the miniapp sees.
4176
+ */
4177
+ private async serveLocalAi(
4178
+ packageName: string,
4179
+ requestId: string | undefined,
4180
+ request: Record<string, unknown>,
4181
+ reason: LocalLlmReason,
4182
+ primaryError: {code: MiniappErrorCode; message: string} | null,
4183
+ ): Promise<void> {
4184
+ const modelId = localLlmModelManager.selectedModelId()
4185
+ if (!(await localLlmModelManager.ensureSelectedModel())) {
4186
+ const status = localLlmModelManager.getStatus()
4187
+ const state =
4188
+ status?.modelId === modelId && status.stage === "downloading"
4189
+ ? `is downloading (${status.percent}%)`
4190
+ : status?.modelId === modelId && status.stage === "failed"
4191
+ ? `failed to download (${status.error ?? "unknown error"})`
4192
+ : "is not downloaded yet"
4193
+ console.warn(`${LOG_TAG}: ai.complete on-device for ${packageName} (${reason}): model ${modelId} ${state}`)
4194
+ this.sendResult(
4195
+ packageName,
4196
+ requestId,
4197
+ false,
4198
+ undefined,
4199
+ primaryError ?? {
4200
+ code: MiniappErrorCode.AI_LOCAL_UNAVAILABLE,
4201
+ message: `The on-device model (${localLlmModelManager.model(modelId).label}) ${state}. Manage it in Settings > AI Models.`,
4202
+ },
4203
+ )
4204
+ return
4205
+ }
4206
+
4207
+ try {
4208
+ // webSearch is a vendor server-side grant; the phone has none.
4209
+ const {webSearch: _webSearch, ...localRequest} = request
4210
+ const result = await localLlmProvider.complete(localRequest as never)
4211
+ console.log(`${LOG_TAG}: ai.complete served on-device for ${packageName} (${reason}, ${result.modelId})`)
4212
+ this.sendResult(packageName, requestId, true, {
4213
+ text: result.text,
4214
+ stopReason: result.stopReason,
4215
+ ...(result.toolCalls ? {toolCalls: result.toolCalls} : {}),
4216
+ usage: result.usage,
4217
+ provider: "device",
4218
+ model: result.modelId,
4219
+ billedToUserKey: false,
4220
+ })
4221
+ } catch (err) {
4222
+ const message = err instanceof Error ? err.message : "on-device completion failed"
4223
+ console.warn(`${LOG_TAG}: ai.complete on-device failed for ${packageName}: ${message}`)
4224
+ this.sendResult(
4225
+ packageName,
4226
+ requestId,
4227
+ false,
4228
+ undefined,
4229
+ primaryError ?? {code: MiniappErrorCode.AI_LOCAL_UNAVAILABLE, message},
4230
+ )
4231
+ }
4232
+ }
4233
+
4117
4234
  /**
4118
4235
  * ai.generate — agentic visual generation. Always relayed to the cloud
4119
4236
  * runtime (the server drives the provider's agentic loop; there is no
@@ -5318,13 +5435,12 @@ class LocalMiniappRuntime {
5318
5435
  this.capabilitiesWired = true
5319
5436
  const broadcast = () => this.broadcastCapabilities()
5320
5437
  // Connection, model and firmware also decide the dashboard view's eligibility;
5321
- // a connected Neural Band adds its swipe2d surface to `button`.
5438
+ // connected controllers add their input surfaces to `button`.
5322
5439
  useGlassesStore.subscribe(
5323
5440
  (s) =>
5324
- `${s.sceneCanvasWidth ?? 0}x${s.sceneCanvasHeight ?? 0}:${s.sceneVector === true}:${s.firmwareVersion}:${s.connection.state}:${s.deviceModel}:${s.controllerConnected}`,
5441
+ `${s.sceneCanvasWidth ?? 0}x${s.sceneCanvasHeight ?? 0}:${s.sceneVector === true}:${s.firmwareVersion}:${s.connection.state}:${s.deviceModel}:${readyControllersKey(s.controllers)}`,
5325
5442
  broadcast,
5326
5443
  )
5327
- useSettingsStore.subscribe((s) => s.getSetting(SETTINGS.default_controller.key), broadcast)
5328
5444
  // The debug knob flips hasNativeNavigation on custom firmware.
5329
5445
  useSettingsStore.subscribe((s) => s.getSetting(SETTINGS.cfw_native_navigation.key), broadcast)
5330
5446
  // The dashboard master switch flips the dashboard view.