@glassly/engine 0.3.5 → 0.3.6-dev.846

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 (206) hide show
  1. package/build/engine.d.ts +11 -1
  2. package/build/engine.d.ts.map +1 -1
  3. package/build/engine.js +13 -1
  4. package/build/engine.js.map +1 -1
  5. package/build/facades/ai.d.ts +3 -3
  6. package/build/facades/ai.d.ts.map +1 -1
  7. package/build/facades/ai.js +3 -3
  8. package/build/facades/ai.js.map +1 -1
  9. package/build/facades/glasses.d.ts +5 -1
  10. package/build/facades/glasses.d.ts.map +1 -1
  11. package/build/facades/glasses.js +39 -30
  12. package/build/facades/glasses.js.map +1 -1
  13. package/build/facades/memory.d.ts +10 -2
  14. package/build/facades/memory.d.ts.map +1 -1
  15. package/build/facades/memory.js +10 -0
  16. package/build/facades/memory.js.map +1 -1
  17. package/build/facades/miniapps.d.ts +2 -0
  18. package/build/facades/miniapps.d.ts.map +1 -1
  19. package/build/facades/miniapps.js +2 -0
  20. package/build/facades/miniapps.js.map +1 -1
  21. package/build/facades/pairing.d.ts.map +1 -1
  22. package/build/facades/pairing.js +3 -2
  23. package/build/facades/pairing.js.map +1 -1
  24. package/build/facades/providerKeys.d.ts +6 -2
  25. package/build/facades/providerKeys.d.ts.map +1 -1
  26. package/build/facades/providerKeys.js +6 -2
  27. package/build/facades/providerKeys.js.map +1 -1
  28. package/build/generated/releaseMetadata.js +4 -4
  29. package/build/generated/releaseMetadata.js.map +1 -1
  30. package/build/services/AiTierResolution.d.ts +3 -2
  31. package/build/services/AiTierResolution.d.ts.map +1 -1
  32. package/build/services/AiTierResolution.js +7 -2
  33. package/build/services/AiTierResolution.js.map +1 -1
  34. package/build/services/AppRegistry.d.ts +4 -1
  35. package/build/services/AppRegistry.d.ts.map +1 -1
  36. package/build/services/AppRegistry.js +16 -2
  37. package/build/services/AppRegistry.js.map +1 -1
  38. package/build/services/BlobStore.js +3 -3
  39. package/build/services/BlobStore.js.map +1 -1
  40. package/build/services/BluetoothRestoreLaunch.d.ts +8 -0
  41. package/build/services/BluetoothRestoreLaunch.d.ts.map +1 -0
  42. package/build/services/BluetoothRestoreLaunch.js +34 -0
  43. package/build/services/BluetoothRestoreLaunch.js.map +1 -0
  44. package/build/services/CloudClientService.d.ts +4 -4
  45. package/build/services/ForegroundLocationPermission.d.ts +1 -1
  46. package/build/services/ForegroundLocationPermission.d.ts.map +1 -1
  47. package/build/services/ForegroundLocationPermission.js.map +1 -1
  48. package/build/services/GestureDotsService.d.ts.map +1 -1
  49. package/build/services/GestureDotsService.js +2 -1
  50. package/build/services/GestureDotsService.js.map +1 -1
  51. package/build/services/GlassesTouchInput.d.ts +6 -4
  52. package/build/services/GlassesTouchInput.d.ts.map +1 -1
  53. package/build/services/GlassesTouchInput.js +81 -41
  54. package/build/services/GlassesTouchInput.js.map +1 -1
  55. package/build/services/LocalDisplayManager.d.ts +12 -4
  56. package/build/services/LocalDisplayManager.d.ts.map +1 -1
  57. package/build/services/LocalDisplayManager.js +21 -7
  58. package/build/services/LocalDisplayManager.js.map +1 -1
  59. package/build/services/LocalLlmModelManager.d.ts +3 -1
  60. package/build/services/LocalLlmModelManager.d.ts.map +1 -1
  61. package/build/services/LocalLlmModelManager.js +13 -5
  62. package/build/services/LocalLlmModelManager.js.map +1 -1
  63. package/build/services/LocalLlmPrompt.d.ts +10 -9
  64. package/build/services/LocalLlmPrompt.d.ts.map +1 -1
  65. package/build/services/LocalLlmPrompt.js +43 -32
  66. package/build/services/LocalLlmPrompt.js.map +1 -1
  67. package/build/services/LocalLlmProvider.d.ts +1 -0
  68. package/build/services/LocalLlmProvider.d.ts.map +1 -1
  69. package/build/services/LocalLlmProvider.js +62 -22
  70. package/build/services/LocalLlmProvider.js.map +1 -1
  71. package/build/services/LocalMiniappRuntime.d.ts +28 -6
  72. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  73. package/build/services/LocalMiniappRuntime.js +190 -148
  74. package/build/services/LocalMiniappRuntime.js.map +1 -1
  75. package/build/services/MemoryStore.d.ts +13 -1
  76. package/build/services/MemoryStore.d.ts.map +1 -1
  77. package/build/services/MemoryStore.js +98 -1
  78. package/build/services/MemoryStore.js.map +1 -1
  79. package/build/services/MiniappExitGesture.d.ts +6 -3
  80. package/build/services/MiniappExitGesture.d.ts.map +1 -1
  81. package/build/services/MiniappExitGesture.js +4 -4
  82. package/build/services/MiniappExitGesture.js.map +1 -1
  83. package/build/services/MiniappWidgetMode.d.ts +6 -4
  84. package/build/services/MiniappWidgetMode.d.ts.map +1 -1
  85. package/build/services/MiniappWidgetMode.js +7 -6
  86. package/build/services/MiniappWidgetMode.js.map +1 -1
  87. package/build/services/ProviderKeyValidation.d.ts +2 -0
  88. package/build/services/ProviderKeyValidation.d.ts.map +1 -1
  89. package/build/services/ProviderKeyValidation.js +4 -0
  90. package/build/services/ProviderKeyValidation.js.map +1 -1
  91. package/build/services/SceneRenderer.d.ts.map +1 -1
  92. package/build/services/SceneRenderer.js +3 -1
  93. package/build/services/SceneRenderer.js.map +1 -1
  94. package/build/services/ScreenshotFixture.d.ts +56 -0
  95. package/build/services/ScreenshotFixture.d.ts.map +1 -0
  96. package/build/services/ScreenshotFixture.js +72 -0
  97. package/build/services/ScreenshotFixture.js.map +1 -0
  98. package/build/services/actionInvoke.d.ts +37 -0
  99. package/build/services/actionInvoke.d.ts.map +1 -0
  100. package/build/services/actionInvoke.js +89 -0
  101. package/build/services/actionInvoke.js.map +1 -0
  102. package/build/services/appleWatch/AppleWatchLink.d.ts +42 -0
  103. package/build/services/appleWatch/AppleWatchLink.d.ts.map +1 -0
  104. package/build/services/appleWatch/AppleWatchLink.js +259 -0
  105. package/build/services/appleWatch/AppleWatchLink.js.map +1 -0
  106. package/build/services/controllers.d.ts.map +1 -1
  107. package/build/services/controllers.js +16 -0
  108. package/build/services/controllers.js.map +1 -1
  109. package/build/services/manifestActions.d.ts +4 -2
  110. package/build/services/manifestActions.d.ts.map +1 -1
  111. package/build/services/manifestActions.js +9 -3
  112. package/build/services/manifestActions.js.map +1 -1
  113. package/build/services/memoryBackupFile.d.ts +9 -0
  114. package/build/services/memoryBackupFile.d.ts.map +1 -0
  115. package/build/services/memoryBackupFile.js +47 -0
  116. package/build/services/memoryBackupFile.js.map +1 -0
  117. package/build/services/settingsBridge.d.ts +4 -4
  118. package/build/services/settingsBridge.d.ts.map +1 -1
  119. package/build/services/settingsBridge.js +9 -4
  120. package/build/services/settingsBridge.js.map +1 -1
  121. package/build/services/widgetPlacements.d.ts +8 -7
  122. package/build/services/widgetPlacements.d.ts.map +1 -1
  123. package/build/services/widgetPlacements.js +7 -6
  124. package/build/services/widgetPlacements.js.map +1 -1
  125. package/build/stores/apps.d.ts +6 -0
  126. package/build/stores/apps.d.ts.map +1 -1
  127. package/build/stores/apps.js +29 -12
  128. package/build/stores/apps.js.map +1 -1
  129. package/build/stores/bluetoothSettingKeys.d.ts +2 -0
  130. package/build/stores/bluetoothSettingKeys.d.ts.map +1 -1
  131. package/build/stores/bluetoothSettingKeys.js +17 -0
  132. package/build/stores/bluetoothSettingKeys.js.map +1 -1
  133. package/build/stores/settings.d.ts.map +1 -1
  134. package/build/stores/settings.js +42 -2
  135. package/build/stores/settings.js.map +1 -1
  136. package/build/types/applet.d.ts +5 -1
  137. package/build/types/applet.d.ts.map +1 -1
  138. package/build/types/applet.js.map +1 -1
  139. package/build/types/capabilities/rayneo-io.d.ts.map +1 -1
  140. package/build/types/capabilities/rayneo-io.js +2 -0
  141. package/build/types/capabilities/rayneo-io.js.map +1 -1
  142. package/build/types/enums.d.ts +2 -1
  143. package/build/types/enums.d.ts.map +1 -1
  144. package/build/types/enums.js +1 -0
  145. package/build/types/enums.js.map +1 -1
  146. package/build/utils/cloudClient/RnUdpAdapter.d.ts.map +1 -1
  147. package/build/utils/cloudClient/RnUdpAdapter.js +13 -2
  148. package/build/utils/cloudClient/RnUdpAdapter.js.map +1 -1
  149. package/build/utils/display/scene/index.d.ts +1 -1
  150. package/build/utils/display/scene/index.d.ts.map +1 -1
  151. package/build/utils/display/scene/index.js.map +1 -1
  152. package/build/utils/display/scene/process.d.ts.map +1 -1
  153. package/build/utils/display/scene/process.js +20 -1
  154. package/build/utils/display/scene/process.js.map +1 -1
  155. package/build/utils/display/scene/types.d.ts +1 -1
  156. package/build/utils/display/scene/types.d.ts.map +1 -1
  157. package/build/utils/display/scene/types.js.map +1 -1
  158. package/build/utils/miniappGlobals.d.ts +6 -0
  159. package/build/utils/miniappGlobals.d.ts.map +1 -1
  160. package/build/utils/miniappGlobals.js +28 -4
  161. package/build/utils/miniappGlobals.js.map +1 -1
  162. package/package.json +9 -9
  163. package/src/engine.ts +13 -1
  164. package/src/facades/ai.ts +3 -3
  165. package/src/facades/glasses.ts +65 -42
  166. package/src/facades/memory.ts +11 -2
  167. package/src/facades/miniapps.ts +3 -0
  168. package/src/facades/pairing.ts +4 -2
  169. package/src/facades/providerKeys.ts +6 -1
  170. package/src/generated/releaseMetadata.ts +4 -4
  171. package/src/services/AiTierResolution.ts +9 -3
  172. package/src/services/AppRegistry.ts +18 -3
  173. package/src/services/BlobStore.ts +3 -3
  174. package/src/services/BluetoothRestoreLaunch.ts +37 -0
  175. package/src/services/ForegroundLocationPermission.ts +1 -1
  176. package/src/services/GestureDotsService.ts +2 -1
  177. package/src/services/GlassesTouchInput.ts +86 -38
  178. package/src/services/LocalDisplayManager.ts +22 -8
  179. package/src/services/LocalLlmModelManager.ts +16 -6
  180. package/src/services/LocalLlmPrompt.ts +56 -37
  181. package/src/services/LocalLlmProvider.ts +74 -24
  182. package/src/services/LocalMiniappRuntime.ts +228 -147
  183. package/src/services/MemoryStore.ts +111 -2
  184. package/src/services/MiniappExitGesture.ts +8 -5
  185. package/src/services/MiniappWidgetMode.ts +9 -7
  186. package/src/services/ProviderKeyValidation.ts +5 -0
  187. package/src/services/SceneRenderer.ts +3 -1
  188. package/src/services/ScreenshotFixture.ts +113 -0
  189. package/src/services/actionInvoke.ts +105 -0
  190. package/src/services/appleWatch/AppleWatchLink.ts +297 -0
  191. package/src/services/controllers.ts +17 -0
  192. package/src/services/manifestActions.ts +16 -4
  193. package/src/services/memoryBackupFile.ts +50 -0
  194. package/src/services/settingsBridge.ts +9 -4
  195. package/src/services/widgetPlacements.ts +23 -9
  196. package/src/stores/apps.ts +35 -11
  197. package/src/stores/bluetoothSettingKeys.ts +20 -0
  198. package/src/stores/settings.ts +44 -2
  199. package/src/types/applet.ts +5 -1
  200. package/src/types/capabilities/rayneo-io.ts +2 -0
  201. package/src/types/enums.ts +1 -0
  202. package/src/utils/cloudClient/RnUdpAdapter.ts +13 -1
  203. package/src/utils/display/scene/index.ts +1 -0
  204. package/src/utils/display/scene/process.ts +17 -1
  205. package/src/utils/display/scene/types.ts +1 -0
  206. package/src/utils/miniappGlobals.ts +30 -4
