@glassly/engine 0.3.3-dev.670 → 0.3.4-beta.685

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 +14 -3
  30. package/build/services/GestureDotsService.d.ts.map +1 -1
  31. package/build/services/GestureDotsService.js +48 -13
  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 +29 -3
  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 +52 -15
  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 +29 -3
@@ -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.670",
16
- "releaseSetId": "glassly-0.3.3-dev.670",
17
- "sourceCommit": "2a19d64f1d228e74e3e49f4321264e7bfe446b75",
14
+ "familyBaseVersion": "0.3.4",
15
+ "releaseIdentity": "0.3.4-beta.685",
16
+ "releaseSetId": "glassly-0.3.4-beta.685",
17
+ "sourceCommit": "947444146e20f43af57384aaa113a10fb545f283",
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
@@ -50,17 +50,25 @@ const MIDDLE_FINGER_GLYPHS: Partial<Record<GestureDotGlyph, GestureDotGlyph>> =
50
50
 
51
51
  /** `glyph` as `finger` draws it: the middle finger's shapes are hollow. */
52
52
  function fingerGlyph(glyph: GestureDotGlyph, finger: number): GestureDotGlyph {
53
- return finger === 1 ? (MIDDLE_FINGER_GLYPHS[glyph] ?? glyph) : glyph
53
+ return finger === 1 ? MIDDLE_FINGER_GLYPHS[glyph] ?? glyph : glyph
54
+ }
55
+
56
+ function fingerKey(controllerId: string | undefined, finger: number): string {
57
+ return `${controllerId ?? ""}:${finger}`
58
+ }
59
+
60
+ function fingerOf(key: string): number {
61
+ return Number(key.slice(key.lastIndexOf(":") + 1))
54
62
  }
55
63
 
56
64
  class GestureDotsService {
57
65
  private started = false
58
66
  private flash: GestureDotGlyph | null = null
59
67
  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}>()
68
+ /** Raw controller holds survive hold recognition/dial events until their own release. Keyed by fingerKey. */
69
+ private presses = new Map<string, {raw: boolean; glyph: GestureDotGlyph}>()
62
70
  /** When each finger last sent a press edge (touch_down, or tap_end after one). */
63
- private lastEdgeAt = new Map<number, number>()
71
+ private lastEdgeAt = new Map<string, number>()
64
72
  /** Avoid sending identical images for successive phases of the same press. */
65
73
  private renderedGlyph: GestureDotGlyph | null = null
66
74
  /** Whether the indicator scene currently has anything on it (needs a clear when the flash ends). */
@@ -120,15 +128,16 @@ class GestureDotsService {
120
128
  * Flash a gesture on the indicator. Other gesture names are ignored.
121
129
  * Claimed gestures cancel any earlier press/flash without starting a new
122
130
  * 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).
131
+ * Neural Band's finger on its pinches and press edges (1 = middle);
132
+ * `controllerId` keeps two controllers' fingers apart.
124
133
  */
125
- noteGesture(gestureName: string, claimed = false, fingerIndex?: number): void {
134
+ noteGesture(gestureName: string, claimed = false, fingerIndex?: number, controllerId?: string): void {
126
135
  if (!this.started || !this.enabled()) return
127
- this.applyGesture(gestureName, claimed, fingerIndex)
136
+ this.applyGesture(gestureName, claimed, fingerIndex, controllerId)
128
137
  this.trace("input", {gestureName, claimed})
129
138
  }
130
139
 
131
- private applyGesture(gestureName: string, claimed: boolean, fingerIndex?: number): void {
140
+ private applyGesture(gestureName: string, claimed: boolean, fingerIndex?: number, controllerId?: string): void {
132
141
  if (localDisplayManager.hasActiveDisplay() || useDisplayStore.getState().overlayOwner !== null) {
133
142
  this.relinquish()
134
143
  return
@@ -139,9 +148,10 @@ class GestureDotsService {
139
148
  return
140
149
  }
141
150
  const finger = fingerIndex ?? 0
142
- const press = this.presses.get(finger)
151
+ const key = this.pressKey(controllerId, finger)
152
+ const press = this.presses.get(key)
143
153
  if (gestureName === "touch_down" || gestureName === "tap_start") {
144
- if (gestureName === "touch_down") this.lastEdgeAt.set(finger, Date.now())
154
+ if (gestureName === "touch_down") this.lastEdgeAt.set(key, Date.now())
145
155
  if (press) {
146
156
  // Both reports describe one press: after touch_down, tap_start means it is held.
147
157
  if (gestureName === "touch_down") press.raw = true
@@ -150,14 +160,14 @@ class GestureDotsService {
150
160
  return
151
161
  }
152
162
  this.clearFlash()
153
- this.presses.set(finger, {raw: gestureName === "touch_down", glyph: fingerGlyph("dot", finger)})
163
+ this.presses.set(key, {raw: gestureName === "touch_down", glyph: fingerGlyph("dot", finger)})
154
164
  this.render()
155
165
  return
156
166
  }
157
167
  if (gestureName === "tap_end") {
158
168
  if (!press) return
159
- this.presses.delete(finger)
160
- if (press.raw) this.lastEdgeAt.set(finger, Date.now())
169
+ this.presses.delete(key)
170
+ if (press.raw) this.lastEdgeAt.set(key, Date.now())
161
171
  this.render()
162
172
  return
163
173
  }
@@ -173,15 +183,42 @@ class GestureDotsService {
173
183
  this.render()
174
184
  return
175
185
  }
176
- const lastEdgeAt = this.lastEdgeAt.get(finger)
186
+ const lastEdgeAt = this.lastEdgeFor(finger)
177
187
  if (!press && shownByPress && lastEdgeAt !== undefined && Date.now() - lastEdgeAt < EDGE_REPORT_GRACE_MS) return
178
188
  // Completed taps/swipes become one continuous pulse, even if the device
179
189
  // omits tap_end. Older tap_start-only devices retain the same fallback.
180
- this.presses.delete(finger)
190
+ this.presses.delete(key)
181
191
  if (glyph) this.startFlash(glyph)
182
192
  this.render()
183
193
  }
184
194
 
195
+ /**
196
+ * The press this edge belongs to. Glasses relaying a ring tag its edges
197
+ * unevenly (a touch_down carries the ring's id while the firmware leaves the
198
+ * release's source unspecified, or the reverse), so an edge that misses its
199
+ * exact key joins the one outstanding press of that finger instead of
200
+ * leaving it stuck.
201
+ */
202
+ private pressKey(controllerId: string | undefined, finger: number): string {
203
+ const exact = fingerKey(controllerId, finger)
204
+ if (this.presses.has(exact)) return exact
205
+ if (controllerId !== undefined) {
206
+ const unattributed = fingerKey(undefined, finger)
207
+ return this.presses.has(unattributed) ? unattributed : exact
208
+ }
209
+ const candidates = [...this.presses.keys()].filter((key) => fingerOf(key) === finger)
210
+ return candidates.length === 1 ? candidates[0] : exact
211
+ }
212
+
213
+ /** The finger's latest press edge under any attribution (see pressKey). */
214
+ private lastEdgeFor(finger: number): number | undefined {
215
+ let latest: number | undefined
216
+ for (const [key, at] of this.lastEdgeAt) {
217
+ if (fingerOf(key) === finger && (latest === undefined || at > latest)) latest = at
218
+ }
219
+ return latest
220
+ }
221
+
185
222
  private clearFlash(): void {
186
223
  if (this.flashTimer !== null) BgTimer.clearTimeout(this.flashTimer)
187
224
  this.flashTimer = null
@@ -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
+ }