@glassly/engine 0.3.3-dev.669 → 0.3.4-dev.674

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 +217 -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 +289 -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
@@ -14,9 +14,11 @@ import BluetoothSdk from "@glassly/bluetooth-sdk/internal"
14
14
  import type {
15
15
  ButtonPressEvent,
16
16
  ConnectOptions,
17
+ ControllerStatus,
17
18
  Device,
18
19
  KeyboardAdvertEvent,
19
20
  KeyboardPairingEvent,
21
+ PairedController,
20
22
  TouchEvent,
21
23
  } from "@glassly/bluetooth-sdk"
22
24
  import {useGlassesStore} from "../stores/glasses"
@@ -26,9 +28,11 @@ import micStateCoordinator from "../services/MicStateCoordinator"
26
28
  import {projectPairingIdentity} from "../services/PairingIdentity"
27
29
  import {isGlassesConnected, isGlassesReady} from "../services/GlassesReadiness"
28
30
  import {pushAllBluetoothSettings} from "../services/GlassesSettingsSync"
31
+ import {isControllerReady, pairedControllersFrom, readyControllers} from "../services/controllers"
29
32
  import neuralBandInput, {
30
33
  type NeuralBandClaimStatus,
31
34
  type NeuralBandHandwritingStatus,
35
+ type NeuralBandHandwritingUpdate,
32
36
  } from "../services/neuralBand/NeuralBandInput"
33
37
  import {recordSupportProfileConnectionFailure} from "../services/SupportProfileSync"
34
38
  import {getModelCapabilities, type DeviceTypes} from "../types"
@@ -55,14 +59,17 @@ function projectStatus() {
55
59
  }
56
60
  }
57
61
 
