@glassly/engine 0.3.8-dev.1009 → 0.3.8-dev.1014

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glassly/engine",
3
- "version": "0.3.8-dev.1009",
3
+ "version": "0.3.8-dev.1014",
4
4
  "description": "Glassly Engine — miniapp registry, WebView bridge, and miniapp store",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -97,17 +97,17 @@
97
97
  "registry": "https://registry.npmjs.org/"
98
98
  },
99
99
  "dependencies": {
100
- "@glassly/bluetooth-sdk": "0.3.8-dev.1009",
101
- "@glassly/cloud-client": "0.3.8-dev.1009",
102
- "@glassly/cloud-protocol": "0.3.8-dev.1009",
103
- "@glassly/crust": "0.3.8-dev.1009",
104
- "@glassly/miniapp": "0.3.8-dev.1009",
100
+ "@glassly/bluetooth-sdk": "0.3.8-dev.1014",
101
+ "@glassly/cloud-client": "0.3.8-dev.1014",
102
+ "@glassly/cloud-protocol": "0.3.8-dev.1014",
103
+ "@glassly/crust": "0.3.8-dev.1014",
104
+ "@glassly/miniapp": "0.3.8-dev.1014",
105
105
  "buffer": "^6.0.3",
106
106
  "events": "^3.3.0",
107
107
  "react-native-marked": "8.3.2",
108
108
  "semver": "^7.7.4",
109
109
  "typesafe-ts": "^1.7.1",
110
- "@glassly/llm-providers": "0.3.8-dev.1009",
110
+ "@glassly/llm-providers": "0.3.8-dev.1014",
111
111
  "@huggingface/jinja": "^0.5.9",
112
112
  "jsonrepair": "^3.12.0"
113
113
  },
