@elizaos/plugin-capacitor-bridge 2.0.3-beta.2 → 2.0.3-beta.3

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.
@@ -0,0 +1,1234 @@
1
+ // src/mobile-device-bridge-bootstrap.ts
2
+ import { randomUUID } from "crypto";
3
+ import {
4
+ createWriteStream,
5
+ existsSync,
6
+ mkdirSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ renameSync,
10
+ statSync,
11
+ unlinkSync
12
+ } from "fs";
13
+ import net from "net";
14
+ import path from "path";
15
+ import { Readable } from "stream";
16
+ import { pipeline } from "stream/promises";
17
+ import {
18
+ logger,
19
+ ModelType,
20
+ resolveStateDir
21
+ } from "@elizaos/core";
22
+ var DEVICE_BRIDGE_PATH = "/api/local-inference/device-bridge";
23
+ var PROVIDER = "capacitor-llama";
24
+ var LOCAL_INFERENCE_PRIORITY = 0;
25
+ var DEFAULT_NATIVE_REQUEST_TIMEOUT_MS = 6e5;
26
+ var DEFAULT_CALL_TIMEOUT_MS = DEFAULT_NATIVE_REQUEST_TIMEOUT_MS;
27
+ var DEFAULT_LOAD_TIMEOUT_MS = DEFAULT_NATIVE_REQUEST_TIMEOUT_MS;
28
+ var SERVICE_ENABLED = process.env.ELIZA_DEVICE_BRIDGE_ENABLED?.trim() === "1";
29
+ var registeredRuntimes = /* @__PURE__ */ new WeakSet();
30
+ var KNOWN_EMBEDDING_DIMENSIONS = {
31
+ "eliza-1-embedding": 1024,
32
+ // 2B reuses the text backbone for embeddings (--pooling last), so its dim is the
33
+ // model's embedding_length = 2048 (device-verified: EMBED -> dim 2048), NOT 1536.
34
+ "eliza-1-2b": 2048,
35
+ "eliza-1-4b": 2560
36
+ };
37
+ var GEMMA_MTP_DRAFT = { draftMin: 1, draftMax: 1 };
38
+ var ELIZA_1_LOAD_METADATA = {
39
+ // 2B is the smallest/entry tier (the small-phone default). Every shipped
40
+ // tier can use a Gemma 4 assistant drafter when that companion GGUF is
41
+ // staged next to the bundle. The bridge never falls back to same-file MTP
42
+ // because that was the retired Qwen contract.
43
+ "eliza-1-2b": {
44
+ contextSize: 131072,
45
+ mtp: { drafterFile: "mtp/drafter-2b.gguf", ...GEMMA_MTP_DRAFT }
46
+ },
47
+ "eliza-1-4b": {
48
+ contextSize: 65536,
49
+ mtp: { drafterFile: "mtp/drafter-4b.gguf", ...GEMMA_MTP_DRAFT }
50
+ },
51
+ "eliza-1-9b": {
52
+ contextSize: 65536,
53
+ mtp: { drafterFile: "mtp/drafter-9b.gguf", ...GEMMA_MTP_DRAFT }
54
+ },
55
+ "eliza-1-27b": {
56
+ contextSize: 131072,
57
+ mtp: { drafterFile: "mtp/drafter-27b.gguf", ...GEMMA_MTP_DRAFT }
58
+ },
59
+ "eliza-1-27b-256k": {
60
+ contextSize: 262144,
61
+ mtp: { drafterFile: "mtp/drafter-27b-256k.gguf", ...GEMMA_MTP_DRAFT }
62
+ }
63
+ };
64
+ function bionicMtpOverride() {
65
+ const raw = process.env.ELIZA_BIONIC_MTP?.trim().toLowerCase();
66
+ if (!raw) return void 0;
67
+ if (raw === "0" || raw === "false" || raw === "no" || raw === "off") {
68
+ return false;
69
+ }
70
+ if (raw === "1" || raw === "true" || raw === "yes" || raw === "on") {
71
+ return true;
72
+ }
73
+ return void 0;
74
+ }
75
+ function isWsModule(value) {
76
+ return typeof value === "object" && value !== null && typeof value.WebSocketServer === "function" && typeof value.WebSocket === "function";
77
+ }
78
+ var MobileDeviceBridge = class {
79
+ wss = null;
80
+ devices = /* @__PURE__ */ new Map();
81
+ pendingLoads = /* @__PURE__ */ new Map();
82
+ pendingUnloads = /* @__PURE__ */ new Map();
83
+ pendingGenerates = /* @__PURE__ */ new Map();
84
+ pendingEmbeds = /* @__PURE__ */ new Map();
85
+ pendingFormatChats = /* @__PURE__ */ new Map();
86
+ expectedPairingToken = process.env.ELIZA_DEVICE_PAIRING_TOKEN?.trim() || process.env.ELIZA_DEVICE_BRIDGE_TOKEN?.trim() || null;
87
+ status() {
88
+ const devices = [...this.devices.values()].map((device) => ({
89
+ deviceId: device.deviceId,
90
+ capabilities: device.capabilities,
91
+ loadedPath: device.loadedPath,
92
+ connectedSince: new Date(device.connectedAt).toISOString()
93
+ }));
94
+ return {
95
+ enabled: SERVICE_ENABLED && Boolean(this.expectedPairingToken),
96
+ connected: devices.length > 0,
97
+ devices,
98
+ primaryDeviceId: devices[0]?.deviceId ?? null,
99
+ pendingRequests: this.pendingLoads.size + this.pendingUnloads.size + this.pendingGenerates.size + this.pendingEmbeds.size,
100
+ modelPath: resolveLocalModelPath("TEXT_LARGE")
101
+ };
102
+ }
103
+ async attachToHttpServer(server) {
104
+ if (!SERVICE_ENABLED || this.wss) return;
105
+ if (!this.expectedPairingToken) {
106
+ logger.warn(
107
+ "[mobile-device-bridge] Disabled: ELIZA_DEVICE_PAIRING_TOKEN is required when ELIZA_DEVICE_BRIDGE_ENABLED=1"
108
+ );
109
+ return;
110
+ }
111
+ const wsModule = await import("ws");
112
+ if (!isWsModule(wsModule)) {
113
+ throw new Error("ws module did not expose WebSocketServer/WebSocket");
114
+ }
115
+ const ws = wsModule;
116
+ const wss = new ws.WebSocketServer({
117
+ noServer: true,
118
+ maxPayload: 1024 * 1024
119
+ });
120
+ this.wss = wss;
121
+ wss.on("error", (err) => {
122
+ logger.warn("[mobile-device-bridge] WSS error:", err.message);
123
+ });
124
+ server.on("upgrade", (request, socket, head) => {
125
+ const url = new URL(request.url ?? "/", "http://localhost");
126
+ if (url.pathname !== DEVICE_BRIDGE_PATH) return;
127
+ wss.handleUpgrade(request, socket, head, (client) => {
128
+ this.handleConnection(client, ws.WebSocket, url);
129
+ });
130
+ });
131
+ logger.info(
132
+ `[mobile-device-bridge] Listening for Capacitor device bridge at ${DEVICE_BRIDGE_PATH}`
133
+ );
134
+ }
135
+ handleConnection(socket, WsCtor, url) {
136
+ const queryToken = url.searchParams.get("token")?.trim();
137
+ if (!this.expectedPairingToken || queryToken !== this.expectedPairingToken) {
138
+ logger.warn(
139
+ "[mobile-device-bridge] Rejecting connection: bad query token"
140
+ );
141
+ socket.close(4001, "unauthorized");
142
+ return;
143
+ }
144
+ let registeredDeviceId = null;
145
+ socket.on("message", (raw) => {
146
+ let msg;
147
+ try {
148
+ const text = typeof raw === "string" ? raw : raw.toString("utf8");
149
+ msg = JSON.parse(text);
150
+ } catch {
151
+ logger.warn("[mobile-device-bridge] Ignoring non-JSON frame");
152
+ return;
153
+ }
154
+ if (!registeredDeviceId) {
155
+ if (msg.type !== "register") {
156
+ socket.close(4002, "must-register-first");
157
+ return;
158
+ }
159
+ if (msg.payload.capabilities.platform === "ios") {
160
+ logger.warn(
161
+ "[mobile-device-bridge] Rejecting iOS registration: use native IPC"
162
+ );
163
+ socket.close(4003, "ios-ipc-required");
164
+ return;
165
+ }
166
+ if (!this.expectedPairingToken || msg.payload.pairingToken !== this.expectedPairingToken) {
167
+ logger.warn(
168
+ "[mobile-device-bridge] Rejecting register: bad pairing token"
169
+ );
170
+ socket.close(4001, "unauthorized");
171
+ return;
172
+ }
173
+ registeredDeviceId = msg.payload.deviceId;
174
+ this.devices.set(registeredDeviceId, {
175
+ deviceId: registeredDeviceId,
176
+ socket,
177
+ capabilities: msg.payload.capabilities,
178
+ loadedPath: msg.payload.loadedPath,
179
+ connectedAt: Date.now()
180
+ });
181
+ logger.info(
182
+ `[mobile-device-bridge] Device connected: ${registeredDeviceId} (${msg.payload.capabilities.platform})`
183
+ );
184
+ return;
185
+ }
186
+ this.handleDeviceMessage(msg);
187
+ });
188
+ socket.on("close", () => {
189
+ if (!registeredDeviceId) return;
190
+ const current = this.devices.get(registeredDeviceId);
191
+ if (current?.socket === socket) {
192
+ this.devices.delete(registeredDeviceId);
193
+ logger.info(
194
+ `[mobile-device-bridge] Device disconnected: ${registeredDeviceId}`
195
+ );
196
+ }
197
+ });
198
+ socket.on("error", (err) => {
199
+ logger.warn("[mobile-device-bridge] Socket error:", err.message);
200
+ });
201
+ const heartbeat = setInterval(() => {
202
+ if (!registeredDeviceId || socket.readyState !== WsCtor.OPEN) return;
203
+ try {
204
+ socket.send(JSON.stringify({ type: "ping", at: Date.now() }));
205
+ } catch {
206
+ clearInterval(heartbeat);
207
+ }
208
+ }, 15e3);
209
+ if (typeof heartbeat === "object" && "unref" in heartbeat) {
210
+ heartbeat.unref();
211
+ }
212
+ }
213
+ handleDeviceMessage(msg) {
214
+ if (msg.type === "pong" || msg.type === "register") return;
215
+ if (msg.type === "loadResult") {
216
+ const pending = this.pendingLoads.get(msg.correlationId);
217
+ if (!pending) return;
218
+ clearTimeout(pending.timeout);
219
+ this.pendingLoads.delete(msg.correlationId);
220
+ if (msg.ok === true) {
221
+ const device = this.devices.get(pending.routedDeviceId);
222
+ if (device) device.loadedPath = msg.loadedPath;
223
+ pending.resolve(void 0);
224
+ } else {
225
+ pending.reject(new Error(msg.error));
226
+ }
227
+ return;
228
+ }
229
+ if (msg.type === "unloadResult") {
230
+ const pending = this.pendingUnloads.get(msg.correlationId);
231
+ if (!pending) return;
232
+ clearTimeout(pending.timeout);
233
+ this.pendingUnloads.delete(msg.correlationId);
234
+ if (msg.ok === true) {
235
+ const device = this.devices.get(pending.routedDeviceId);
236
+ if (device) device.loadedPath = null;
237
+ pending.resolve(void 0);
238
+ } else {
239
+ pending.reject(new Error(msg.error));
240
+ }
241
+ return;
242
+ }
243
+ if (msg.type === "generateResult") {
244
+ const pending = this.pendingGenerates.get(msg.correlationId);
245
+ if (!pending) return;
246
+ clearTimeout(pending.timeout);
247
+ this.pendingGenerates.delete(msg.correlationId);
248
+ if (msg.ok === true) {
249
+ pending.resolve(msg.text);
250
+ } else {
251
+ pending.reject(new Error(msg.error));
252
+ }
253
+ return;
254
+ }
255
+ if (msg.type === "embedResult") {
256
+ const pending = this.pendingEmbeds.get(msg.correlationId);
257
+ if (!pending) return;
258
+ clearTimeout(pending.timeout);
259
+ this.pendingEmbeds.delete(msg.correlationId);
260
+ if (msg.ok === true) {
261
+ pending.resolve(msg.embedding);
262
+ } else {
263
+ pending.reject(new Error(msg.error));
264
+ }
265
+ return;
266
+ }
267
+ if (msg.type === "formatChatResult") {
268
+ const pending = this.pendingFormatChats.get(msg.correlationId);
269
+ if (!pending) return;
270
+ clearTimeout(pending.timeout);
271
+ this.pendingFormatChats.delete(msg.correlationId);
272
+ if (msg.ok === true) {
273
+ pending.resolve(msg.prompt);
274
+ } else {
275
+ pending.reject(new Error(msg.error));
276
+ }
277
+ }
278
+ }
279
+ primaryDevice() {
280
+ return this.devices.values().next().value ?? null;
281
+ }
282
+ sendToPrimary(pendingMap, makeMessage, timeoutMs, timeoutMessage) {
283
+ const device = this.primaryDevice();
284
+ if (!device) {
285
+ return Promise.reject(
286
+ new Error(
287
+ "DEVICE_DISCONNECTED: no Capacitor llama device bridge attached"
288
+ )
289
+ );
290
+ }
291
+ const correlationId = randomUUID();
292
+ const message = makeMessage(correlationId);
293
+ return new Promise((resolve, reject) => {
294
+ const timeout = setTimeout(() => {
295
+ pendingMap.delete(correlationId);
296
+ reject(new Error(timeoutMessage));
297
+ }, timeoutMs);
298
+ if (typeof timeout === "object" && "unref" in timeout) {
299
+ timeout.unref();
300
+ }
301
+ pendingMap.set(correlationId, {
302
+ resolve,
303
+ reject,
304
+ timeout,
305
+ routedDeviceId: device.deviceId
306
+ });
307
+ try {
308
+ device.socket.send(JSON.stringify(message));
309
+ } catch (err) {
310
+ clearTimeout(timeout);
311
+ pendingMap.delete(correlationId);
312
+ reject(err instanceof Error ? err : new Error(String(err)));
313
+ }
314
+ });
315
+ }
316
+ async loadModel(args) {
317
+ const device = this.primaryDevice();
318
+ if (device?.loadedPath === args.modelPath) return;
319
+ return this.sendToPrimary(
320
+ this.pendingLoads,
321
+ (correlationId) => ({
322
+ type: "load",
323
+ correlationId,
324
+ ...args
325
+ }),
326
+ readTimeoutMs("ELIZA_DEVICE_LOAD_TIMEOUT_MS", DEFAULT_LOAD_TIMEOUT_MS),
327
+ "DEVICE_TIMEOUT: model load exceeded deadline"
328
+ );
329
+ }
330
+ async unloadModel() {
331
+ const device = this.primaryDevice();
332
+ if (!device?.loadedPath) return;
333
+ return this.sendToPrimary(
334
+ this.pendingUnloads,
335
+ (correlationId) => ({ type: "unload", correlationId }),
336
+ readTimeoutMs(
337
+ "ELIZA_DEVICE_GENERATE_TIMEOUT_MS",
338
+ DEFAULT_CALL_TIMEOUT_MS
339
+ ),
340
+ "DEVICE_TIMEOUT: unload exceeded deadline"
341
+ );
342
+ }
343
+ generate(args) {
344
+ return this.sendToPrimary(
345
+ this.pendingGenerates,
346
+ (correlationId) => ({
347
+ type: "generate",
348
+ correlationId,
349
+ prompt: args.prompt,
350
+ stopSequences: args.stopSequences,
351
+ maxTokens: args.maxTokens,
352
+ temperature: args.temperature
353
+ }),
354
+ readTimeoutMs(
355
+ "ELIZA_DEVICE_GENERATE_TIMEOUT_MS",
356
+ DEFAULT_CALL_TIMEOUT_MS
357
+ ),
358
+ "DEVICE_TIMEOUT: no device responded within deadline"
359
+ );
360
+ }
361
+ embed(args) {
362
+ return this.sendToPrimary(
363
+ this.pendingEmbeds,
364
+ (correlationId) => ({
365
+ type: "embed",
366
+ correlationId,
367
+ input: args.input
368
+ }),
369
+ readTimeoutMs("ELIZA_DEVICE_EMBED_TIMEOUT_MS", DEFAULT_CALL_TIMEOUT_MS),
370
+ "DEVICE_TIMEOUT: no device returned embeddings within deadline"
371
+ );
372
+ }
373
+ /**
374
+ * Apply the model's native chat template (Jinja, from the GGUF) to the
375
+ * given message list. Round-trips to the WebView so the Capacitor
376
+ * `LlamaCpp.getFormattedChat()` plugin call can invoke llama.cpp's
377
+ * `llama_chat_apply_template`. Returns the fully tokenized chat
378
+ * prompt string ready to feed back into `generate()`. Returns `null`
379
+ * when the loaded model has no chat template baked in (caller should
380
+ * fall back to a manual flatten in that case).
381
+ */
382
+ formatChat(messages) {
383
+ return this.sendToPrimary(
384
+ this.pendingFormatChats,
385
+ (correlationId) => ({
386
+ type: "formatChat",
387
+ correlationId,
388
+ messages
389
+ }),
390
+ readTimeoutMs("ELIZA_DEVICE_LOAD_TIMEOUT_MS", DEFAULT_LOAD_TIMEOUT_MS),
391
+ "DEVICE_TIMEOUT: chat template format exceeded deadline"
392
+ );
393
+ }
394
+ };
395
+ var mobileDeviceBridge = new MobileDeviceBridge();
396
+ function readTimeoutMs(envKey, fallback) {
397
+ const parsed = Number.parseInt(process.env[envKey]?.trim() ?? "", 10);
398
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
399
+ }
400
+ function modelsDir() {
401
+ return path.join(resolveStateDir(), "local-inference", "models");
402
+ }
403
+ function registryPath() {
404
+ return path.join(resolveStateDir(), "local-inference", "registry.json");
405
+ }
406
+ function assignmentsPath() {
407
+ return path.join(resolveStateDir(), "local-inference", "assignments.json");
408
+ }
409
+ function readJsonFile(filePath) {
410
+ try {
411
+ return JSON.parse(readFileSync(filePath, "utf8"));
412
+ } catch {
413
+ return null;
414
+ }
415
+ }
416
+ function positiveInteger(value) {
417
+ const numeric = typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value, 10) : Number.NaN;
418
+ return Number.isInteger(numeric) && numeric > 0 ? numeric : null;
419
+ }
420
+ function nonEmptyString(value) {
421
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
422
+ }
423
+ function resolveFromEnv(slot) {
424
+ const key = slot === "TEXT_EMBEDDING" ? "ELIZA_LOCAL_EMBEDDING_MODEL_PATH" : "ELIZA_LOCAL_CHAT_MODEL_PATH";
425
+ const specific = process.env[key]?.trim();
426
+ if (specific && existsSync(specific)) return specific;
427
+ const fallback = process.env.ELIZA_LOCAL_MODEL_PATH?.trim();
428
+ if (fallback && existsSync(fallback)) return fallback;
429
+ return null;
430
+ }
431
+ function resolveFromRegistry(slot) {
432
+ const assignments = readJsonFile(
433
+ assignmentsPath()
434
+ )?.assignments;
435
+ const assigned = assignments?.[slot];
436
+ if (typeof assigned !== "string" || !assigned.trim()) return null;
437
+ const models = readRegistryModels();
438
+ const matched = models.find((model) => model.id === assigned);
439
+ return typeof matched?.path === "string" && existsSync(matched.path) ? matched.path : null;
440
+ }
441
+ function readRegistryModels() {
442
+ return readJsonFile(registryPath())?.models ?? [];
443
+ }
444
+ function resolveAssignedRegistryModel(slot) {
445
+ const assignments = readJsonFile(
446
+ assignmentsPath()
447
+ )?.assignments;
448
+ const assigned = assignments?.[slot];
449
+ if (typeof assigned !== "string" || !assigned.trim()) return null;
450
+ const models = readRegistryModels();
451
+ const matched = models.find((model) => model.id === assigned);
452
+ if (typeof matched?.path !== "string" || !existsSync(matched.path)) {
453
+ return null;
454
+ }
455
+ return {
456
+ id: assigned,
457
+ path: matched.path,
458
+ dimensions: matched.dimensions,
459
+ embeddingDimension: matched.embeddingDimension,
460
+ embeddingDimensions: matched.embeddingDimensions
461
+ };
462
+ }
463
+ function resolveManifestModel(slot) {
464
+ const manifest = readJsonFile(
465
+ path.join(modelsDir(), "manifest.json")
466
+ );
467
+ const targetRole = slot === "TEXT_EMBEDDING" ? "embedding" : "chat";
468
+ for (const entry of manifest?.models ?? []) {
469
+ if (entry.role !== targetRole) continue;
470
+ const fileName = entry.ggufFile ?? entry.filename;
471
+ if (!fileName) continue;
472
+ const absolute = path.join(modelsDir(), fileName);
473
+ if (existsSync(absolute)) return { path: absolute, entry };
474
+ }
475
+ return null;
476
+ }
477
+ function resolveFromManifest(slot) {
478
+ return resolveManifestModel(slot)?.path ?? null;
479
+ }
480
+ function drafterCandidates(modelPath, drafterFile) {
481
+ const modelDir = path.dirname(modelPath);
482
+ const basename = path.basename(drafterFile);
483
+ const candidates = /* @__PURE__ */ new Set();
484
+ if (path.basename(modelDir) === "text") {
485
+ const bundleRoot = path.dirname(modelDir);
486
+ candidates.add(path.join(bundleRoot, drafterFile));
487
+ }
488
+ candidates.add(path.join(modelDir, drafterFile));
489
+ candidates.add(path.join(modelDir, basename));
490
+ candidates.add(path.join(modelsDir(), drafterFile));
491
+ candidates.add(path.join(modelsDir(), basename));
492
+ return [...candidates];
493
+ }
494
+ function resolveGemmaDrafterPath(modelPath, drafterFile) {
495
+ for (const candidate of drafterCandidates(modelPath, drafterFile)) {
496
+ if (existsSync(candidate)) return candidate;
497
+ }
498
+ return null;
499
+ }
500
+ function resolveFirstGguf() {
501
+ const dir = modelsDir();
502
+ if (!existsSync(dir)) return null;
503
+ for (const name of readdirSync(dir)) {
504
+ if (!name.toLowerCase().endsWith(".gguf")) continue;
505
+ const absolute = path.join(dir, name);
506
+ if (existsSync(absolute)) return absolute;
507
+ }
508
+ return null;
509
+ }
510
+ function resolveLocalModelPath(slot) {
511
+ return resolveFromEnv(slot) ?? resolveFromRegistry(slot) ?? resolveFromManifest(slot) ?? resolveFirstGguf();
512
+ }
513
+ function buildLoadArgsFromRegistryModel(model) {
514
+ const args = { modelPath: model.path };
515
+ const eliza1 = ELIZA_1_LOAD_METADATA[model.id];
516
+ if (eliza1) {
517
+ args.contextSize = eliza1.contextSize;
518
+ if (process.env.ELIZA_BIONIC_KV_QUANT?.trim() === "1") {
519
+ args.cacheTypeK = "qjl1_256";
520
+ args.cacheTypeV = "tbq3_0";
521
+ }
522
+ const mtpOverride = bionicMtpOverride();
523
+ const mtpEnabled = mtpOverride ?? eliza1.mtp !== void 0;
524
+ const drafterPath = eliza1.mtp ? resolveGemmaDrafterPath(model.path, eliza1.mtp.drafterFile) : null;
525
+ if (mtpEnabled && eliza1.mtp && drafterPath) {
526
+ args.draftModelPath = drafterPath;
527
+ args.draftMin = eliza1.mtp.draftMin;
528
+ args.draftMax = eliza1.mtp.draftMax;
529
+ args.mobileSpeculative = true;
530
+ }
531
+ }
532
+ return args;
533
+ }
534
+ function applyManifestLoadHints(args, entry) {
535
+ const contextSize = positiveInteger(entry.contextSize);
536
+ if (contextSize !== null) args.contextSize = contextSize;
537
+ if (typeof entry.useGpu === "boolean") args.useGpu = entry.useGpu;
538
+ const maxThreads = positiveInteger(entry.maxThreads);
539
+ if (maxThreads !== null) args.maxThreads = maxThreads;
540
+ const draftContextSize = positiveInteger(entry.draftContextSize);
541
+ if (draftContextSize !== null) args.draftContextSize = draftContextSize;
542
+ const draftMin = positiveInteger(entry.draftMin);
543
+ if (draftMin !== null) args.draftMin = draftMin;
544
+ const draftMax = positiveInteger(entry.draftMax);
545
+ if (draftMax !== null) args.draftMax = draftMax;
546
+ const speculativeSamples = positiveInteger(entry.speculativeSamples);
547
+ if (speculativeSamples !== null) {
548
+ args.speculativeSamples = speculativeSamples;
549
+ }
550
+ const draftModelPath = nonEmptyString(entry.draftModelPath);
551
+ if (draftModelPath) args.draftModelPath = draftModelPath;
552
+ const cacheTypeK = nonEmptyString(entry.cacheTypeK);
553
+ if (cacheTypeK) args.cacheTypeK = cacheTypeK;
554
+ const cacheTypeV = nonEmptyString(entry.cacheTypeV);
555
+ if (cacheTypeV) args.cacheTypeV = cacheTypeV;
556
+ if (typeof entry.mobileSpeculative === "boolean") {
557
+ args.mobileSpeculative = entry.mobileSpeculative;
558
+ }
559
+ if (typeof entry.disableThinking === "boolean") {
560
+ args.disableThinking = entry.disableThinking;
561
+ }
562
+ return args;
563
+ }
564
+ function buildLoadArgsFromManifestModel(model) {
565
+ const id = nonEmptyString(model.entry.id);
566
+ const args = id ? buildLoadArgsFromRegistryModel({ id, path: model.path }) : { modelPath: model.path };
567
+ return applyManifestLoadHints(args, model.entry);
568
+ }
569
+ function resolveLocalLoadArgs(slot) {
570
+ const envPath = resolveFromEnv(slot);
571
+ if (envPath) return { modelPath: envPath };
572
+ const registryModel = resolveAssignedRegistryModel(slot);
573
+ if (registryModel) return buildLoadArgsFromRegistryModel(registryModel);
574
+ const manifestModel = resolveManifestModel(slot);
575
+ if (manifestModel) return buildLoadArgsFromManifestModel(manifestModel);
576
+ const firstGguf = resolveFirstGguf();
577
+ return firstGguf ? { modelPath: firstGguf } : null;
578
+ }
579
+ var RECOMMENDED_MODELS = {
580
+ // The quantized 2B is the shipped mobile default. Both chat slots resolve
581
+ // to it — it is the entry tier, fits 8 GB-class phones with headroom, and is
582
+ // the model bundled into the AOSP image. The load path runs it at 64k
583
+ // context (see ELIZA_1_LOAD_METADATA) with compressed KV.
584
+ TEXT_SMALL: {
585
+ id: "eliza-1-2b",
586
+ hfRepo: "elizaos/eliza-1",
587
+ ggufFile: "bundles/2b/text/eliza-1-2b-128k.gguf",
588
+ localFile: "eliza-1-2b-128k.gguf"
589
+ },
590
+ TEXT_LARGE: {
591
+ id: "eliza-1-2b",
592
+ hfRepo: "elizaos/eliza-1",
593
+ ggufFile: "bundles/2b/text/eliza-1-2b-128k.gguf",
594
+ localFile: "eliza-1-2b-128k.gguf"
595
+ },
596
+ TEXT_EMBEDDING: {
597
+ id: "eliza-1-embedding",
598
+ hfRepo: "elizaos/eliza-1",
599
+ ggufFile: "bundles/4b/embedding/eliza-1-embedding.gguf",
600
+ localFile: "eliza-1-embedding.gguf"
601
+ }
602
+ };
603
+ var inflightDownloads = /* @__PURE__ */ new Map();
604
+ function buildHfResolveUrl(model) {
605
+ const encodedPath = model.ggufFile.split("/").map((segment) => encodeURIComponent(segment)).join("/");
606
+ return `https://huggingface.co/${model.hfRepo}/resolve/main/${encodedPath}?download=true`;
607
+ }
608
+ function buildRecommendedLoadArgs(slot, modelPath) {
609
+ const model = RECOMMENDED_MODELS[slot];
610
+ return buildLoadArgsFromRegistryModel({ id: model.id, path: modelPath });
611
+ }
612
+ async function downloadRecommendedModelFor(slot) {
613
+ const model = RECOMMENDED_MODELS[slot];
614
+ const dir = modelsDir();
615
+ mkdirSync(dir, { recursive: true });
616
+ const finalPath = path.join(
617
+ dir,
618
+ model.localFile ?? path.basename(model.ggufFile)
619
+ );
620
+ if (existsSync(finalPath)) {
621
+ const sz = statSync(finalPath).size;
622
+ if (!model.expectedSizeBytes || sz === model.expectedSizeBytes) {
623
+ return finalPath;
624
+ }
625
+ logger.warn(
626
+ `[mobile-device-bridge] ${model.ggufFile} present but size ${sz} != expected ${model.expectedSizeBytes}; re-downloading.`
627
+ );
628
+ try {
629
+ unlinkSync(finalPath);
630
+ } catch {
631
+ }
632
+ }
633
+ const dedupKey = model.id;
634
+ const existing = inflightDownloads.get(dedupKey);
635
+ if (existing) return existing;
636
+ const promise = (async () => {
637
+ const url = buildHfResolveUrl(model);
638
+ const stagingPath = `${finalPath}.part`;
639
+ try {
640
+ unlinkSync(stagingPath);
641
+ } catch {
642
+ }
643
+ logger.info(
644
+ `[mobile-device-bridge] Auto-downloading recommended ${slot} model ${model.id} from ${url}`
645
+ );
646
+ const response = await fetch(url, { redirect: "follow" });
647
+ if (!response.ok || !response.body) {
648
+ throw new Error(
649
+ `[mobile-device-bridge] Recommended-model download failed (${slot}): HTTP ${response.status} ${response.statusText} from ${url}`
650
+ );
651
+ }
652
+ await pipeline(
653
+ Readable.fromWeb(response.body),
654
+ createWriteStream(stagingPath)
655
+ );
656
+ const stagedSize = statSync(stagingPath).size;
657
+ if (model.expectedSizeBytes && stagedSize !== model.expectedSizeBytes) {
658
+ try {
659
+ unlinkSync(stagingPath);
660
+ } catch {
661
+ }
662
+ throw new Error(
663
+ `[mobile-device-bridge] Downloaded ${model.ggufFile} size ${stagedSize} != expected ${model.expectedSizeBytes}; aborting and removing partial file.`
664
+ );
665
+ }
666
+ renameSync(stagingPath, finalPath);
667
+ logger.info(
668
+ `[mobile-device-bridge] Auto-download complete: ${finalPath} (${stagedSize} bytes)`
669
+ );
670
+ return finalPath;
671
+ })();
672
+ inflightDownloads.set(dedupKey, promise);
673
+ try {
674
+ return await promise;
675
+ } finally {
676
+ inflightDownloads.delete(dedupKey);
677
+ }
678
+ }
679
+ async function resolveLoadArgsWithAutoDownload(slot) {
680
+ const existing = resolveLocalLoadArgs(slot);
681
+ if (existing) return existing;
682
+ if (process.env.ELIZA_DISABLE_MODEL_AUTO_DOWNLOAD?.trim() === "1") {
683
+ return null;
684
+ }
685
+ const downloaded = await downloadRecommendedModelFor(slot);
686
+ return buildRecommendedLoadArgs(slot, downloaded);
687
+ }
688
+ function resolveEmbeddingDimension() {
689
+ const assigned = resolveAssignedRegistryModel("TEXT_EMBEDDING");
690
+ return positiveInteger(process.env.ELIZA_LOCAL_EMBEDDING_DIMENSIONS) ?? positiveInteger(process.env.TEXT_EMBEDDING_DIMENSIONS) ?? positiveInteger(assigned?.dimensions) ?? positiveInteger(assigned?.embeddingDimension) ?? positiveInteger(assigned?.embeddingDimensions) ?? (assigned?.id ? KNOWN_EMBEDDING_DIMENSIONS[assigned.id] : null) ?? KNOWN_EMBEDDING_DIMENSIONS[RECOMMENDED_MODELS.TEXT_EMBEDDING.id] ?? 1024;
691
+ }
692
+ function flattenChatParamsForPrompt(params) {
693
+ if (typeof params.prompt === "string" && params.prompt.length > 0) {
694
+ return params.prompt;
695
+ }
696
+ const messages = params.messages ?? [];
697
+ const blocks = [];
698
+ const hasSystemMessage = messages.some(
699
+ (m) => m.role === "system"
700
+ );
701
+ if (!hasSystemMessage && typeof params.system === "string" && params.system) {
702
+ blocks.push(`system:
703
+ ${params.system}`);
704
+ }
705
+ for (const m of messages) {
706
+ const content = typeof m.content === "string" ? m.content : "";
707
+ if (!content) continue;
708
+ const role = (m.role ?? "user").toLowerCase();
709
+ const safeRole = role === "system" || role === "assistant" || role === "user" ? role : "user";
710
+ blocks.push(`${safeRole}:
711
+ ${content}`);
712
+ }
713
+ blocks.push("assistant:");
714
+ return blocks.join("\n\n");
715
+ }
716
+ var BIONIC_REQUEST_TIMEOUT_MS = readTimeoutMs(
717
+ "ELIZA_BIONIC_REQUEST_TIMEOUT_MS",
718
+ 3e5
719
+ );
720
+ var BIONIC_MAX_FRAME_BYTES = 64 * 1024 * 1024;
721
+ function bionicSocketName() {
722
+ if (process.env.ELIZA_BIONIC_HOST_DELEGATED?.trim() !== "1") return null;
723
+ const sock = process.env.ELIZA_BIONIC_INFERENCE_SOCK?.trim();
724
+ return sock ? sock : null;
725
+ }
726
+ function deriveBionicBundleDir(modelPath) {
727
+ if (!modelPath) return "";
728
+ const dir = path.dirname(modelPath);
729
+ if (path.basename(dir) === "text") return path.dirname(dir);
730
+ return "";
731
+ }
732
+ function roleForGemmaPrompt(role) {
733
+ if (role === "assistant") return "model";
734
+ if (role === "system") return "system";
735
+ return "user";
736
+ }
737
+ function collectChatMlPromptMessages(prompt, system) {
738
+ const headerPattern = /<\|im_start\|>(system|user|assistant)(?:\n|$)/g;
739
+ const headers = [];
740
+ let match = headerPattern.exec(prompt);
741
+ while (match !== null) {
742
+ headers.push({
743
+ index: match.index,
744
+ role: match[1],
745
+ bodyStart: match.index + match[0].length
746
+ });
747
+ match = headerPattern.exec(prompt);
748
+ }
749
+ if (headers.length === 0) return null;
750
+ const result = [];
751
+ if (system?.trim() && headers[0]?.role !== "system") {
752
+ result.push({ role: "system", content: system.trim() });
753
+ }
754
+ for (let i = 0; i < headers.length; i += 1) {
755
+ const current = headers[i];
756
+ const next = headers[i + 1];
757
+ const rawContent = prompt.slice(current.bodyStart, next ? next.index : prompt.length).replace(/<\|im_end\|>\s*$/g, "").trim();
758
+ if (!rawContent) continue;
759
+ result.push({ role: current.role, content: rawContent });
760
+ }
761
+ return result.length > 0 ? result : null;
762
+ }
763
+ function renderGemmaPromptMessages(messages) {
764
+ let out = "";
765
+ for (const m of messages) {
766
+ const content = m.content.trim();
767
+ if (!content) continue;
768
+ out += `<start_of_turn>${roleForGemmaPrompt(m.role)}
769
+ ${content}<end_of_turn>
770
+ `;
771
+ }
772
+ return `${out}<start_of_turn>model
773
+ `;
774
+ }
775
+ function buildGemmaBionicPrompt(params) {
776
+ const prompt = typeof params.prompt === "string" ? params.prompt : "";
777
+ const trimmedPrompt = prompt.trimEnd();
778
+ if (trimmedPrompt.includes("<start_of_turn>") && trimmedPrompt.includes("<start_of_turn>model")) {
779
+ return trimmedPrompt;
780
+ }
781
+ const msgs = prompt.includes("<|im_start|>") ? collectChatMlPromptMessages(prompt, params.system) : collectMessagesForNativeTemplate(params);
782
+ if (!msgs || msgs.length === 0) {
783
+ return `<start_of_turn>user
784
+ ${flattenChatParamsForPrompt(params).trim()}<end_of_turn>
785
+ <start_of_turn>model
786
+ `;
787
+ }
788
+ return renderGemmaPromptMessages(msgs);
789
+ }
790
+ function bionicHostGenerate(socketName, request) {
791
+ const payload = Buffer.from(JSON.stringify(request), "utf8");
792
+ const frame = Buffer.allocUnsafe(4 + payload.length);
793
+ frame.writeUInt32BE(payload.length, 0);
794
+ payload.copy(frame, 4);
795
+ return new Promise((resolve, reject) => {
796
+ const sock = net.connect({ path: `\0${socketName}` });
797
+ let settled = false;
798
+ let chunks = Buffer.alloc(0);
799
+ let expected = -1;
800
+ const finish = (err, value) => {
801
+ if (settled) return;
802
+ settled = true;
803
+ clearTimeout(timer);
804
+ sock.destroy();
805
+ err ? reject(err) : resolve(value);
806
+ };
807
+ const timer = setTimeout(
808
+ () => finish(new Error("[mobile-device-bridge] bionic host timed out")),
809
+ BIONIC_REQUEST_TIMEOUT_MS
810
+ );
811
+ sock.on("connect", () => sock.write(frame));
812
+ sock.on("data", (d) => {
813
+ chunks = Buffer.concat([chunks, d]);
814
+ if (expected < 0 && chunks.length >= 4) {
815
+ expected = chunks.readUInt32BE(0);
816
+ if (expected < 0 || expected > BIONIC_MAX_FRAME_BYTES) {
817
+ finish(
818
+ new Error(`[mobile-device-bridge] bad bionic frame ${expected}`)
819
+ );
820
+ return;
821
+ }
822
+ }
823
+ if (expected >= 0 && chunks.length >= 4 + expected) {
824
+ try {
825
+ finish(
826
+ null,
827
+ JSON.parse(chunks.subarray(4, 4 + expected).toString("utf8"))
828
+ );
829
+ } catch (e) {
830
+ finish(
831
+ new Error(
832
+ `[mobile-device-bridge] bad bionic JSON: ${e.message}`
833
+ )
834
+ );
835
+ }
836
+ }
837
+ });
838
+ sock.on(
839
+ "error",
840
+ (e) => finish(
841
+ new Error(`[mobile-device-bridge] bionic socket error: ${e.message}`)
842
+ )
843
+ );
844
+ sock.on("close", () => {
845
+ if (!settled)
846
+ finish(new Error("[mobile-device-bridge] bionic host closed early"));
847
+ });
848
+ });
849
+ }
850
+ function bionicHostGenerateStream(socketName, request, onToken) {
851
+ const payload = Buffer.from(
852
+ JSON.stringify({ ...request, op: "generateStream" }),
853
+ "utf8"
854
+ );
855
+ const frame = Buffer.allocUnsafe(4 + payload.length);
856
+ frame.writeUInt32BE(payload.length, 0);
857
+ payload.copy(frame, 4);
858
+ return new Promise((resolve, reject) => {
859
+ const sock = net.connect({ path: `\0${socketName}` });
860
+ let settled = false;
861
+ let chunks = Buffer.alloc(0);
862
+ const finish = (err, value) => {
863
+ if (settled) return;
864
+ settled = true;
865
+ clearTimeout(timer);
866
+ sock.destroy();
867
+ err ? reject(err) : resolve(value);
868
+ };
869
+ const timer = setTimeout(
870
+ () => finish(new Error("[mobile-device-bridge] bionic host timed out")),
871
+ BIONIC_REQUEST_TIMEOUT_MS
872
+ );
873
+ sock.on("connect", () => sock.write(frame));
874
+ sock.on("data", (d) => {
875
+ chunks = Buffer.concat([chunks, d]);
876
+ for (; ; ) {
877
+ if (chunks.length < 4) break;
878
+ const expected = chunks.readUInt32BE(0);
879
+ if (expected < 0 || expected > BIONIC_MAX_FRAME_BYTES) {
880
+ finish(
881
+ new Error(`[mobile-device-bridge] bad bionic frame ${expected}`)
882
+ );
883
+ return;
884
+ }
885
+ if (chunks.length < 4 + expected) break;
886
+ const json = chunks.subarray(4, 4 + expected).toString("utf8");
887
+ chunks = chunks.subarray(4 + expected);
888
+ let msg;
889
+ try {
890
+ msg = JSON.parse(json);
891
+ } catch (e) {
892
+ finish(
893
+ new Error(
894
+ `[mobile-device-bridge] bad bionic JSON: ${e.message}`
895
+ )
896
+ );
897
+ return;
898
+ }
899
+ if (msg.type === "token") {
900
+ if (typeof msg.text === "string" && msg.text) onToken(msg.text);
901
+ continue;
902
+ }
903
+ finish(null, msg);
904
+ return;
905
+ }
906
+ });
907
+ sock.on(
908
+ "error",
909
+ (e) => finish(
910
+ new Error(`[mobile-device-bridge] bionic socket error: ${e.message}`)
911
+ )
912
+ );
913
+ sock.on("close", () => {
914
+ if (!settled)
915
+ finish(new Error("[mobile-device-bridge] bionic host closed early"));
916
+ });
917
+ });
918
+ }
919
+ function makeGenerateHandler(slot) {
920
+ return async (_runtime, params) => {
921
+ const bionicSock = bionicSocketName();
922
+ if (bionicSock) {
923
+ const installed = resolveLocalLoadArgs(slot);
924
+ const baseRequest = {
925
+ bundleDir: installed ? deriveBionicBundleDir(installed.modelPath) : "",
926
+ drafterPath: installed?.draftModelPath ?? "",
927
+ prompt: buildGemmaBionicPrompt(params),
928
+ maxTokens: params.maxTokens ?? 256
929
+ };
930
+ const onChunk = params.onStreamChunk;
931
+ let accumulated = "";
932
+ const res = typeof onChunk === "function" ? await bionicHostGenerateStream(bionicSock, baseRequest, (text) => {
933
+ accumulated += text;
934
+ void onChunk(text, void 0, accumulated);
935
+ }) : await bionicHostGenerate(bionicSock, {
936
+ op: "generate",
937
+ ...baseRequest
938
+ });
939
+ if (!res.ok) {
940
+ throw new Error(
941
+ `[mobile-device-bridge] bionic host generate failed: ${res.error ?? "unknown"}`
942
+ );
943
+ }
944
+ if (typeof res.tokS === "number") {
945
+ logger.info(
946
+ `[mobile-device-bridge] bionic GPU generate: ${res.tokens ?? "?"} tok @ ${res.tokS.toFixed(1)} tok/s`
947
+ );
948
+ }
949
+ return res.text ?? "";
950
+ }
951
+ const loadArgs = await resolveLoadArgsWithAutoDownload(slot);
952
+ if (!loadArgs) {
953
+ throw new Error(
954
+ `[mobile-device-bridge] No local GGUF model installed under ${modelsDir()} and auto-download is disabled (ELIZA_DISABLE_MODEL_AUTO_DOWNLOAD=1). Install a model or unset the disable flag.`
955
+ );
956
+ }
957
+ await mobileDeviceBridge.loadModel(loadArgs);
958
+ const messagesForTemplate = collectMessagesForNativeTemplate(params);
959
+ let nativePrompt = null;
960
+ if (messagesForTemplate) {
961
+ try {
962
+ nativePrompt = await mobileDeviceBridge.formatChat(messagesForTemplate);
963
+ } catch (err) {
964
+ logger.warn(
965
+ `[mobile-device-bridge] getFormattedChat failed, falling back to plain-text flatten: ${err instanceof Error ? err.message : String(err)}`
966
+ );
967
+ }
968
+ }
969
+ const prompt = nativePrompt ?? flattenChatParamsForPrompt(params);
970
+ return mobileDeviceBridge.generate({
971
+ prompt,
972
+ stopSequences: params.stopSequences,
973
+ maxTokens: params.maxTokens,
974
+ temperature: params.temperature
975
+ });
976
+ };
977
+ }
978
+ function collectMessagesForNativeTemplate(params) {
979
+ const messages = params.messages ?? [];
980
+ if (messages.length === 0 && typeof params.prompt === "string") {
981
+ return collectRoleLabeledPromptMessages(params.prompt, params.system);
982
+ }
983
+ const result = [];
984
+ const hasSystemMessage = messages.some(
985
+ (m) => m.role === "system"
986
+ );
987
+ if (!hasSystemMessage && typeof params.system === "string" && params.system) {
988
+ result.push({ role: "system", content: params.system });
989
+ }
990
+ for (const m of messages) {
991
+ const content = typeof m.content === "string" ? m.content : "";
992
+ if (!content) continue;
993
+ const role = (m.role ?? "user").toLowerCase();
994
+ const safeRole = role === "system" || role === "assistant" || role === "user" ? role : "user";
995
+ result.push({ role: safeRole, content });
996
+ }
997
+ return result.length > 0 ? result : null;
998
+ }
999
+ function collectRoleLabeledPromptMessages(prompt, system) {
1000
+ if (!/^(system|user|assistant):\n/.test(prompt)) return null;
1001
+ const headerPattern = /(^|\n{2,})(system|user|assistant):\n/g;
1002
+ const headers = [];
1003
+ let match = headerPattern.exec(prompt);
1004
+ while (match !== null) {
1005
+ headers.push({
1006
+ index: match.index,
1007
+ role: match[2],
1008
+ bodyStart: match.index + match[0].length
1009
+ });
1010
+ match = headerPattern.exec(prompt);
1011
+ }
1012
+ if (headers.length === 0) return null;
1013
+ const result = [];
1014
+ if (system?.trim() && headers[0]?.role !== "system") {
1015
+ result.push({ role: "system", content: system.trim() });
1016
+ }
1017
+ for (let i = 0; i < headers.length; i += 1) {
1018
+ const current = headers[i];
1019
+ const next = headers[i + 1];
1020
+ const rawContent = prompt.slice(current.bodyStart, next ? next.index : prompt.length).trim();
1021
+ if (!rawContent) continue;
1022
+ result.push({ role: current.role, content: rawContent });
1023
+ }
1024
+ return result.length > 0 ? result : null;
1025
+ }
1026
+ function extractEmbeddingText(params) {
1027
+ if (params === null) return "";
1028
+ if (typeof params === "string") return params;
1029
+ return params.text;
1030
+ }
1031
+ function makeEmbeddingHandler() {
1032
+ return async (_runtime, params) => {
1033
+ if (params === null) {
1034
+ return new Array(resolveEmbeddingDimension()).fill(0);
1035
+ }
1036
+ let loadArgs = resolveLocalLoadArgs("TEXT_EMBEDDING");
1037
+ let modelPath = loadArgs?.modelPath ?? null;
1038
+ if (!modelPath) {
1039
+ if (process.env.ELIZA_DISABLE_MODEL_AUTO_DOWNLOAD?.trim() === "1") {
1040
+ throw new Error(
1041
+ `[mobile-device-bridge] No local GGUF embedding model installed under ${modelsDir()} and auto-download is disabled.`
1042
+ );
1043
+ }
1044
+ modelPath = await downloadRecommendedModelFor("TEXT_EMBEDDING");
1045
+ loadArgs = buildRecommendedLoadArgs("TEXT_EMBEDDING", modelPath);
1046
+ }
1047
+ if (!loadArgs) {
1048
+ throw new Error(
1049
+ `[mobile-device-bridge] No local GGUF embedding model resolved for ${modelsDir()}.`
1050
+ );
1051
+ }
1052
+ const bionicSock = bionicSocketName();
1053
+ if (bionicSock) {
1054
+ const res = await bionicHostGenerate(bionicSock, {
1055
+ op: "embed",
1056
+ bundleDir: deriveBionicBundleDir(loadArgs.modelPath),
1057
+ text: extractEmbeddingText(params)
1058
+ });
1059
+ if (!res.ok || !Array.isArray(res.embedding)) {
1060
+ throw new Error(
1061
+ `[mobile-device-bridge] bionic embed failed: ${res.error ?? "no embedding"}`
1062
+ );
1063
+ }
1064
+ return res.embedding;
1065
+ }
1066
+ await mobileDeviceBridge.loadModel(loadArgs);
1067
+ return mobileDeviceBridge.embed({
1068
+ input: extractEmbeddingText(params)
1069
+ });
1070
+ };
1071
+ }
1072
+ function getMobileDeviceBridgeStatus() {
1073
+ return mobileDeviceBridge.status();
1074
+ }
1075
+ async function loadMobileDeviceBridgeModel(modelPath, modelId) {
1076
+ await mobileDeviceBridge.loadModel(
1077
+ modelId ? buildLoadArgsFromRegistryModel({ id: modelId, path: modelPath }) : { modelPath }
1078
+ );
1079
+ }
1080
+ async function unloadMobileDeviceBridgeModel() {
1081
+ await mobileDeviceBridge.unloadModel();
1082
+ }
1083
+ async function attachMobileDeviceBridgeToServer(server) {
1084
+ await mobileDeviceBridge.attachToHttpServer(server);
1085
+ }
1086
+ async function imageUrlToBase64(url) {
1087
+ if (url.startsWith("data:")) {
1088
+ const comma = url.indexOf(",");
1089
+ return comma >= 0 ? url.slice(comma + 1) : url;
1090
+ }
1091
+ const resp = await fetch(url);
1092
+ if (!resp.ok) {
1093
+ throw new Error(
1094
+ `[mobile-device-bridge] IMAGE_DESCRIPTION failed to fetch ${url}: ${resp.status}`
1095
+ );
1096
+ }
1097
+ return Buffer.from(await resp.arrayBuffer()).toString("base64");
1098
+ }
1099
+ function collapseDescriptionRepetition(text) {
1100
+ const sentences = text.replace(/\s+/g, " ").split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter(Boolean);
1101
+ const seen = /* @__PURE__ */ new Set();
1102
+ const kept = [];
1103
+ for (const sentence of sentences) {
1104
+ const key = sentence.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
1105
+ if (key && seen.has(key)) {
1106
+ continue;
1107
+ }
1108
+ seen.add(key);
1109
+ kept.push(sentence);
1110
+ if (kept.length >= 6) {
1111
+ break;
1112
+ }
1113
+ }
1114
+ return kept.join(" ").trim() || text.trim();
1115
+ }
1116
+ function makeBionicImageDescriptionHandler() {
1117
+ return async (_runtime, params) => {
1118
+ const socketName = bionicSocketName();
1119
+ if (!socketName) {
1120
+ throw new Error(
1121
+ "[mobile-device-bridge] IMAGE_DESCRIPTION requires the bionic host (ELIZA_BIONIC_HOST_DELEGATED=1)"
1122
+ );
1123
+ }
1124
+ const url = typeof params === "string" ? params : params?.imageUrl;
1125
+ if (typeof url !== "string" || url.length === 0) {
1126
+ throw new Error(
1127
+ "[mobile-device-bridge] IMAGE_DESCRIPTION requires a non-empty imageUrl"
1128
+ );
1129
+ }
1130
+ const prompt = typeof params === "object" && params ? params.prompt : void 0;
1131
+ const imageBase64 = await imageUrlToBase64(url);
1132
+ const res = await bionicHostGenerate(socketName, {
1133
+ op: "image",
1134
+ bundleDir: "",
1135
+ imageBase64,
1136
+ mmprojPath: "",
1137
+ prompt: prompt ?? ""
1138
+ });
1139
+ if (!res.ok) {
1140
+ throw new Error(
1141
+ `[mobile-device-bridge] bionic image describe failed: ${res.error ?? "unknown error"}`
1142
+ );
1143
+ }
1144
+ const raw = (res.text ?? "").trim();
1145
+ if (!raw) {
1146
+ throw new Error(
1147
+ "[mobile-device-bridge] bionic image describe returned empty text"
1148
+ );
1149
+ }
1150
+ const description = collapseDescriptionRepetition(raw);
1151
+ return {
1152
+ title: description.split(/[.!?]/, 1)[0]?.trim() || "Image",
1153
+ description
1154
+ };
1155
+ };
1156
+ }
1157
+ async function ensureMobileDeviceBridgeInferenceHandlers(runtime) {
1158
+ logger.debug("[mobile-device-bridge] Bootstrap entered");
1159
+ if (!SERVICE_ENABLED || process.env.ELIZA_LOCAL_LLAMA?.trim() === "1") {
1160
+ logger.debug("[mobile-device-bridge] Disabled or AOSP local llama active");
1161
+ return false;
1162
+ }
1163
+ if (registeredRuntimes.has(runtime)) {
1164
+ logger.debug("[mobile-device-bridge] Handlers already registered");
1165
+ return true;
1166
+ }
1167
+ const runtimeWithRegistration = runtime;
1168
+ if (typeof runtimeWithRegistration.getModel !== "function" || typeof runtimeWithRegistration.registerModel !== "function") {
1169
+ logger.error(
1170
+ "[mobile-device-bridge] Runtime is missing getModel/registerModel; cannot wire handlers."
1171
+ );
1172
+ return false;
1173
+ }
1174
+ runtimeWithRegistration.registerModel(
1175
+ ModelType.TEXT_SMALL,
1176
+ makeGenerateHandler("TEXT_SMALL"),
1177
+ PROVIDER,
1178
+ LOCAL_INFERENCE_PRIORITY
1179
+ );
1180
+ runtimeWithRegistration.registerModel(
1181
+ ModelType.TEXT_LARGE,
1182
+ makeGenerateHandler("TEXT_LARGE"),
1183
+ PROVIDER,
1184
+ LOCAL_INFERENCE_PRIORITY
1185
+ );
1186
+ if (!resolveLocalLoadArgs("TEXT_SMALL") && process.env.ELIZA_DISABLE_MODEL_AUTO_DOWNLOAD?.trim() !== "1") {
1187
+ downloadRecommendedModelFor("TEXT_SMALL").catch(
1188
+ (err) => logger.warn(
1189
+ `[mobile-device-bridge] Background chat-model download failed: ${err.message}`
1190
+ )
1191
+ );
1192
+ }
1193
+ runtimeWithRegistration.registerModel(
1194
+ ModelType.TEXT_EMBEDDING,
1195
+ makeEmbeddingHandler(),
1196
+ PROVIDER,
1197
+ LOCAL_INFERENCE_PRIORITY
1198
+ );
1199
+ if (bionicSocketName()) {
1200
+ runtimeWithRegistration.registerModel(
1201
+ ModelType.IMAGE_DESCRIPTION,
1202
+ makeBionicImageDescriptionHandler(),
1203
+ PROVIDER,
1204
+ LOCAL_INFERENCE_PRIORITY
1205
+ );
1206
+ logger.info(
1207
+ "[mobile-device-bridge] Registered bionic IMAGE_DESCRIPTION handler (op=image)"
1208
+ );
1209
+ }
1210
+ const embeddingModelPath = resolveLocalModelPath("TEXT_EMBEDDING");
1211
+ if (!embeddingModelPath && process.env.ELIZA_DISABLE_MODEL_AUTO_DOWNLOAD?.trim() !== "1") {
1212
+ downloadRecommendedModelFor("TEXT_EMBEDDING").catch(
1213
+ (err) => logger.warn(
1214
+ `[mobile-device-bridge] Background embedding-model download failed: ${err.message}`
1215
+ )
1216
+ );
1217
+ }
1218
+ logger.info(
1219
+ `[mobile-device-bridge] Registered ${PROVIDER} handlers for TEXT_SMALL / TEXT_LARGE${embeddingModelPath ? " / TEXT_EMBEDDING" : ""} at priority ${LOCAL_INFERENCE_PRIORITY}`
1220
+ );
1221
+ registeredRuntimes.add(runtime);
1222
+ return true;
1223
+ }
1224
+
1225
+ export {
1226
+ mobileDeviceBridge,
1227
+ buildLoadArgsFromRegistryModel,
1228
+ buildGemmaBionicPrompt,
1229
+ getMobileDeviceBridgeStatus,
1230
+ loadMobileDeviceBridgeModel,
1231
+ unloadMobileDeviceBridgeModel,
1232
+ attachMobileDeviceBridgeToServer,
1233
+ ensureMobileDeviceBridgeInferenceHandlers
1234
+ };