58
- function projectControllerStatus() {
59
- const s = useGlassesStore.getState()
60
- return {
61
- connected: s.controllerConnected,
62
- fullyBooted: s.controllerFullyBooted,
63
- battery: s.controllerBatteryLevel,
64
- signal: s.controllerSignalStrength,
62
+ function projectControllerList(): PairedController[] {
63
+ return pairedControllersFrom(useSettingsStore.getState().getSetting(SETTINGS.controllers.key))
64
+ }
65
+
66
+ // Copies: snapshots must not hand callers mutable references into the store.
67
+ function projectControllerStatuses(): Record<string, ControllerStatus> {
68
+ const statuses: Record<string, ControllerStatus> = {}
69
+ for (const [id, status] of Object.entries(useGlassesStore.getState().controllers ?? {})) {
70
+ statuses[id] = {...status}
65
71
  }
72
+ return statuses
66
73
  }
67
74
 
68
75
  function projectKeyboardStatus() {
@@ -107,7 +114,6 @@ function projectInfo() {
107
114
  }
108
115
 
109
116
  export type GlassesStatusSnapshot = ReturnType<typeof projectStatus>
110
- export type ControllerStatusSnapshot = ReturnType<typeof projectControllerStatus>
111
117
  export type KeyboardStatusSnapshot = ReturnType<typeof projectKeyboardStatus>
112
118
  export type GlassesInfoSnapshot = ReturnType<typeof projectInfo>
113
119
 
@@ -259,45 +265,68 @@ export const glasses = {
259
265
  },
260
266
  },
261
267
 
262
- // --- ring controller (optional input device) ---
263
- controller: {
264
- connectDefault: (): Promise<void> => BluetoothSdk.connectDefaultController(),
265
- disconnect: (): Promise<void> => BluetoothSdk.disconnectController(),
266
- forget: (): Promise<void> => BluetoothSdk.forgetController(),
267
- status: (): ControllerStatusSnapshot => projectControllerStatus(),
268
- onStatus: (cb: (status: ControllerStatusSnapshot) => void): (() => void) => {
269
- let last = JSON.stringify(projectControllerStatus())
270
- return useGlassesStore.subscribe(() => {
271
- const snap = projectControllerStatus()
272
- const key = JSON.stringify(snap)
268
+ // --- controllers (input accessories linked to the phone), by registry id ---
269
+ controllers: {
270
+ /** Paired controllers in pairing order. */
271
+ list: (): PairedController[] => projectControllerList(),
272
+ onList: (cb: (controllers: PairedController[]) => void): (() => void) => {
273
+ let last = JSON.stringify(projectControllerList())
274
+ return useSettingsStore.subscribe(() => {
275
+ const list = projectControllerList()
276
+ const key = JSON.stringify(list)
273
277
  if (key === last) return
274
278
  last = key
275
- cb(snap)
279
+ cb(list)
276
280
  })
277
281
  },
282
+ /** Live driver statuses by id; a paired controller without an entry is idle. */
283
+ statuses: (): Record<string, ControllerStatus> => projectControllerStatuses(),
284
+ status: (id: string): ControllerStatus | undefined => projectControllerStatuses()[id],
285
+ onStatus: (cb: (statuses: Record<string, ControllerStatus>) => void): (() => void) =>
286
+ useGlassesStore.subscribe(
287
+ (s) => s.controllers,
288
+ () => cb(projectControllerStatuses()),
289
+ ),
290
+ /** Controllers delivering input (linked, route or claim done). */
291
+ connected: (): ControllerStatus[] => readyControllers(projectControllerStatuses()),
292
+ isConnected: (model?: string): boolean =>
293
+ Object.values(useGlassesStore.getState().controllers ?? {}).some(
294
+ (status) => isControllerReady(status) && (model === undefined || status.model === model),
295
+ ),
296
+ connect: (id: string): Promise<void> => BluetoothSdk.connectController(id),
297
+ /** Reconnect every paired controller that is not connected. */
298
+ connectAll: (): Promise<void> => BluetoothSdk.connectControllers(),
299
+ /** Drop an in-flight controller scan or pairing; paired records are untouched. */
300
+ cancelAttempt: (): Promise<void> => BluetoothSdk.cancelControllerAttempt(),
301
+ /** Disconnect; the controller stays paired. */
302
+ disconnect: (id: string): Promise<void> => BluetoothSdk.disconnectController(id),
303
+ forget: async (id: string): Promise<void> => {
304
+ await BluetoothSdk.forgetController(id)
305
+ neuralBandInput.forget(id)
306
+ },
278
307
  },
279
308
 
280
- // --- Meta Neural Band (a controller with a claim step and its own features) ---
309
+ // --- Meta Neural Band (a controller with a claim step and its own features), by controller id ---
281
310
  neuralBand: {
282
- /** Store the Meta account session the one-time band claim uses. */
311
+ /** Store the Meta account session the one-time band claim uses (account-wide). */
283
312
  setMetaSession: (accessToken: string, userId: string): Promise<void> =>
284
313
  BluetoothSdk.neuralBandSetMetaSession(accessToken, userId),
285
314
  clearMetaSession: (): Promise<void> => BluetoothSdk.neuralBandClearMetaSession(),
286
315
  hasMetaSession: (): Promise<boolean> => BluetoothSdk.neuralBandHasMetaSession(),
287
- /** A band identity is stored on this phone, so the remembered band connects without a claim. */
288
- isClaimed: (): Promise<boolean> => BluetoothSdk.neuralBandIsClaimed(),
289
- /** The latest claim progress, kept across screens. */
290
- claimStatus: (): NeuralBandClaimStatus => neuralBandInput.claimStatus(),
316
+ /** This band's identity is stored on this phone, so it reconnects without a claim. */
317
+ isClaimed: (id: string): Promise<boolean> => BluetoothSdk.neuralBandIsClaimed(id),
318
+ /** The latest claim progress of any band (with its `controllerId`), kept across screens. */
319
+ claimStatus: (): NeuralBandClaimStatus => ({...neuralBandInput.claimStatus()}),
291
320
  resetClaim: (): void => neuralBandInput.resetClaim(),
292
321
  onClaimStatus: (cb: (status: NeuralBandClaimStatus) => void): (() => void) => neuralBandInput.onClaimStatus(cb),
293
322
  /** The band's handwriting model; writing arrives on the miniapp keyboard stream. */
294
- setHandwriting: (enabled: boolean): Promise<void> => BluetoothSdk.neuralBandSetHandwriting(enabled),
295
- handwritingStatus: (): NeuralBandHandwritingStatus => neuralBandInput.handwritingStatus(),
296
- onHandwritingStatus: (cb: (status: NeuralBandHandwritingStatus) => void): (() => void) =>
323
+ setHandwriting: (id: string, enabled: boolean): Promise<void> => BluetoothSdk.neuralBandSetHandwriting(id, enabled),
324
+ handwritingStatus: (id: string): NeuralBandHandwritingStatus => ({...neuralBandInput.handwritingStatus(id)}),
325
+ onHandwritingStatus: (cb: (update: NeuralBandHandwritingUpdate) => void): (() => void) =>
297
326
  neuralBandInput.onHandwritingStatus(cb),
298
327
  /** The band's air mouse; movement reaches miniapps on the pointer stream. */
299
- setPointer: (enabled: boolean): Promise<void> => BluetoothSdk.neuralBandSetPointer(enabled),
300
- setHand: (hand: "left" | "right"): Promise<void> => BluetoothSdk.neuralBandSetHand(hand),
328
+ setPointer: (id: string, enabled: boolean): Promise<void> => BluetoothSdk.neuralBandSetPointer(id, enabled),
329
+ setHand: (id: string, hand: "left" | "right"): Promise<void> => BluetoothSdk.neuralBandSetHand(id, hand),
301
330
  },
302
331
 
303
332
  // --- audio (glasses media-volume, own-app playback hint, live mic level) ---
@@ -23,6 +23,7 @@ const INTERNAL_KEYS = new Set<string>([
23
23
  SETTINGS.device_name.key,
24
24
  SETTINGS.device_address.key,
25
25
  SETTINGS.project_name.key,
26
+ SETTINGS.controllers.key,
26
27
  SETTINGS.pending_controller.key,
27
28
  SETTINGS.default_controller.key,
28
29
  SETTINGS.controller_device_name.key,
@@ -13,7 +13,7 @@ import BluetoothSdk from "@glassly/bluetooth-sdk/internal"
13
13
  import type {ConnectOptions, Device, DeviceModel, PairFailureEvent, GlassesNotReadyEvent} from "@glassly/bluetooth-sdk"
14
14
  import {useCoreStore} from "../stores/core"
15
15
  import {useGlassesStore} from "../stores/glasses"
16
- import {SETTINGS, useSettingsStore} from "../stores/settings"
16
+ import {useSettingsStore} from "../stores/settings"
17
17
  import {hasDefaultDevice} from "../services/DeviceStoreHydration"
18
18
  import {
19
19
  markPendingSelection,
@@ -28,7 +28,7 @@ import {
28
28
  type AutomaticReportSubmissionStatus,
29
29
  } from "../services/AutomaticReportResult"
30
30
  import {pushAllBluetoothSettings} from "../services/GlassesSettingsSync"
31
- import {ControllerTypes} from "../types"
31
+ import {isControllerModel, isControllerReady} from "../services/controllers"
32
32
  import {submitAutomaticReport} from "./reports"
33
33
 
34
34
  import {BgTimer} from "../utils/timers"
@@ -63,12 +63,15 @@ function projectTargetReady(options: Pick<PairingReadyWaitOptions, "deviceModel"
63
63
  const {deviceModel, deviceName} = options
64
64
  const glasses = useGlassesStore.getState()
65
65
 
66
- if (deviceModel !== undefined && (Object.values(ControllerTypes) as string[]).includes(deviceModel)) {
67
- if (!glasses.controllerConnected || !glasses.controllerFullyBooted) return false
68
- const settings = useSettingsStore.getState()
69
- const pairedModel = settings.getSetting(SETTINGS.default_controller.key)
70
- const pairedName = settings.getSetting(SETTINGS.controller_device_name.key)
71
- return (!deviceModel || pairedModel === deviceModel) && (!deviceName || pairedName === deviceName)
66
+ // A controller target is ready once some driver of that model delivers input;
67
+ // the scan's device name is the R1's ring id or the band's Bluetooth name.
68
+ if (isControllerModel(deviceModel)) {
69
+ return Object.values(glasses.controllers ?? {}).some(
70
+ (status) =>
71
+ status.model === deviceModel &&
72
+ isControllerReady(status) &&
73
+ (!deviceName || status.name === deviceName || status.id === deviceName),
74
+ )
72
75
  }
73
76
 
74
77
  if (!isGlassesReady(glasses.connection)) return false
@@ -283,17 +286,12 @@ export const pairing = {
283
286
  * host renders it as a finish-pairing affordance until pairing succeeds. */
284
287
  markPendingSelection: (model: string) => markPendingSelection(model),
285
288
 
286
- /** Start scanning for nearby glasses. Results land on `searchResults()`/`onFound()`. */
289
+ /** Start scanning for nearby glasses or controllers of `model`. Results land on `searchResults()`/`onFound()`. */
287
290
  scan: (model: DeviceModel): Promise<void> => BluetoothSdk.startScan(model),
288
291
  /** Whether a scan is currently in progress. */
289
292
  scanning: (): boolean => useCoreStore.getState().searching,
290
293
  /** Subscribe to scan-in-progress changes; fires only when it changes. Returns an unsubscribe. */
291
294
  onScanning: (cb: (scanning: boolean) => void): (() => void) => useCoreStore.subscribe((s) => s.searching, cb),
292
- /** Whether a controller scan is currently in progress. */
293
- scanningController: (): boolean => useCoreStore.getState().searchingController,
294
- /** Subscribe to controller-scan-in-progress changes; fires only when it changes. Returns an unsubscribe. */
295
- onScanningController: (cb: (scanning: boolean) => void): (() => void) =>
296
- useCoreStore.subscribe((s) => s.searchingController, cb),
297
295
  /** Whether the phone has another Bluetooth (audio) device connected — the BT Classic pairing hint. */
298
296
  otherBtConnected: (): boolean => useCoreStore.getState().otherBtConnected,
299
297
  /** Subscribe to other-Bluetooth-device-connected changes; fires only when it changes. Returns an unsubscribe. */
@@ -394,6 +392,12 @@ export const pairing = {
394
392
  await BluetoothSdk.forget()
395
393
  },
396
394
 
395
+ /**
396
+ * Abandon an in-flight controller pairing (back-out, failure retry). Only the
397
+ * attempt's driver is dropped; paired controllers and the glasses are untouched.
398
+ */
399
+ abandonControllerAttempt: (): Promise<void> => BluetoothSdk.cancelControllerAttempt(),
400
+
397
401
  /** Subscribe to pairing failures; returns an unsubscribe. */
398
402
  onPairFailure: (cb: (event: PairFailureEvent) => void): (() => void) => {
399
403
  const sub = BluetoothSdk.addListener("pair_failure", cb)
@@ -11,10 +11,10 @@ export interface EngineReleaseMetadata {
11
11
 
12
12
  export const ENGINE_RELEASE_METADATA: Readonly<EngineReleaseMetadata> = Object.freeze({
13
13
  "schemaVersion": 1,
14
- "familyBaseVersion": "0.3.3",
15
- "releaseIdentity": "0.3.3-dev.669",
16
- "releaseSetId": "glassly-0.3.3-dev.669",
17
- "sourceCommit": "b2dc9c028ab169ec3c98f35b7b56875febb4f536",
14
+ "familyBaseVersion": "0.3.4",
15
+ "releaseIdentity": "0.3.4-dev.674",
16
+ "releaseSetId": "glassly-0.3.4-dev.674",
17
+ "sourceCommit": "2f5ad0a66dd2ffde5eb1dba2994437fb58e0d367",
18
18
  "otaManifestUrl": null,
19
19
  "otaManifestSha256": null
20
20
  })
package/src/index.ts CHANGED
@@ -46,12 +46,22 @@ export type {
46
46
  export {SETTINGS, useSetting, AI_DATA_CONSENT_VERSION} from "./stores/settings"
47
47
  export {isCfwFirmwareVersion, isCustomDashboardEligible} from "./services/pages/pageEligibility"
48
48
  export {getEffectiveCapabilities} from "./services/effectiveCapabilities"
49
+ export {isControllerModel, isControllerReady} from "./services/controllers"
49
50
 
50
51
  // AI tier registry: the curated per-tier model dropdown options and helpers
51
52
  // the Settings > AI Models screen renders. Resolution itself runs inside
52
53
  // LocalMiniappRuntime; the screen only reads/writes the ai_model_* settings.
53
54
  export {AI_TIERS, TIER_MODEL_OPTIONS, parseTierSetting} from "./services/AiTierResolution"
54
55
  export type {AiTier, AiTierProvider, TierModelOption} from "./services/AiTierResolution"
56
+ // On-device LLM: the model catalog and download status the same screen
57
+ // renders for the phone-side model (engine.ai.local).
58
+ export type {
59
+ LocalLlmDownloadStage,
60
+ LocalLlmDownloadStatus,
61
+ LocalLlmModelConfig,
62
+ LocalLlmModelId,
63
+ } from "./services/LocalLlmModelManager"
64
+ export {DEFAULT_LOCAL_LLM_MODEL, isLocalLlmModelId} from "./services/LocalLlmModelManager"
55
65
 
56
66
  // Shared user memory timeline (engine.memory / Settings > Memory). Entry and
57
67
  // session shapes are canonical in @glassly/miniapp (the session.memory
@@ -157,7 +167,7 @@ export type {
157
167
 
158
168
  // Bluetooth SDK event types host UI subscribes to via engine facades (the
159
169
  // BluetoothSdk singleton passthrough itself is internal).
160
- export type {PairFailureEvent, GlassesNotReadyEvent} from "@glassly/bluetooth-sdk"
170
+ export type {PairFailureEvent, GlassesNotReadyEvent, PairedController, ControllerStatus} from "@glassly/bluetooth-sdk"
161
171
 
162
172
  // Cloud connection status enum (read model; the connection store is internal).
163
173
  export {WebSocketStatus} from "./stores/connection"
@@ -215,7 +225,11 @@ export {
215
225
  type MetaSession,
216
226
  type MetaSsoTokens,
217
227
  } from "./services/neuralBand/MetaAuth"
218
- export type {NeuralBandClaimStatus, NeuralBandHandwritingStatus} from "./services/neuralBand/NeuralBandInput"
228
+ export type {
229
+ NeuralBandClaimStatus,
230
+ NeuralBandHandwritingStatus,
231
+ NeuralBandHandwritingUpdate,
232
+ } from "./services/neuralBand/NeuralBandInput"
219
233
 
220
234
  // Hardware types and capability profiles.
221
235
  export {
@@ -40,6 +40,13 @@ import dashboardOpenTrace from "./debug/DashboardOpenTrace"
40
40
 
41
41
  let subs: Array<{remove: () => void}> = []
42
42
 
43
+ // Array/object echoes (the controller registry) arrive as fresh objects.
44
+ function sameSettingValue(current: unknown, next: unknown): boolean {
45
+ if (current === next) return true
46
+ if (typeof current !== "object" || typeof next !== "object" || current === null || next === null) return false
47
+ return JSON.stringify(current) === JSON.stringify(next)
48
+ }
49
+
43
50
  export function startDeviceEventRouter(): void {
44
51
  if (subs.length) return
45
52
 
@@ -173,7 +180,7 @@ export function startDeviceEventRouter(): void {
173
180
  // Damp the relay: native re-echoes some settings unconditionally (e.g.
174
181
  // the identity block at every handleDeviceReady) — a same-value echo
175
182
  // must not become a store write (persistence churn + change-push noise).
176
- if (settings.getSetting(event.key) !== event.value) {
183
+ if (!sameSettingValue(settings.getSetting(event.key), event.value)) {
177
184
  await settings.setSetting(event.key, event.value)
178
185
  }
179
186
  // Two-phase identity: a promoted default retires the pending selection
@@ -53,14 +53,18 @@ function fingerGlyph(glyph: GestureDotGlyph, finger: number): GestureDotGlyph {
53
53
  return finger === 1 ? (MIDDLE_FINGER_GLYPHS[glyph] ?? glyph) : glyph
54
54
  }
55
55
 
56
+ function fingerKey(controllerId: string | undefined, finger: number): string {
57
+ return `${controllerId ?? ""}:${finger}`
58
+ }
59
+
56
60
  class GestureDotsService {
57
61
  private started = false
58
62
  private flash: GestureDotGlyph | null = null
59
63
  private flashTimer: number | null = null
60
- /** Raw controller holds survive hold recognition/dial events until their own release. */
61
- private presses = new Map<number, {raw: boolean; glyph: GestureDotGlyph}>()
64
+ /** Raw controller holds survive hold recognition/dial events until their own release. Keyed by fingerKey. */
65
+ private presses = new Map<string, {raw: boolean; glyph: GestureDotGlyph}>()
62
66
  /** When each finger last sent a press edge (touch_down, or tap_end after one). */
63
- private lastEdgeAt = new Map<number, number>()
67
+ private lastEdgeAt = new Map<string, number>()
64
68
  /** Avoid sending identical images for successive phases of the same press. */
65
69
  private renderedGlyph: GestureDotGlyph | null = null
66
70
  /** Whether the indicator scene currently has anything on it (needs a clear when the flash ends). */
@@ -120,15 +124,16 @@ class GestureDotsService {
120
124
  * Flash a gesture on the indicator. Other gesture names are ignored.
121
125
  * Claimed gestures cancel any earlier press/flash without starting a new
122
126
  * one: the claimant may also consume the release edge. `fingerIndex` is the
123
- * Neural Band's finger on its pinches and press edges (1 = middle).
127
+ * Neural Band's finger on its pinches and press edges (1 = middle);
128
+ * `controllerId` keeps two controllers' fingers apart.
124
129
  */
125
- noteGesture(gestureName: string, claimed = false, fingerIndex?: number): void {
130
+ noteGesture(gestureName: string, claimed = false, fingerIndex?: number, controllerId?: string): void {
126
131
  if (!this.started || !this.enabled()) return
127
- this.applyGesture(gestureName, claimed, fingerIndex)
132
+ this.applyGesture(gestureName, claimed, fingerIndex, controllerId)
128
133
  this.trace("input", {gestureName, claimed})
129
134
  }
130
135
 
131
- private applyGesture(gestureName: string, claimed: boolean, fingerIndex?: number): void {
136
+ private applyGesture(gestureName: string, claimed: boolean, fingerIndex?: number, controllerId?: string): void {
132
137
  if (localDisplayManager.hasActiveDisplay() || useDisplayStore.getState().overlayOwner !== null) {
133
138
  this.relinquish()
134
139
  return
@@ -139,9 +144,10 @@ class GestureDotsService {
139
144
  return
140
145
  }
141
146
  const finger = fingerIndex ?? 0
142
- const press = this.presses.get(finger)
147
+ const key = fingerKey(controllerId, finger)
148
+ const press = this.presses.get(key)
143
149
  if (gestureName === "touch_down" || gestureName === "tap_start") {
144
- if (gestureName === "touch_down") this.lastEdgeAt.set(finger, Date.now())
150
+ if (gestureName === "touch_down") this.lastEdgeAt.set(key, Date.now())
145
151
  if (press) {
146
152
  // Both reports describe one press: after touch_down, tap_start means it is held.
147
153
  if (gestureName === "touch_down") press.raw = true
@@ -150,14 +156,14 @@ class GestureDotsService {
150
156
  return
151
157
  }
152
158
  this.clearFlash()
153
- this.presses.set(finger, {raw: gestureName === "touch_down", glyph: fingerGlyph("dot", finger)})
159
+ this.presses.set(key, {raw: gestureName === "touch_down", glyph: fingerGlyph("dot", finger)})
154
160
  this.render()
155
161
  return
156
162
  }
157
163
  if (gestureName === "tap_end") {
158
164
  if (!press) return
159
- this.presses.delete(finger)
160
- if (press.raw) this.lastEdgeAt.set(finger, Date.now())
165
+ this.presses.delete(key)
166
+ if (press.raw) this.lastEdgeAt.set(key, Date.now())
161
167
  this.render()
162
168
  return
163
169
  }
@@ -173,11 +179,11 @@ class GestureDotsService {
173
179
  this.render()
174
180
  return
175
181
  }
176
- const lastEdgeAt = this.lastEdgeAt.get(finger)
182
+ const lastEdgeAt = this.lastEdgeAt.get(key)
177
183
  if (!press && shownByPress && lastEdgeAt !== undefined && Date.now() - lastEdgeAt < EDGE_REPORT_GRACE_MS) return
178
184
  // Completed taps/swipes become one continuous pulse, even if the device
179
185
  // omits tap_end. Older tap_start-only devices retain the same fallback.
180
- this.presses.delete(finger)
186
+ this.presses.delete(key)
181
187
  if (glyph) this.startFlash(glyph)
182
188
  this.render()
183
189
  }
@@ -28,16 +28,18 @@ const miniappExitGesture = new MiniappExitGesture({
28
28
  // Raw ring reports include every scroll step, whether the CFW forwards them
29
29
  // (deviceModel G2) or the ring sends them to the phone itself (deviceModel R1);
30
30
  // both tag source 2. Rate-limit scroll actions separately from press/release
31
- // recognition, using native receipt timestamps so a JS backlog does not
32
- // collapse otherwise separate gestures.
31
+ // recognition, per ring, using native receipt timestamps so a JS backlog does
32
+ // not collapse otherwise separate gestures.
33
33
  const RING_SCROLL_INTERVAL_MS = 100
34
- let lastRingScrollAt: number | null = null
34
+ const lastRingScrollAt = new Map<string, number>()
35
35
 
36
36
  function shouldForwardScroll(event: TouchEvent): boolean {
37
37
  if (event.source !== 2 || (event.gestureName !== "swipe_up" && event.gestureName !== "swipe_down")) return true
38
- const elapsed = lastRingScrollAt === null ? null : event.timestamp - lastRingScrollAt
38
+ const ring = event.controllerId ?? `source:${event.source}`
39
+ const last = lastRingScrollAt.get(ring)
40
+ const elapsed = last === undefined ? null : event.timestamp - last
39
41
  if (elapsed !== null && elapsed >= 0 && elapsed < RING_SCROLL_INTERVAL_MS) return false
40
- lastRingScrollAt = event.timestamp
42
+ lastRingScrollAt.set(ring, event.timestamp)
41
43
  return true
42
44
  }
43
45
 
@@ -72,7 +74,7 @@ export function dispatchTouchEvent(event: TouchEvent): void {
72
74
  !localMiniappRuntime.hasGestureClaim(visible, gesture) &&
73
75
  miniappExitGesture.tryExitVisibleMiniapp()
74
76
  ) {
75
- gestureDotsService.noteGesture(gesture, true)
77
+ gestureDotsService.noteGesture(gesture, true, event.index, event.controllerId)
76
78
  return
77
79
  }
78
80
  }
@@ -83,7 +85,7 @@ export function dispatchTouchEvent(event: TouchEvent): void {
83
85
  dashboardOpenTrace.trigger(osHeadUp ? "head-up" : "double-tap", event.timestamp)
84
86
  }
85
87
  // The press may have started before this app claimed its release edge.
86
- gestureDotsService.noteGesture(gesture, true, event.index)
88
+ gestureDotsService.noteGesture(gesture, true, event.index, event.controllerId)
87
89
  if (!shouldForwardScroll(event)) return
88
90
  localMiniappRuntime.forwardEvent("touch_event", event, undefined, claimant)
89
91
  return
@@ -92,7 +94,7 @@ export function dispatchTouchEvent(event: TouchEvent): void {
92
94
  // Gesture-dots debug indicator (any device, gated by its setting). It
93
95
  // only paints while the screen is otherwise empty. Process even suppressed
94
96
  // scroll repeats here so their completion still clears a held dot.
95
- gestureDotsService.noteGesture(gesture, false, event.index)
97
+ gestureDotsService.noteGesture(gesture, false, event.index, event.controllerId)
96
98
 
97
99
  if (!shouldForwardScroll(event)) return
98
100
  localMiniappRuntime.forwardEvent("touch_event", event)
@@ -116,7 +118,7 @@ export function dispatchTouchEvent(event: TouchEvent): void {
116
118
 
117
119
  export function resetTouchInput(): void {
118
120
  miniappExitGesture.reset()
119
- lastRingScrollAt = null
121
+ lastRingScrollAt.clear()
120
122
  }
121
123
 
122
124
  /** Phone mirror input uses the same dashboard/miniapp policy as real glasses. */
@@ -0,0 +1,49 @@
1
+ /**
2
+ * LlmRouting — pure decision logic for where one session.ai.complete call
3
+ * runs: the PRIMARY path (vendor-direct on a user key, or the cloud relay) or
4
+ * the on-device model. Same shape as TranscriptionRouting.ts so the rules are
5
+ * unit-testable; LocalMiniappRuntime supplies the inputs and does the work.
6
+ */
7
+
8
+ export interface LlmRouteInputs {
9
+ /** Request asked for on-device only (`forceLocal: true`). */
10
+ forceLocal: boolean
11
+ /** Request accepts on-device when the primary is down (`localFallback: true`). */
12
+ localFallback: boolean
13
+ /**
14
+ * Whether the primary path is currently believed usable: a saved user key
15
+ * for the resolved provider, or cloud connected + not disabled in Privacy
16
+ * + monthly AI credit not exhausted.
17
+ */
18
+ primaryUp: boolean
19
+ }
20
+
21
+ /**
22
+ * - "local": run on-device, never touch the primary.
23
+ * - "primary": primary only; failures surface as today.
24
+ * - "primary_then_local": try the primary, and on any failure serve the same
25
+ * request on-device.
26
+ */
27
+ export type LlmRoute = "local" | "primary" | "primary_then_local"
28
+
29
+ export function decideLlmRoute({forceLocal, localFallback, primaryUp}: LlmRouteInputs): LlmRoute {
30
+ if (forceLocal) return "local"
31
+ if (!localFallback) return "primary"
32
+ return primaryUp ? "primary_then_local" : "local"
33
+ }
34
+
35
+ /** Why a request landed on-device; reported in logs and the result's `model` line. */
36
+ export type LocalLlmReason = "force_local" | "cloud_disconnected" | "cloud_disabled" | "llm_quota" | "primary_failed"
37
+
38
+ export function localLlmReason(inputs: {
39
+ forceLocal: boolean
40
+ cloudDisabled: boolean
41
+ cloudConnected: boolean
42
+ quotaBlocked: boolean
43
+ }): LocalLlmReason {
44
+ if (inputs.forceLocal) return "force_local"
45
+ if (inputs.cloudDisabled) return "cloud_disabled"
46
+ if (inputs.quotaBlocked) return "llm_quota"
47
+ if (!inputs.cloudConnected) return "cloud_disconnected"
48
+ return "primary_failed"
49
+ }