package/src/facades/ai.ts CHANGED
@@ -21,6 +21,12 @@ export const ai = {
21
21
  /** The Settings > Offline Models pick. */
22
22
  selectedModelId: (): LocalLlmModelId => localLlmModelManager.selectedModelId(),
23
23
  select: (id: LocalLlmModelId): Promise<void> => localLlmModelManager.select(id),
24
+ /** Whether the model fits this phone's memory at all. */
25
+ fitsDevice: (id: LocalLlmModelId): boolean => localLlmModelManager.fitsDevice(id),
26
+ /** The OS killed the app while this model loaded; picking it again retries it. */
27
+ isBlocked: (id: LocalLlmModelId): boolean => localLlmModelManager.isBlocked(id),
28
+ /** Why the model will not load on this phone, or null when it can. */
29
+ unusableReason: (id: LocalLlmModelId): string | null => localLlmModelManager.unusableReason(id),
24
30
  isDownloaded: (id?: LocalLlmModelId): Promise<boolean> => localLlmModelManager.isModelAvailable(id),
25
31
  downloadedModelIds: (): Promise<LocalLlmModelId[]> => localLlmModelManager.downloadedModelIds(),
26
32
  download: (id?: LocalLlmModelId): Promise<void> => localLlmModelManager.downloadModel(id),
@@ -12,9 +12,9 @@ export interface EngineReleaseMetadata {
12
12
  export const ENGINE_RELEASE_METADATA: Readonly<EngineReleaseMetadata> = Object.freeze({
13
13
  "schemaVersion": 1,
14
14
  "familyBaseVersion": "0.3.8",
15
- "releaseIdentity": "0.3.8-dev.1009",
16
- "releaseSetId": "glassly-0.3.8-dev.1009",
17
- "sourceCommit": "4983873da52b50b866925cee3b550fa9dd4b7c28",
15
+ "releaseIdentity": "0.3.8-dev.1014",
16
+ "releaseSetId": "glassly-0.3.8-dev.1014",
17
+ "sourceCommit": "91cc37d7b4c99dc750e22528c9487e33fb27c53c",
18
18
  "otaManifestUrl": null,
19
19
  "otaManifestSha256": null
20
20
  })
@@ -15,6 +15,7 @@ import {Platform} from "react-native"
15
15
  import * as RNFS from "@dr.pogodin/react-native-fs"
16
16
 
17
17
  import {SETTINGS, useSettingsStore} from "../stores/settings"
18
+ import {storage} from "../utils/storage"
18
19
 
19
20
  export type LocalLlmModelId = "qwen3-0.6b" | "qwen3-1.7b" | "qwen3-4b"
20
21
 
@@ -28,7 +29,7 @@ export interface LocalLlmModelConfig {
28
29
  sizeBytes: number
29
30
  /** Exact size of the published .pte; the availability check compares against it. */
30
31
  modelBytes: number
31
- /** Rough resident memory while loaded; the Settings copy warns above ~half of device RAM. */
32
+ /** Rough resident memory while loaded; `fitsDevice` holds it against the phone's RAM. */
32
33
  ramBytes: number
33
34
  urls: {
34
35
  model: string
@@ -106,6 +107,36 @@ export function isLocalLlmModelId(value: unknown): value is LocalLlmModelId {
106
107
  return typeof value === "string" && value in MODELS
107
108
  }
108
109
 
110
+ // Without the increased-memory-limit entitlement iOS kills a foreground app
111
+ // at roughly 60% of physical RAM (Android's low-memory killer is no kinder),
112
+ // and the host holds about 1 GB besides the model: JS runtimes, WebViews,
113
+ // the speech model. A 4 GB phone fits the 0.6B model and nothing larger.
114
+ const APP_MEMORY_SHARE = 0.6
115
+ const HOST_BASELINE_BYTES = 1 * GB
116
+
117
+ export function modelFitsMemory(model: LocalLlmModelConfig, deviceMemoryBytes: number | null): boolean {
118
+ if (!deviceMemoryBytes) return true
119
+ return model.ramBytes + HOST_BASELINE_BYTES <= deviceMemoryBytes * APP_MEMORY_SHARE
120
+ }
121
+
122
+ let deviceMemory: number | null | undefined
123
+ function deviceMemoryBytes(): number | null {
124
+ if (deviceMemory === undefined) {
125
+ try {
126
+ deviceMemory = (require("expo-device") as {totalMemory?: number | null}).totalMemory ?? null
127
+ } catch {
128
+ deviceMemory = null
129
+ }
130
+ }
131
+ return deviceMemory
132
+ }
133
+
134
+ // Written before a model's first load and generation, cleared after either
135
+ // ends. A marker still there at the next launch means the OS killed the app
136
+ // mid-load (out of memory), so that model stays off until picked again.
137
+ const COLD_LOAD_KEY = "local_llm_cold_load"
138
+ const BLOCKED_MODELS_KEY = "local_llm_blocked_models"
139
+
109
140
  type StatusListener = (status: LocalLlmDownloadStatus | null) => void
110
141
 
111
142
  class LocalLlmModelManager {
@@ -114,6 +145,7 @@ class LocalLlmModelManager {
114
145
  private downloadJobId?: number
115
146
  private inFlight: {modelId: LocalLlmModelId; promise: Promise<void>} | null = null
116
147
  private cancelled = false
148
+ private coldLoadRecovered = false
117
149
  private currentStatus: LocalLlmDownloadStatus | null = null
118
150
  private listeners = new Set<StatusListener>()
119
151
  private modelReadyListeners = new Set<(modelId: LocalLlmModelId) => void>()
@@ -135,16 +167,89 @@ class LocalLlmModelManager {
135
167
  return MODELS[id]
136
168
  }
137
169
 
138
- /** The Settings > AI Models pick; garbage in the store resolves to the default. */
170
+ /**
171
+ * The model on-device requests use: the Settings > Offline Models pick
172
+ * (garbage resolves to the default) while it is usable, else the largest
173
+ * model that is. With nothing usable it stays the pick, and loading it
174
+ * fails with `unusableReason`.
175
+ */
139
176
  selectedModelId(): LocalLlmModelId {
140
177
  const raw = useSettingsStore.getState().getSetting(SETTINGS.local_llm_model.key)
141
- return isLocalLlmModelId(raw) ? raw : DEFAULT_LOCAL_LLM_MODEL
178
+ const picked = isLocalLlmModelId(raw) ? raw : DEFAULT_LOCAL_LLM_MODEL
179
+ if (this.isUsable(picked)) return picked
180
+ const fallback = this.models()
181
+ .filter((m) => this.isUsable(m.id))
182
+ .sort((a, b) => b.ramBytes - a.ramBytes)[0]
183
+ return fallback?.id ?? picked
142
184
  }
143
185
 
186
+ /** Picking a model in Settings is also the way to retry one that crashed the app. */
144
187
  async select(id: LocalLlmModelId): Promise<void> {
188
+ this.unblock(id)
145
189
  await useSettingsStore.getState().setSetting(SETTINGS.local_llm_model.key, id)
146
190
  }
147
191
 
192
+ fitsDevice(id: LocalLlmModelId): boolean {
193
+ return modelFitsMemory(MODELS[id], deviceMemoryBytes())
194
+ }
195
+
196
+ /** The OS killed the app while this model loaded; it stays off until picked again. */
197
+ isBlocked(id: LocalLlmModelId): boolean {
198
+ return this.blockedModelIds().includes(id)
199
+ }
200
+
201
+ isUsable(id: LocalLlmModelId): boolean {
202
+ return this.fitsDevice(id) && !this.isBlocked(id)
203
+ }
204
+
205
+ /** Why `id` will not load on this phone, or null when it can. */
206
+ unusableReason(id: LocalLlmModelId): string | null {
207
+ const label = MODELS[id].label
208
+ if (!this.fitsDevice(id)) {
209
+ return `${label} needs more memory than this phone has. Pick a smaller model in Settings > Offline Models.`
210
+ }
211
+ if (this.isBlocked(id)) {
212
+ return `${label} closed the app the last time it loaded. Pick it again in Settings > Offline Models to retry, or pick a smaller model.`
213
+ }
214
+ return null
215
+ }
216
+
217
+ /** Mark the start of a model's first load and generation in this process. */
218
+ beginColdLoad(id: LocalLlmModelId): void {
219
+ this.recoverColdLoad()
220
+ storage.save(COLD_LOAD_KEY, id)
221
+ }
222
+
223
+ endColdLoad(): void {
224
+ storage.removeMultiple([COLD_LOAD_KEY])
225
+ }
226
+
227
+ private blockedModelIds(): LocalLlmModelId[] {
228
+ this.recoverColdLoad()
229
+ const stored = storage.load<unknown>(BLOCKED_MODELS_KEY)
230
+ const ids = stored.is_ok() ? stored.value : []
231
+ return Array.isArray(ids) ? ids.filter(isLocalLlmModelId) : []
232
+ }
233
+
234
+ private unblock(id: LocalLlmModelId): void {
235
+ const remaining = this.blockedModelIds().filter((blocked) => blocked !== id)
236
+ storage.save(BLOCKED_MODELS_KEY, remaining)
237
+ }
238
+
239
+ // A cold-load marker left by an earlier process: that load never finished.
240
+ private recoverColdLoad(): void {
241
+ if (this.coldLoadRecovered) return
242
+ this.coldLoadRecovered = true
243
+ const marker = storage.load<unknown>(COLD_LOAD_KEY)
244
+ if (!marker.is_ok() || !isLocalLlmModelId(marker.value)) return
245
+ const id = marker.value
246
+ console.warn(`LOCAL_LLM: the app was killed while ${id} loaded; keeping it off until it is picked again`)
247
+ const stored = storage.load<unknown>(BLOCKED_MODELS_KEY)
248
+ const blocked = stored.is_ok() && Array.isArray(stored.value) ? stored.value.filter(isLocalLlmModelId) : []
249
+ if (!blocked.includes(id)) storage.save(BLOCKED_MODELS_KEY, [...blocked, id])
250
+ storage.removeMultiple([COLD_LOAD_KEY])
251
+ }
252
+
148
253
  getModelDirectory(): string {
149
254
  return `${RNFS.DocumentDirectoryPath}/llm_models`
150
255
  }
@@ -210,6 +315,7 @@ class LocalLlmModelManager {
210
315
  async ensureSelectedModel(): Promise<boolean> {
211
316
  const id = this.selectedModelId()
212
317
  if (await this.isModelAvailable(id)) return true
318
+ if (!this.fitsDevice(id)) return false
213
319
  void this.downloadModel(id).catch((error) => {
214
320
  console.warn(`LOCAL_LLM: background download of ${id} failed:`, error)
215
321
  })
@@ -222,6 +328,7 @@ class LocalLlmModelManager {
222
328
  * the running one — the RNFS job slot is singular.
223
329
  */
224
330
  async downloadModel(id: LocalLlmModelId = this.selectedModelId()): Promise<void> {
331
+ if (!this.fitsDevice(id)) throw new Error(this.unusableReason(id) ?? `${MODELS[id].label} does not fit this phone.`)
225
332
  if (this.inFlight) {
226
333
  if (this.inFlight.modelId === id) return this.inFlight.promise
227
334
  await this.inFlight.promise.catch(() => undefined)
@@ -187,6 +187,11 @@ class LocalLlmProvider {
187
187
 
188
188
  private async completeInner(request: LlmCompleteRequest): Promise<LocalLlmCompleteResult> {
189
189
  this.clearIdleTimer()
190
+ // Loading and the first generation are where memory peaks; if the OS kills
191
+ // the app in between, the marker keeps the next launch from looping on it.
192
+ const selectedId = localLlmModelManager.selectedModelId()
193
+ const coldModelId = this.loaded?.modelId === selectedId ? null : selectedId
194
+ if (coldModelId) localLlmModelManager.beginColdLoad(coldModelId)
190
195
  try {
191
196
  const model = await this.ensureLoaded()
192
197
  const jsonMode = request.jsonMode === true
@@ -248,6 +253,7 @@ class LocalLlmProvider {
248
253
  )
249
254
  return {...completion, modelId: model.modelId}
250
255
  } finally {
256
+ if (coldModelId) localLlmModelManager.endColdLoad()
251
257
  this.armIdleTimer()
252
258
  }
253
259
  }
@@ -272,6 +278,8 @@ class LocalLlmProvider {
272
278
  // Runs inside the request queue, so nothing is generating; unload() would
273
279
  // wait on that same queue.
274
280
  this.releaseRunner()
281
+ const unusable = localLlmModelManager.unusableReason(modelId)
282
+ if (unusable) throw new Error(unusable)
275
283
  if (!(await localLlmModelManager.isModelAvailable(modelId))) {
276
284
  throw new Error(`on-device model ${modelId} is not downloaded`)
277
285
  }
@@ -4322,6 +4322,18 @@ class LocalMiniappRuntime {
4322
4322
  primaryError: {code: MiniappErrorCode; message: string} | null,
4323
4323
  ): Promise<void> {
4324
4324
  const modelId = localLlmModelManager.selectedModelId()
4325
+ const unusable = localLlmModelManager.unusableReason(modelId)
4326
+ if (unusable) {
4327
+ console.warn(`${LOG_TAG}: ai.complete on-device for ${packageName} (${reason}): ${unusable}`)
4328
+ this.sendResult(
4329
+ packageName,
4330
+ requestId,
4331
+ false,
4332
+ undefined,
4333
+ primaryError ?? {code: MiniappErrorCode.AI_LOCAL_UNAVAILABLE, message: unusable},
4334
+ )
4335
+ return
4336
+ }
4325
4337
  if (!(await localLlmModelManager.ensureSelectedModel())) {
4326
4338
  const status = localLlmModelManager.getStatus()
4327
4339
  const state =
@@ -4338,7 +4350,7 @@ class LocalMiniappRuntime {
4338
4350
  undefined,
4339
4351
  primaryError ?? {
4340
4352
  code: MiniappErrorCode.AI_LOCAL_UNAVAILABLE,
4341
- message: `The on-device model (${localLlmModelManager.model(modelId).label}) ${state}. Manage it in Settings > AI Models.`,
4353
+ message: `The on-device model (${localLlmModelManager.model(modelId).label}) ${state}. Manage it in Settings > Offline Models.`,
4342
4354
  },
4343
4355
  )
4344
4356
  return