@@ -5,6 +5,7 @@ import BluetoothSdk from "@glassly/bluetooth-sdk/internal"
5
5
  import {useAppStatusStore} from "../stores/apps"
6
6
  import {useGlassesStore} from "../stores/glasses"
7
7
  import {BgTimer} from "../utils/timers"
8
+ import {hasInput2d} from "./controllers"
8
9
  import dashboardOpenTrace from "./debug/DashboardOpenTrace"
9
10
  import gestureDotsService from "./GestureDotsService"
10
11
  import {isGlassesConnected} from "./GlassesReadiness"
@@ -17,7 +18,7 @@ import {pagesEligible} from "./pages/pageEligibility"
17
18
 
18
19
  const miniappExitGesture = new MiniappExitGesture({
19
20
  getState: () => useAppStatusStore.getState(),
20
- getVisiblePackageName: () => localDisplayManager.getVisibleMiniappPackageName(),
21
+ getTargetPackageName: () => localDisplayManager.getMiniappToLeave(),
21
22
  beginExitNotice: (packageName, text) => localDisplayManager.beginMiniappExit(packageName, text),
22
23
  finishExitNotice: (packageName) => localDisplayManager.finishMiniappExit(packageName),
23
24
  cancelExitNotice: (packageName) => localDisplayManager.cancelMiniappExit(packageName),
@@ -25,54 +26,104 @@ const miniappExitGesture = new MiniappExitGesture({
25
26
  clearTimeout: (timerId) => BgTimer.clearTimeout(timerId),
26
27
  })
27
28
 
28
- // Raw ring reports include every scroll step, whether the CFW forwards them
29
- // (deviceModel G2) or the ring sends them to the phone itself (deviceModel R1);
30
- // both tag source 2. Rate-limit scroll actions separately from press/release
31
- // recognition, per ring, using native receipt timestamps so a JS backlog does
32
- // not collapse otherwise separate gestures.
33
- const RING_SCROLL_INTERVAL_MS = 100
34
- const lastRingScrollAt = new Map<string, number>()
35
-
36
- function shouldForwardScroll(event: TouchEvent): boolean {
37
- if (event.source !== 2 || (event.gestureName !== "swipe_up" && event.gestureName !== "swipe_down")) return true
38
- const ring = event.controllerId ?? `source:${event.source}`
39
- const last = lastRingScrollAt.get(ring)
29
+ // The ring reports a slide once per step of travel, whether the CFW forwards
30
+ // it (deviceModel G2) or the ring sends it to the phone itself (deviceModel
31
+ // R1); both tag source 2. One press is one swipe: the stroke's first step goes
32
+ // out as swipe_up / swipe_down, and every step, the first included, as
33
+ // scroll_up / scroll_down with its distance and speed for smooth scrolling. A
34
+ // stroke ends at the ring's next touch_down or tap_end, or after
35
+ // RING_STROKE_IDLE_MS without a step when a report was lost. Reversing within
36
+ // a stroke fires no swipe (a finger rolling back at lift-off). Native receipt
37
+ // timestamps keep strokes apart when JS drains a backlog.
38
+ const RING_STROKE_IDLE_MS = 1000
39
+ /** Per ring, when the open stroke's last step arrived. */
40
+ const ringStrokes = new Map<string, number>()
41
+
42
+ function ringKey(event: TouchEvent): string {
43
+ return event.controllerId ?? `source:${event.source}`
44
+ }
45
+
46
+ function isRingSlideStep(event: TouchEvent): boolean {
47
+ return event.source === 2 && (event.gestureName === "swipe_up" || event.gestureName === "swipe_down")
48
+ }
49
+
50
+ function opensRingStroke(event: TouchEvent): boolean {
51
+ const last = ringStrokes.get(ringKey(event))
52
+ ringStrokes.set(ringKey(event), event.timestamp)
40
53
  const elapsed = last === undefined ? null : event.timestamp - last
41
- if (elapsed !== null && elapsed >= 0 && elapsed < RING_SCROLL_INTERVAL_MS) return false
42
- lastRingScrollAt.set(ring, event.timestamp)
43
- return true
54
+ return elapsed === null || elapsed < 0 || elapsed >= RING_STROKE_IDLE_MS
55
+ }
56
+
57
+ function endRingStroke(event: TouchEvent): void {
58
+ if (event.source === 2 && (event.gestureName === "touch_down" || event.gestureName === "tap_end")) {
59
+ ringStrokes.delete(ringKey(event))
60
+ }
61
+ }
62
+
63
+ /** The discrete swipe a stroke opens with: the step's travel belongs to scroll_*. */
64
+ function strokeSwipe(step: TouchEvent): TouchEvent {
65
+ const swipe = {...step}
66
+ delete swipe.distance
67
+ delete swipe.speed
68
+ return swipe
69
+ }
70
+
71
+ /**
72
+ * Miniapps' copy of the touch: `input2d: true` when it came from a controller
73
+ * that swipes in two dimensions (the Neural Band), the field absent otherwise,
74
+ * so a 1D swipe from the temples or the ring stays tellable while a band is
75
+ * connected under the same gesture names.
76
+ */
77
+ function toMiniapps(event: TouchEvent): TouchEvent & {input2d?: true} {
78
+ const id = event.controllerId
79
+ if (id === undefined) return event
80
+ const model = useGlassesStore.getState().controllers?.[id]?.model
81
+ return model !== undefined && hasInput2d(model) ? {...event, input2d: true} : event
44
82
  }
45
83
 
46
84
  /**
47
85
  * Route both real glasses input and phone mirror controls through the same
48
86
  * policy. A claimed gesture goes to the app holding the claim, alone; an
49
87
  * unclaimed one goes to every subscriber and then the engine's default. The
50
- * one rule ahead of the claims: an unclaimed double tap on a miniapp whose
51
- * frame is visibly on the glasses leaves that app, whoever else claimed the
52
- * gesture — the dashboard's claim is for the empty screen. Head position
88
+ * one rule ahead of the claims: an unclaimed double tap leaves the miniapp
89
+ * the wearer is in — the one visibly on the glasses, else the running
90
+ * standard app on an empty screen — whoever else claimed the gesture; the
91
+ * dashboard's claim is for the screen with no app in it. Head position
53
92
  * (head_up / head_down) is a gesture like any other: the dashboard claims it
54
93
  * while it runs, and an unclaimed head_up on glasses that defer the dashboard
55
- * to the phone opens the native one, as it would with no app at all.
94
+ * to the phone opens the native one, as it would with no app at all. A ring
95
+ * slide step becomes one swipe per stroke plus a scroll step (see above).
56
96
  */
57
97
  export function dispatchTouchEvent(event: TouchEvent): void {
98
+ if (isRingSlideStep(event)) {
99
+ if (opensRingStroke(event)) routeTouchEvent(strokeSwipe(event))
100
+ routeTouchEvent({...event, gestureName: event.gestureName === "swipe_up" ? "scroll_up" : "scroll_down"})
101
+ return
102
+ }
103
+ endRingStroke(event)
104
+ routeTouchEvent(event)
105
+ }
106
+
107
+ function routeTouchEvent(event: TouchEvent): void {
58
108
  const gesture = event.gestureName
59
109
  const claimant = localMiniappRuntime.gestureClaimant(gesture)
60
- // The Neural Band's middle finger (index 1) shares the tap names but is left
61
- // to miniapps: the engine's own double-tap behaviors are the index finger's.
62
- const osDoubleTap = gesture === "double_tap" && event.index !== 1
110
+ const doubleTap = gesture === "double_tap"
111
+ // The Neural Band's middle finger (index 1) shares the tap names. Either
112
+ // finger leaves the app on screen; the firmware dashboard is the index finger's.
113
+ const osDoubleTap = doubleTap && event.index !== 1
63
114
  const osHeadUp = gesture === "head_up"
64
115
 
65
- if (osDoubleTap) {
66
- // A visible main miniapp opts out of OS exit by registering double tap (the
116
+ if (doubleTap) {
117
+ // The app being left opts out of OS exit by registering double tap (the
67
118
  // terminal's back-to-menu). The latest claimant still receives it. Otherwise
68
119
  // the tap means "leave the app". The controller shows the bounded OS
69
- // notice first, then uses the normal
70
- // store stop path (and closes its phone overlay).
71
- const visible = localDisplayManager.getVisibleMiniappPackageName()
120
+ // notice first, then uses the normal store stop path (and closes its
121
+ // phone overlay).
122
+ const leaving = localDisplayManager.getMiniappToLeave()
72
123
  if (
73
- visible !== null &&
74
- !localMiniappRuntime.hasGestureClaim(visible, gesture) &&
75
- miniappExitGesture.tryExitVisibleMiniapp()
124
+ leaving !== null &&
125
+ !localMiniappRuntime.hasGestureClaim(leaving, gesture) &&
126
+ miniappExitGesture.tryExitMiniapp()
76
127
  ) {
77
128
  gestureDotsService.noteGesture(gesture, true, event.index, event.controllerId)
78
129
  return
@@ -86,18 +137,15 @@ export function dispatchTouchEvent(event: TouchEvent): void {
86
137
  }
87
138
  // The press may have started before this app claimed its release edge.
88
139
  gestureDotsService.noteGesture(gesture, true, event.index, event.controllerId)
89
- if (!shouldForwardScroll(event)) return
90
- localMiniappRuntime.forwardEvent("touch_event", event, undefined, claimant)
140
+ localMiniappRuntime.forwardEvent("touch_event", toMiniapps(event), undefined, claimant)
91
141
  return
92
142
  }
93
143
 
94
144
  // Gesture-dots debug indicator (any device, gated by its setting). It
95
- // only paints while the screen is otherwise empty. Process even suppressed
96
- // scroll repeats here so their completion still clears a held dot.
145
+ // only paints while the screen is otherwise empty.
97
146
  gestureDotsService.noteGesture(gesture, false, event.index, event.controllerId)
98
147
 
99
- if (!shouldForwardScroll(event)) return
100
- localMiniappRuntime.forwardEvent("touch_event", event)
148
+ localMiniappRuntime.forwardEvent("touch_event", toMiniapps(event))
101
149
 
102
150
  // Nobody claimed a double tap or a head-up: the glasses' native dashboard.
103
151
  // On eligible Glassly CFW glasses the dashboard miniapp claims both while it
@@ -118,7 +166,7 @@ export function dispatchTouchEvent(event: TouchEvent): void {
118
166
 
119
167
  export function resetTouchInput(): void {
120
168
  miniappExitGesture.reset()
121
- lastRingScrollAt.clear()
169
+ ringStrokes.clear()
122
170
  }
123
171
 
124
172
  /** Phone mirror input uses the same dashboard/miniapp policy as real glasses. */
@@ -165,7 +165,7 @@ class LocalDisplayManager {
165
165
  private overlayOwner: string | null = null
166
166
 
167
167
  /** Miniapp whose visible frame is temporarily covered by an OS exit notice. */
168
- private exitingApp: {packageName: string; coveredDisplay: ActiveDisplay} | null = null
168
+ private exitingApp: {packageName: string; coveredDisplay: ActiveDisplay | null} | null = null
169
169
 
170
170
  private expiryTimerId: number | null = null
171
171
 
@@ -309,16 +309,30 @@ class LocalDisplayManager {
309
309
  }
310
310
 
311
311
  /**
312
- * Cover the currently visible miniapp with a persistent system exit notice.
313
- * While it is up, miniapp display traffic is held off so a streaming app
314
- * cannot immediately overwrite the notice. The caller completes the normal
315
- * stop flow and then calls finishMiniappExit(); onUnmount does the same as a
312
+ * The miniapp an unclaimed double tap leaves: the one visibly drawing on
313
+ * the main view, else the core (running standard) app on an empty or
314
+ * system-drawn screen — an app that has drawn nothing yet, or whose frame
315
+ * expired, is still the one the wearer is in. Nothing while a page covers
316
+ * the main view: the page's claims own the tap.
317
+ */
318
+ public getMiniappToLeave(): string | null {
319
+ if (this.overlayOwner !== null) return null
320
+ return this.getVisibleMiniappPackageName() ?? this.coreApp
321
+ }
322
+
323
+ /**
324
+ * Cover the leaving miniapp with a persistent system exit notice. While it
325
+ * is up, miniapp display traffic is held off so a streaming app cannot
326
+ * immediately overwrite the notice. The caller completes the normal stop
327
+ * flow and then calls finishMiniappExit(); onUnmount does the same as a
316
328
  * safety net.
317
329
  */
318
330
  public beginMiniappExit(packageName: string, text: string): boolean {
319
331
  if (this.exitingApp !== null) return this.exitingApp.packageName === packageName
320
- const coveredDisplay = this.currentDisplay
321
- if (!coveredDisplay || this.getVisibleMiniappPackageName() !== packageName) return false
332
+ if (this.getMiniappToLeave() !== packageName) return false
333
+ // Only the app's own visible frame comes back on cancel; a system frame
334
+ // or an empty screen does not.
335
+ const coveredDisplay = this.getVisibleMiniappPackageName() === packageName ? this.currentDisplay : null
322
336
 
323
337
  console.log(`${LOG_TAG}: beginMiniappExit(${packageName})`)
324
338
  this.exitingApp = {packageName, coveredDisplay}
@@ -349,7 +363,7 @@ class LocalDisplayManager {
349
363
  this.exitingApp = null
350
364
 
351
365
  if (this.currentDisplay?.packageName !== SYSTEM_NOTICE_PKG) return
352
- if (covered.expiresAt === null || covered.expiresAt > this.now()) {
366
+ if (covered && (covered.expiresAt === null || covered.expiresAt > this.now())) {
353
367
  this.restoreDisplay(covered)
354
368
  } else {
355
369
  this.currentDisplay = null
@@ -24,8 +24,10 @@ export interface LocalLlmModelConfig {
24
24
  id: LocalLlmModelId
25
25
  /** Human label for the Settings dropdown. */
26
26
  label: string
27
- /** Approximate download size in bytes (the .pte dominates). */
27
+ /** Total download size in bytes (the .pte dominates). */
28
28
  sizeBytes: number
29
+ /** Exact size of the published .pte; the availability check compares against it. */
30
+ modelBytes: number
29
31
  /** Rough resident memory while loaded; the Settings copy warns above ~half of device RAM. */
30
32
  ramBytes: number
31
33
  urls: {
@@ -54,8 +56,10 @@ export interface LocalLlmDownloadStatus {
54
56
 
55
57
  // XNNPACK 8da4w exports from software-mansion/react-native-executorch-qwen-3,
56
58
  // pinned to the v0.10.0 release tag that react-native-executorch 0.10.x
57
- // expects. Sizes are the .pte file sizes on the Hub (tokenizer adds ~11 MB).
59
+ // expects. modelBytes are the .pte sizes on the Hub; sizeBytes adds the
60
+ // tokenizer files (~11 MB).
58
61
  const QWEN3_BASE = "https://huggingface.co/software-mansion/react-native-executorch-qwen-3/resolve/v0.10.0"
62
+ const TOKENIZER_BYTES = 11_431_080
59
63
  const MB = 1024 * 1024
60
64
  const GB = 1024 * MB
61
65
 
@@ -63,7 +67,8 @@ const MODELS: Record<LocalLlmModelId, LocalLlmModelConfig> = {
63
67
  "qwen3-0.6b": {
64
68
  id: "qwen3-0.6b",
65
69
  label: "Qwen3 0.6B",
66
- sizeBytes: 517 * MB,
70
+ sizeBytes: 505_686_400 + TOKENIZER_BYTES,
71
+ modelBytes: 505_686_400,
67
72
  ramBytes: 0.9 * GB,
68
73
  urls: {
69
74
  model: `${QWEN3_BASE}/0_6b/xnnpack/qwen_3_0_6b_xnnpack_8da4w.pte`,
@@ -74,7 +79,8 @@ const MODELS: Record<LocalLlmModelId, LocalLlmModelConfig> = {
74
79
  "qwen3-1.7b": {
75
80
  id: "qwen3-1.7b",
76
81
  label: "Qwen3 1.7B",
77
- sizeBytes: 1315 * MB,
82
+ sizeBytes: 1_303_738_496 + TOKENIZER_BYTES,
83
+ modelBytes: 1_303_738_496,
78
84
  ramBytes: 1.8 * GB,
79
85
  urls: {
80
86
  model: `${QWEN3_BASE}/1_7b/xnnpack/qwen_3_1_7b_xnnpack_8da4w.pte`,
@@ -85,7 +91,8 @@ const MODELS: Record<LocalLlmModelId, LocalLlmModelConfig> = {
85
91
  "qwen3-4b": {
86
92
  id: "qwen3-4b",
87
93
  label: "Qwen3 4B",
88
- sizeBytes: 2693 * MB,
94
+ sizeBytes: 2_681_646_976 + TOKENIZER_BYTES,
95
+ modelBytes: 2_681_646_976,
89
96
  ramBytes: 3.4 * GB,
90
97
  urls: {
91
98
  model: `${QWEN3_BASE}/4b/xnnpack/qwen_3_4b_xnnpack_8da4w.pte`,
@@ -161,7 +168,7 @@ class LocalLlmModelManager {
161
168
  // most of the published size. Downloads land in a .part file and are
162
169
  // renamed only once whole, so this only catches external tampering.
163
170
  const stat = await RNFS.stat(paths.model)
164
- return Number(stat.size) >= MODELS[id].sizeBytes * 0.95
171
+ return Number(stat.size) >= MODELS[id].modelBytes * 0.95
165
172
  } catch (error) {
166
173
  console.warn("LOCAL_LLM: availability check failed:", error)
167
174
  return false
@@ -243,6 +250,9 @@ class LocalLlmModelManager {
243
250
  await this.fetchFile(config.urls.model, paths.model, (percent) =>
244
251
  this.setStatus({modelId: id, stage: "downloading", percent}),
245
252
  )
253
+ if (!(await this.isModelAvailable(id))) {
254
+ throw new Error(`downloaded model is smaller than the expected ${config.modelBytes} bytes`)
255
+ }
246
256
  this.setStatus({modelId: id, stage: "complete", percent: 100})
247
257
  for (const listener of this.modelReadyListeners) {
248
258
  try {
@@ -85,55 +85,74 @@ export function toTemplateTools(tools?: LlmTool[]): TemplateTool[] | undefined {
85
85
  }))
86
86
  }
87
87
 
88
- /** Rough tokens-per-character for a BPE vocabulary over mixed prose/JSON. */
89
- const CHARS_PER_TOKEN = 3.2
90
- /** Template framing per message (`<|im_start|>role … <|im_end|>`). */
91
- const TOKENS_PER_MESSAGE = 6
92
-
93
- export function estimateTokens(text: string): number {
94
- return Math.ceil(text.length / CHARS_PER_TOKEN)
95
- }
96
-
97
- export function estimateMessageTokens(message: TemplateMessage): number {
98
- let total = TOKENS_PER_MESSAGE + estimateTokens(message.content)
99
- if (message.role === "assistant" && message.tool_calls) {
100
- for (const call of message.tool_calls) total += estimateTokens(JSON.stringify(call.function)) + 8
101
- }
102
- return total
88
+ export interface FittedLocalPrompt {
89
+ prompt: string
90
+ promptTokens: number
91
+ truncated: boolean
103
92
  }
104
93
 
105
- const MIN_TOOL_CONTENT_CHARS = 1_200
106
-
107
94
  /**
108
- * Fit the conversation into `budgetTokens`. History is grouped into turns
109
- * (a user message plus everything up to the next user message, so a tool
110
- * round stays with the question it answers); the oldest turns go first and
111
- * the last turn is never dropped — the model must at least see the question.
112
- * When that last turn alone still does not fit, tool outputs are cut to
113
- * their head.
95
+ * Measure the complete rendered prompt with the model's tokenizer, including
96
+ * template framing, system instructions and tool schemas. Drop old turns first,
97
+ * then shorten tool results / message text in the final turn. Never cut template
98
+ * delimiters, system instructions, or structured tool calls to make them fit.
114
99
  */
115
- export function fitToBudget(messages: TemplateMessage[], budgetTokens: number, toolTokens = 0): TemplateMessage[] {
116
- const total = (list: TemplateMessage[]) => list.reduce((sum, m) => sum + estimateMessageTokens(m), toolTokens)
100
+ export async function fitPromptToContext(
101
+ messages: TemplateMessage[],
102
+ budgetTokens: number,
103
+ render: (messages: TemplateMessage[]) => string,
104
+ countTokens: (prompt: string) => Promise<number>,
105
+ ): Promise<FittedLocalPrompt> {
106
+ if (!Number.isInteger(budgetTokens) || budgetTokens < 1) {
107
+ throw new Error("On-device model has no room for a prompt after reserving output tokens")
108
+ }
117
109
  const system = messages[0]?.role === "system" ? [messages[0]] : []
118
110
  const turns: TemplateMessage[][] = []
119
111
  for (const message of messages.slice(system.length)) {
120
112
  if (message.role === "user" || turns.length === 0) turns.push([message])
121
113
  else turns[turns.length - 1]!.push(message)
122
114
  }
123
-
124
- while (turns.length > 1 && total([...system, ...turns.flat()]) > budgetTokens) {
125
- turns.shift()
126
- }
127
-
128
115
  let fitted = [...system, ...turns.flat()]
129
- if (total(fitted) > budgetTokens) {
130
- fitted = fitted.map((m) =>
131
- m.role === "tool" && m.content.length > MIN_TOOL_CONTENT_CHARS
132
- ? {...m, content: `${m.content.slice(0, MIN_TOOL_CONTENT_CHARS)}\n…[truncated]`}
133
- : m,
134
- )
116
+ let truncated = false
117
+ const marker = "\n…[truncated]…\n"
118
+ while (true) {
119
+ const prompt = render(fitted)
120
+ const promptTokens = await countTokens(prompt)
121
+ if (!Number.isInteger(promptTokens) || promptTokens < 1) {
122
+ throw new Error("On-device tokenizer returned an invalid prompt token count")
123
+ }
124
+ if (promptTokens <= budgetTokens) return {prompt, promptTokens, truncated}
125
+ truncated = true
126
+ if (turns.length > 1) {
127
+ turns.shift()
128
+ fitted = [...system, ...turns.flat()]
129
+ continue
130
+ }
131
+ // Preserve the user's question while tool output can still be shortened.
132
+ const candidates = fitted
133
+ .map((message, index) => ({message, index}))
134
+ .filter(({message}) => message.role !== "system" && Array.from(message.content).length > marker.length * 2)
135
+ .sort(
136
+ (a, b) =>
137
+ Number(b.message.role === "tool") - Number(a.message.role === "tool") ||
138
+ b.message.content.length - a.message.content.length,
139
+ )
140
+ const candidate = candidates[0]
141
+ if (!candidate) {
142
+ throw new Error(
143
+ `On-device prompt instructions and tool definitions exceed the context budget (${promptTokens} > ${budgetTokens} tokens)`,
144
+ )
145
+ }
146
+ // Keep both ends of long transcripts and questions, without splitting a
147
+ // Unicode code point. Each pass shrinks strictly, then remeasures the whole
148
+ // rendered prompt; character ratios are never used as a safety check.
149
+ const chars = Array.from(candidate.message.content)
150
+ const keep = Math.floor(chars.length / 4)
151
+ fitted[candidate.index] = {
152
+ ...candidate.message,
153
+ content: `${chars.slice(0, keep).join("")}${marker}${chars.slice(-keep).join("")}`,
154
+ }
135
155
  }
136
- return fitted
137
156
  }
138
157
 
139
158
  /** Remove a leading `<think>…</think>` block (or an unterminated one). */
@@ -22,9 +22,8 @@ import {BgTimer} from "../utils/timers"
22
22
  import localLlmModelManager, {type LocalLlmModelId} from "./LocalLlmModelManager"
23
23
  import {
24
24
  buildTemplateMessages,
25
- estimateTokens,
26
25
  finalizeLocalResult,
27
- fitToBudget,
26
+ fitPromptToContext,
28
27
  toTemplateTools,
29
28
  type LocalCompletion,
30
29
  } from "./LocalLlmPrompt"
@@ -33,6 +32,7 @@ import {SETTINGS, useSettingsStore} from "../stores/settings"
33
32
  type Rne = typeof import("react-native-executorch")
34
33
  // The runner API lives on the `llm` namespace; derive its types from there so
35
34
  // the import stays a type-only reference (no native module at load time).
35
+ type Tokenizer = ReturnType<Rne["nlp"]["loadTokenizer"]>
36
36
  type LLMRunner = ReturnType<Rne["llm"]["createLLMRunner"]>
37
37
  type LLMGenerationStats = ReturnType<LLMRunner["generate"]>
38
38
  type LLMGenerationConfig = NonNullable<Parameters<LLMRunner["generate"]>[1]>
@@ -43,14 +43,23 @@ const LOG_TAG = "LOCAL_LLM"
43
43
  const IDLE_UNLOAD_MS = 5 * 60_000
44
44
  /** Hard stop on one generation — the SDK request times out at 120 s anyway. */
45
45
  const GENERATION_DEADLINE_MS = 100_000
46
+ // One generating, one waiting. Assist's proactive insights re-request every
47
+ // time the SDK's 60 s timeout fires while a slow on-device run is still going,
48
+ // and an unbounded queue kept the phone generating for callers that had
49
+ // already given up (one reporter saw 472 s runs and 15 s touch latency).
50
+ const MAX_PENDING_REQUESTS = 2
51
+ // Under the SDK's 60 s request timeout: a request that waited longer has no caller.
52
+ const QUEUE_WAIT_LIMIT_MS = 55_000
46
53
  const DEFAULT_MAX_NEW_TOKENS = 512
47
54
  const MAX_NEW_TOKENS_CEILING = 1_024
48
- /** Headroom between the estimated prompt and the context end; estimates are rough. */
55
+ /** Reserve space for runner-added BOS/EOS tokens and context boundary bookkeeping. */
49
56
  const CONTEXT_MARGIN_TOKENS = 96
50
57
 
51
58
  interface LoadedModel {
52
59
  modelId: LocalLlmModelId
53
60
  runner: LLMRunner
61
+ tokenizer: Tokenizer
62
+ countTokens: (tokenizer: Tokenizer, prompt: string) => Promise<number>
54
63
  template: Template
55
64
  stopTokens: string[]
56
65
  maxSeqLen: number
@@ -85,12 +94,18 @@ function generateWorklet(
85
94
  return {text, stats}
86
95
  }
87
96
 
97
+ function countTokensWorklet(tokenizer: Tokenizer, prompt: string): number {
98
+ "worklet"
99
+ return tokenizer.encode(prompt).length
100
+ }
101
+
88
102
  class LocalLlmProvider {
89
103
  private static instance: LocalLlmProvider
90
104
 
91
105
  private loaded: LoadedModel | null = null
92
106
  private loading: Promise<LoadedModel> | null = null
93
107
  private queue: Promise<unknown> = Promise.resolve()
108
+ private pendingCount = 0
94
109
  private idleTimer: number | null = null
95
110
  private rne: Rne | null = null
96
111
 
@@ -120,9 +135,24 @@ class LocalLlmProvider {
120
135
 
121
136
  /** Serve one neutral completion on-device. Throws when the model is missing or generation fails. */
122
137
  complete(request: LlmCompleteRequest): Promise<LocalLlmCompleteResult> {
123
- const run = this.queue.then(() => this.completeInner(request))
138
+ if (this.pendingCount >= MAX_PENDING_REQUESTS) {
139
+ return Promise.reject(
140
+ new Error(`The on-device model is busy (${this.pendingCount} requests pending); try again shortly.`),
141
+ )
142
+ }
143
+ this.pendingCount += 1
144
+ const queuedAt = Date.now()
145
+ const run = this.queue.then(() => {
146
+ const waitedMs = Date.now() - queuedAt
147
+ if (waitedMs > QUEUE_WAIT_LIMIT_MS) {
148
+ throw new Error(`The on-device model was busy for ${Math.round(waitedMs / 1000)} s; request dropped.`)
149
+ }
150
+ return this.completeInner(request)
151
+ })
124
152
  this.queue = run.catch(() => undefined)
125
- return run
153
+ return run.finally(() => {
154
+ this.pendingCount -= 1
155
+ })
126
156
  }
127
157
 
128
158
  /** Release the resident runner (idle, model switch, memory pressure). Safe to call repeatedly. */
@@ -150,6 +180,8 @@ class LocalLlmProvider {
150
180
  loaded.runner.dispose()
151
181
  } catch (error) {
152
182
  console.warn(`${LOG_TAG}: runner dispose failed:`, error)
183
+ } finally {
184
+ loaded.tokenizer.dispose()
153
185
  }
154
186
  }
155
187
 
@@ -159,21 +191,21 @@ class LocalLlmProvider {
159
191
  const model = await this.ensureLoaded()
160
192
  const jsonMode = request.jsonMode === true
161
193
  const tools = toTemplateTools(request.tools)
162
- const toolTokens = tools ? estimateTokens(JSON.stringify(tools)) + 64 : 0
163
194
 
164
195
  const maxNewTokens = Math.max(
165
196
  16,
166
197
  Math.min(request.maxTokens ?? DEFAULT_MAX_NEW_TOKENS, MAX_NEW_TOKENS_CEILING, Math.floor(model.maxSeqLen / 4)),
167
198
  )
168
199
  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
- })
200
+ const {prompt, promptTokens, truncated} = await fitPromptToContext(
201
+ buildTemplateMessages({...request, jsonMode}),
202
+ promptBudget,
203
+ (messages) => model.template.render({messages, tools, add_generation_prompt: true, enable_thinking: false}),
204
+ (text) => model.countTokens(model.tokenizer, text),
205
+ )
206
+ console.log(
207
+ `${LOG_TAG}: ${model.modelId} prompt=${promptTokens} budget=${promptBudget} context=${model.maxSeqLen} maxNewTokens=${maxNewTokens} truncated=${truncated}`,
208
+ )
177
209
 
178
210
  model.runner.reset(0)
179
211
  const deadline = BgTimer.setTimeout(() => {
@@ -189,6 +221,12 @@ class LocalLlmProvider {
189
221
  {maxNewTokens, temperature: request.temperature ?? 0.7},
190
222
  model.stopTokens,
191
223
  )
224
+ } catch (error) {
225
+ console.warn(
226
+ `${LOG_TAG}: generation failed: model=${model.modelId} prompt=${promptTokens} context=${model.maxSeqLen} maxNewTokens=${maxNewTokens}`,
227
+ error,
228
+ )
229
+ throw error
192
230
  } finally {
193
231
  BgTimer.clearTimeout(deadline)
194
232
  }
@@ -246,16 +284,28 @@ class LocalLlmProvider {
246
284
  const startedAt = Date.now()
247
285
  const createRunner = rne.wrapAsync(rne.llm.createLLMRunner)
248
286
  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),
287
+ let tokenizer: Tokenizer | undefined
288
+ try {
289
+ const maxSeqLen = runner.getKVCacheState().maxSeqLen
290
+ if (!Number.isInteger(maxSeqLen) || maxSeqLen <= CONTEXT_MARGIN_TOKENS + 16) {
291
+ throw new Error(`On-device model ${modelId} reported an invalid context size: ${maxSeqLen}`)
292
+ }
293
+ tokenizer = await rne.wrapAsync(rne.nlp.loadTokenizer)(paths.tokenizer)
294
+ console.log(`${LOG_TAG}: loaded ${modelId} (context ${maxSeqLen}) in ${Date.now() - startedAt} ms`)
295
+ return {
296
+ modelId,
297
+ runner,
298
+ tokenizer,
299
+ countTokens: rne.wrapAsync(countTokensWorklet),
300
+ template,
301
+ stopTokens: [...tokenizerConfig.stopTokens],
302
+ maxSeqLen,
303
+ generate: rne.wrapAsync(generateWorklet),
304
+ }
305
+ } catch (error) {
306
+ tokenizer?.dispose()
307
+ runner.dispose()
308
+ throw error
259
309
  }
260
310
  }
261
311