@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
@@ -0,0 +1,327 @@
1
+ /**
2
+ * LocalLlmModelManager — the on-device LLM model files: a catalog of
3
+ * ExecuTorch exports (Software Mansion's Qwen3 .pte builds on Hugging Face),
4
+ * their download into Documents/llm_models/<id>/, availability checks, and a
5
+ * status stream the Settings > AI Models screen renders. Modelled on
6
+ * STTModelManager; the download itself is RNFS so the Settings screen can
7
+ * list, cancel, and delete models with the same calls the STT rows use.
8
+ *
9
+ * Which model is "the" on-device model is the `local_llm_model` setting
10
+ * (Settings > AI Models). Downloads are on demand: the first request that
11
+ * routes on-device without a model present starts one (ensureSelectedModel),
12
+ * and the Settings screen can start one explicitly.
13
+ */
14
+ import {Platform} from "react-native"
15
+ import * as RNFS from "@dr.pogodin/react-native-fs"
16
+
17
+ import {SETTINGS, useSettingsStore} from "../stores/settings"
18
+
19
+ export type LocalLlmModelId = "qwen3-0.6b" | "qwen3-1.7b" | "qwen3-4b"
20
+
21
+ export const DEFAULT_LOCAL_LLM_MODEL: LocalLlmModelId = "qwen3-1.7b"
22
+
23
+ export interface LocalLlmModelConfig {
24
+ id: LocalLlmModelId
25
+ /** Human label for the Settings dropdown. */
26
+ label: string
27
+ /** Approximate download size in bytes (the .pte dominates). */
28
+ sizeBytes: number
29
+ /** Rough resident memory while loaded; the Settings copy warns above ~half of device RAM. */
30
+ ramBytes: number
31
+ urls: {
32
+ model: string
33
+ tokenizer: string
34
+ tokenizerConfig: string
35
+ }
36
+ }
37
+
38
+ /** The three files every model directory holds; names are fixed so paths never depend on the URL. */
39
+ export interface LocalLlmModelPaths {
40
+ model: string
41
+ tokenizer: string
42
+ tokenizerConfig: string
43
+ }
44
+
45
+ export type LocalLlmDownloadStage = "idle" | "downloading" | "complete" | "failed"
46
+
47
+ export interface LocalLlmDownloadStatus {
48
+ modelId: LocalLlmModelId
49
+ stage: LocalLlmDownloadStage
50
+ /** 0–100, meaningful while stage is "downloading". */
51
+ percent: number
52
+ error?: string
53
+ }
54
+
55
+ // XNNPACK 8da4w exports from software-mansion/react-native-executorch-qwen-3,
56
+ // 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).
58
+ const QWEN3_BASE = "https://huggingface.co/software-mansion/react-native-executorch-qwen-3/resolve/v0.10.0"
59
+ const MB = 1024 * 1024
60
+ const GB = 1024 * MB
61
+
62
+ const MODELS: Record<LocalLlmModelId, LocalLlmModelConfig> = {
63
+ "qwen3-0.6b": {
64
+ id: "qwen3-0.6b",
65
+ label: "Qwen3 0.6B",
66
+ sizeBytes: 517 * MB,
67
+ ramBytes: 0.9 * GB,
68
+ urls: {
69
+ model: `${QWEN3_BASE}/0_6b/xnnpack/qwen_3_0_6b_xnnpack_8da4w.pte`,
70
+ tokenizer: `${QWEN3_BASE}/tokenizer.json`,
71
+ tokenizerConfig: `${QWEN3_BASE}/tokenizer_config.json`,
72
+ },
73
+ },
74
+ "qwen3-1.7b": {
75
+ id: "qwen3-1.7b",
76
+ label: "Qwen3 1.7B",
77
+ sizeBytes: 1315 * MB,
78
+ ramBytes: 1.8 * GB,
79
+ urls: {
80
+ model: `${QWEN3_BASE}/1_7b/xnnpack/qwen_3_1_7b_xnnpack_8da4w.pte`,
81
+ tokenizer: `${QWEN3_BASE}/tokenizer.json`,
82
+ tokenizerConfig: `${QWEN3_BASE}/tokenizer_config.json`,
83
+ },
84
+ },
85
+ "qwen3-4b": {
86
+ id: "qwen3-4b",
87
+ label: "Qwen3 4B",
88
+ sizeBytes: 2693 * MB,
89
+ ramBytes: 3.4 * GB,
90
+ urls: {
91
+ model: `${QWEN3_BASE}/4b/xnnpack/qwen_3_4b_xnnpack_8da4w.pte`,
92
+ tokenizer: `${QWEN3_BASE}/tokenizer.json`,
93
+ tokenizerConfig: `${QWEN3_BASE}/tokenizer_config.json`,
94
+ },
95
+ },
96
+ }
97
+
98
+ export function isLocalLlmModelId(value: unknown): value is LocalLlmModelId {
99
+ return typeof value === "string" && value in MODELS
100
+ }
101
+
102
+ type StatusListener = (status: LocalLlmDownloadStatus | null) => void
103
+
104
+ class LocalLlmModelManager {
105
+ private static instance: LocalLlmModelManager
106
+
107
+ private downloadJobId?: number
108
+ private inFlight: {modelId: LocalLlmModelId; promise: Promise<void>} | null = null
109
+ private cancelled = false
110
+ private currentStatus: LocalLlmDownloadStatus | null = null
111
+ private listeners = new Set<StatusListener>()
112
+ private modelReadyListeners = new Set<(modelId: LocalLlmModelId) => void>()
113
+
114
+ private constructor() {}
115
+
116
+ static getInstance(): LocalLlmModelManager {
117
+ if (!LocalLlmModelManager.instance) {
118
+ LocalLlmModelManager.instance = new LocalLlmModelManager()
119
+ }
120
+ return LocalLlmModelManager.instance
121
+ }
122
+
123
+ models(): LocalLlmModelConfig[] {
124
+ return Object.values(MODELS)
125
+ }
126
+
127
+ model(id: LocalLlmModelId): LocalLlmModelConfig {
128
+ return MODELS[id]
129
+ }
130
+
131
+ /** The Settings > AI Models pick; garbage in the store resolves to the default. */
132
+ selectedModelId(): LocalLlmModelId {
133
+ const raw = useSettingsStore.getState().getSetting(SETTINGS.local_llm_model.key)
134
+ return isLocalLlmModelId(raw) ? raw : DEFAULT_LOCAL_LLM_MODEL
135
+ }
136
+
137
+ async select(id: LocalLlmModelId): Promise<void> {
138
+ await useSettingsStore.getState().setSetting(SETTINGS.local_llm_model.key, id)
139
+ }
140
+
141
+ getModelDirectory(): string {
142
+ return `${RNFS.DocumentDirectoryPath}/llm_models`
143
+ }
144
+
145
+ paths(id: LocalLlmModelId = this.selectedModelId()): LocalLlmModelPaths {
146
+ const dir = `${this.getModelDirectory()}/${id}`
147
+ return {
148
+ model: `${dir}/model.pte`,
149
+ tokenizer: `${dir}/tokenizer.json`,
150
+ tokenizerConfig: `${dir}/tokenizer_config.json`,
151
+ }
152
+ }
153
+
154
+ async isModelAvailable(id: LocalLlmModelId = this.selectedModelId()): Promise<boolean> {
155
+ const paths = this.paths(id)
156
+ try {
157
+ for (const path of [paths.tokenizerConfig, paths.tokenizer, paths.model]) {
158
+ if (!(await RNFS.exists(path))) return false
159
+ }
160
+ // A truncated .pte loads as garbage or crashes the runtime: insist on
161
+ // most of the published size. Downloads land in a .part file and are
162
+ // renamed only once whole, so this only catches external tampering.
163
+ const stat = await RNFS.stat(paths.model)
164
+ return Number(stat.size) >= MODELS[id].sizeBytes * 0.95
165
+ } catch (error) {
166
+ console.warn("LOCAL_LLM: availability check failed:", error)
167
+ return false
168
+ }
169
+ }
170
+
171
+ async downloadedModelIds(): Promise<LocalLlmModelId[]> {
172
+ const ids: LocalLlmModelId[] = []
173
+ for (const id of Object.keys(MODELS) as LocalLlmModelId[]) {
174
+ if (await this.isModelAvailable(id)) ids.push(id)
175
+ }
176
+ return ids
177
+ }
178
+
179
+ /** Subscribe to download status changes. Fires immediately with the current status. */
180
+ subscribe(listener: StatusListener): () => void {
181
+ this.listeners.add(listener)
182
+ listener(this.currentStatus)
183
+ return () => {
184
+ this.listeners.delete(listener)
185
+ }
186
+ }
187
+
188
+ getStatus(): LocalLlmDownloadStatus | null {
189
+ return this.currentStatus
190
+ }
191
+
192
+ /** Fired once a model's files are complete on disk. */
193
+ onModelReady(listener: (modelId: LocalLlmModelId) => void): () => void {
194
+ this.modelReadyListeners.add(listener)
195
+ return () => this.modelReadyListeners.delete(listener)
196
+ }
197
+
198
+ /**
199
+ * True when the selected model is on disk. Otherwise starts (or joins) its
200
+ * download in the background and returns false — the caller reports
201
+ * "downloading" rather than waiting out a multi-GB transfer.
202
+ */
203
+ async ensureSelectedModel(): Promise<boolean> {
204
+ const id = this.selectedModelId()
205
+ if (await this.isModelAvailable(id)) return true
206
+ void this.downloadModel(id).catch((error) => {
207
+ console.warn(`LOCAL_LLM: background download of ${id} failed:`, error)
208
+ })
209
+ return false
210
+ }
211
+
212
+ /**
213
+ * Download one model (three files, model last). Concurrent calls for the
214
+ * same model share the transfer; a call for a different model waits for
215
+ * the running one — the RNFS job slot is singular.
216
+ */
217
+ async downloadModel(id: LocalLlmModelId = this.selectedModelId()): Promise<void> {
218
+ if (this.inFlight) {
219
+ if (this.inFlight.modelId === id) return this.inFlight.promise
220
+ await this.inFlight.promise.catch(() => undefined)
221
+ }
222
+ const promise = this.downloadInner(id).finally(() => {
223
+ this.inFlight = null
224
+ })
225
+ this.inFlight = {modelId: id, promise}
226
+ return promise
227
+ }
228
+
229
+ private async downloadInner(id: LocalLlmModelId): Promise<void> {
230
+ const config = MODELS[id]
231
+ const paths = this.paths(id)
232
+ const dir = `${this.getModelDirectory()}/${id}`
233
+ this.cancelled = false
234
+ this.setStatus({modelId: id, stage: "downloading", percent: 0})
235
+
236
+ try {
237
+ await RNFS.mkdir(dir, {NSURLIsExcludedFromBackupKey: true})
238
+ // Tokenizer files first (small), the weights last: a model directory
239
+ // is only "available" once model.pte exists, so an interrupted run
240
+ // never leaves a complete-looking directory behind.
241
+ await this.fetchFile(config.urls.tokenizerConfig, paths.tokenizerConfig)
242
+ await this.fetchFile(config.urls.tokenizer, paths.tokenizer)
243
+ await this.fetchFile(config.urls.model, paths.model, (percent) =>
244
+ this.setStatus({modelId: id, stage: "downloading", percent}),
245
+ )
246
+ this.setStatus({modelId: id, stage: "complete", percent: 100})
247
+ for (const listener of this.modelReadyListeners) {
248
+ try {
249
+ listener(id)
250
+ } catch (error) {
251
+ console.warn("LOCAL_LLM: model-ready listener failed:", error)
252
+ }
253
+ }
254
+ } catch (error) {
255
+ const message = this.cancelled ? "download cancelled" : error instanceof Error ? error.message : String(error)
256
+ this.setStatus({modelId: id, stage: this.cancelled ? "idle" : "failed", percent: 0, error: message})
257
+ try {
258
+ if (await RNFS.exists(`${paths.model}.part`)) await RNFS.unlink(`${paths.model}.part`)
259
+ } catch (cleanupError) {
260
+ console.warn("LOCAL_LLM: partial cleanup failed:", cleanupError)
261
+ }
262
+ throw error
263
+ } finally {
264
+ this.downloadJobId = undefined
265
+ }
266
+ }
267
+
268
+ private async fetchFile(url: string, dest: string, onPercent?: (percent: number) => void): Promise<void> {
269
+ if (this.cancelled) throw new Error("download cancelled")
270
+ const partial = `${dest}.part`
271
+ const download = RNFS.downloadFile({
272
+ fromUrl: url,
273
+ toFile: partial,
274
+ // iOS keeps a background URLSession transfer going while the app is
275
+ // suspended; the files here are large enough to need it.
276
+ background: Platform.OS === "ios",
277
+ discretionary: false,
278
+ progressDivider: 1,
279
+ connectionTimeout: 30_000,
280
+ readTimeout: 60_000,
281
+ progress: (res) => {
282
+ if (!onPercent || res.contentLength <= 0) return
283
+ onPercent(Math.min(99, Math.floor((res.bytesWritten / res.contentLength) * 100)))
284
+ },
285
+ })
286
+ this.downloadJobId = download.jobId
287
+ const result = await download.promise
288
+ if (result.statusCode !== 200) {
289
+ throw new Error(`download failed with status ${result.statusCode}`)
290
+ }
291
+ if (await RNFS.exists(dest)) await RNFS.unlink(dest)
292
+ await RNFS.moveFile(partial, dest)
293
+ }
294
+
295
+ async cancelDownload(): Promise<void> {
296
+ if (this.downloadJobId === undefined) return
297
+ this.cancelled = true
298
+ await RNFS.stopDownload(this.downloadJobId)
299
+ this.downloadJobId = undefined
300
+ }
301
+
302
+ async deleteModel(id: LocalLlmModelId = this.selectedModelId()): Promise<void> {
303
+ const dir = `${this.getModelDirectory()}/${id}`
304
+ if (await RNFS.exists(dir)) await RNFS.unlink(dir)
305
+ if (this.currentStatus?.modelId === id) this.setStatus(null)
306
+ }
307
+
308
+ private setStatus(status: LocalLlmDownloadStatus | null): void {
309
+ this.currentStatus = status
310
+ for (const listener of this.listeners) {
311
+ try {
312
+ listener(status)
313
+ } catch (error) {
314
+ console.warn("LOCAL_LLM: status listener threw:", error)
315
+ }
316
+ }
317
+ }
318
+
319
+ static formatBytes(bytes: number): string {
320
+ if (bytes >= GB) return `${(bytes / GB).toFixed(1)} GB`
321
+ return `${Math.round(bytes / MB)} MB`
322
+ }
323
+ }
324
+
325
+ const instance = LocalLlmModelManager.getInstance()
326
+ export {LocalLlmModelManager}
327
+ export default instance
@@ -0,0 +1,246 @@
1
+ /**
2
+ * LocalLlmPrompt — the pure half of the on-device LLM path: turning a neutral
3
+ * session.ai request into chat-template messages, fitting them into the
4
+ * model's context, and reading the model's text back into the neutral result
5
+ * (tool calls, JSON mode, stop reason). No native imports, so it is
6
+ * bun-testable; LocalLlmProvider owns the runner.
7
+ *
8
+ * Written against Qwen3's chat template: tools are advertised by the
9
+ * template itself, calls come back as `<tool_call>{json}</tool_call>`, and
10
+ * thinking is a `<think>…</think>` block we never ask for but still strip.
11
+ */
12
+ import {jsonrepair} from "jsonrepair"
13
+
14
+ import type {LlmMessage, LlmStopReason, LlmTool, LlmToolCall} from "@glassly/cloud-protocol/llm"
15
+
16
+ export interface TemplateToolCall {
17
+ id: string
18
+ type: "function"
19
+ function: {name: string; arguments: Record<string, unknown>}
20
+ }
21
+
22
+ export type TemplateMessage =
23
+ | {role: "system" | "user"; content: string}
24
+ | {role: "assistant"; content: string; tool_calls?: TemplateToolCall[]}
25
+ | {role: "tool"; content: string; name?: string; tool_call_id?: string}
26
+
27
+ export interface TemplateTool {
28
+ type: "function"
29
+ function: {name: string; description: string; parameters: Record<string, unknown>}
30
+ }
31
+
32
+ /** Appended to the system prompt under `jsonMode`; small models need it said twice. */
33
+ export const JSON_MODE_INSTRUCTION =
34
+ "Respond with exactly one JSON object and nothing else: no prose before or after it, no markdown fences."
35
+
36
+ /**
37
+ * Neutral messages → template messages. A user turn carrying toolResults
38
+ * becomes one `tool` message per result (the template groups consecutive
39
+ * tool messages into a single user block), followed by the user text if any.
40
+ */
41
+ export function buildTemplateMessages(request: {
42
+ messages: LlmMessage[]
43
+ system?: string
44
+ jsonMode?: boolean
45
+ }): TemplateMessage[] {
46
+ const out: TemplateMessage[] = []
47
+ const systemParts = [request.system?.trim(), request.jsonMode ? JSON_MODE_INSTRUCTION : undefined].filter(
48
+ (part): part is string => Boolean(part),
49
+ )
50
+ if (systemParts.length) out.push({role: "system", content: systemParts.join("\n\n")})
51
+
52
+ for (const message of request.messages) {
53
+ if (message.role === "assistant") {
54
+ const toolCalls = message.toolCalls?.map<TemplateToolCall>((call) => ({
55
+ id: call.id,
56
+ type: "function",
57
+ function: {name: call.name, arguments: call.input},
58
+ }))
59
+ out.push(
60
+ toolCalls?.length
61
+ ? {role: "assistant", content: message.content, tool_calls: toolCalls}
62
+ : {role: "assistant", content: message.content},
63
+ )
64
+ continue
65
+ }
66
+ for (const result of message.toolResults ?? []) {
67
+ out.push({
68
+ role: "tool",
69
+ tool_call_id: result.toolCallId,
70
+ content: result.isError ? `Error: ${result.content}` : result.content,
71
+ })
72
+ }
73
+ if (message.content.trim().length > 0 || !message.toolResults?.length) {
74
+ out.push({role: "user", content: message.content})
75
+ }
76
+ }
77
+ return out
78
+ }
79
+
80
+ export function toTemplateTools(tools?: LlmTool[]): TemplateTool[] | undefined {
81
+ if (!tools?.length) return undefined
82
+ return tools.map((tool) => ({
83
+ type: "function",
84
+ function: {name: tool.name, description: tool.description, parameters: tool.inputSchema},
85
+ }))
86
+ }
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
103
+ }
104
+
105
+ const MIN_TOOL_CONTENT_CHARS = 1_200
106
+
107
+ /**
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.
114
+ */
115
+ export function fitToBudget(messages: TemplateMessage[], budgetTokens: number, toolTokens = 0): TemplateMessage[] {
116
+ const total = (list: TemplateMessage[]) => list.reduce((sum, m) => sum + estimateMessageTokens(m), toolTokens)
117
+ const system = messages[0]?.role === "system" ? [messages[0]] : []
118
+ const turns: TemplateMessage[][] = []
119
+ for (const message of messages.slice(system.length)) {
120
+ if (message.role === "user" || turns.length === 0) turns.push([message])
121
+ else turns[turns.length - 1]!.push(message)
122
+ }
123
+
124
+ while (turns.length > 1 && total([...system, ...turns.flat()]) > budgetTokens) {
125
+ turns.shift()
126
+ }
127
+
128
+ 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
+ )
135
+ }
136
+ return fitted
137
+ }
138
+
139
+ /** Remove a leading `<think>…</think>` block (or an unterminated one). */
140
+ export function stripThinking(text: string): string {
141
+ const trimmed = text.replace(/^\s*<think>[\s\S]*?<\/think>\s*/u, "")
142
+ if (trimmed !== text) return trimmed
143
+ return text.replace(/^\s*<think>[\s\S]*$/u, "").trimStart()
144
+ }
145
+
146
+ const TOOL_CALL_RE = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/gu
147
+
148
+ /** Pull `<tool_call>` blocks out of the reply. Unparseable blocks are dropped, not surfaced. */
149
+ export function parseToolCalls(
150
+ text: string,
151
+ makeId: (index: number) => string,
152
+ ): {toolCalls: LlmToolCall[]; text: string} {
153
+ const toolCalls: LlmToolCall[] = []
154
+ let index = 0
155
+ const remaining = text
156
+ .replace(TOOL_CALL_RE, (_match, body: string) => {
157
+ const parsed = parseJsonLoose(body)
158
+ if (parsed && typeof parsed === "object" && typeof (parsed as {name?: unknown}).name === "string") {
159
+ const {name, arguments: args} = parsed as {name: string; arguments?: unknown}
160
+ toolCalls.push({
161
+ id: makeId(index++),
162
+ name,
163
+ input: args && typeof args === "object" && !Array.isArray(args) ? (args as Record<string, unknown>) : {},
164
+ })
165
+ }
166
+ return ""
167
+ })
168
+ .trim()
169
+ return {toolCalls, text: remaining}
170
+ }
171
+
172
+ function parseJsonLoose(body: string): unknown {
173
+ try {
174
+ return JSON.parse(body)
175
+ } catch {
176
+ try {
177
+ return JSON.parse(jsonrepair(body))
178
+ } catch {
179
+ return undefined
180
+ }
181
+ }
182
+ }
183
+
184
+ /**
185
+ * The first JSON object in the reply, repaired if the model left it slightly
186
+ * broken (trailing commas, single quotes, a dropped brace). Null when there
187
+ * is nothing object-shaped: the caller keeps the raw text and the miniapp's
188
+ * own parser decides.
189
+ */
190
+ export function extractJsonObject(text: string): string | null {
191
+ const unfenced = text.replace(/```(?:json)?/giu, "").trim()
192
+ const start = unfenced.indexOf("{")
193
+ if (start < 0) return null
194
+ const end = unfenced.lastIndexOf("}")
195
+ const candidate = end > start ? unfenced.slice(start, end + 1) : unfenced.slice(start)
196
+ try {
197
+ JSON.parse(candidate)
198
+ return candidate
199
+ } catch {
200
+ try {
201
+ const repaired = jsonrepair(candidate)
202
+ JSON.parse(repaired)
203
+ return repaired
204
+ } catch {
205
+ return null
206
+ }
207
+ }
208
+ }
209
+
210
+ export interface LocalGeneration {
211
+ text: string
212
+ promptTokens: number
213
+ generatedTokens: number
214
+ maxNewTokens: number
215
+ }
216
+
217
+ export interface LocalCompletion {
218
+ text: string
219
+ stopReason: LlmStopReason
220
+ toolCalls?: LlmToolCall[]
221
+ usage: {inputTokens: number; outputTokens: number}
222
+ }
223
+
224
+ /** Raw generation → neutral completion. */
225
+ export function finalizeLocalResult(
226
+ generation: LocalGeneration,
227
+ opts: {jsonMode: boolean; toolsOffered: boolean; makeToolCallId: (index: number) => string},
228
+ ): LocalCompletion {
229
+ const usage = {inputTokens: generation.promptTokens, outputTokens: generation.generatedTokens}
230
+ let text = stripThinking(generation.text)
231
+
232
+ if (opts.toolsOffered) {
233
+ const parsed = parseToolCalls(text, opts.makeToolCallId)
234
+ if (parsed.toolCalls.length) {
235
+ return {text: parsed.text, stopReason: "tool_use", toolCalls: parsed.toolCalls, usage}
236
+ }
237
+ text = parsed.text
238
+ }
239
+
240
+ if (opts.jsonMode) {
241
+ text = extractJsonObject(text) ?? text
242
+ }
243
+
244
+ const stopReason: LlmStopReason = generation.generatedTokens >= generation.maxNewTokens ? "max_tokens" : "end_turn"
245
+ return {text: text.trim(), stopReason, usage}
246
+ }