|
| 1 | +#!/usr/bin/env bun |
| 2 | +// build-mobile-bundle.mjs — produce the on-device agent payload. |
| 3 | +// |
| 4 | +// Output layout (consumed by the Phase A asset pipeline): |
| 5 | +// |
| 6 | +// eliza/packages/agent/dist-mobile/ |
| 7 | +// agent-bundle.js the actual bun-runnable payload |
| 8 | +// pglite.wasm PGlite WebAssembly module |
| 9 | +// pglite.data PGlite filesystem image |
| 10 | +// vector.tar.gz pgvector contrib (referenced via ../) |
| 11 | +// fuzzystrmatch.tar.gz fuzzystrmatch contrib (referenced via ../) |
| 12 | +// plugins-manifest.json list of plugins statically baked into the bundle |
| 13 | +// |
| 14 | +// What this build does NOT do: |
| 15 | +// - Stage `node_modules`. All `MOBILE_CORE_PLUGINS` resolve through |
| 16 | +// `STATIC_ELIZA_PLUGINS` in the agent runtime (via static `import * as |
| 17 | +// pluginX from "@elizaos/plugin-X"`), so they are inlined by `Bun.build`. |
| 18 | +// - Bundle a model. Inference goes through `ANTHROPIC_API_KEY` / |
| 19 | +// `ELIZAOS_CLOUD_API_KEY` from the user's onboarding for first-light. |
| 20 | +// |
| 21 | +// PGlite extension paths: |
| 22 | +// `@electric-sql/pglite` resolves four assets via `new URL(..., import.meta.url)`: |
| 23 | +// - "./pglite.wasm" => same dir as the bundle |
| 24 | +// - "./pglite.data" => same dir as the bundle |
| 25 | +// - "../vector.tar.gz" => one dir above the bundle |
| 26 | +// - "../fuzzystrmatch.tar.gz" => one dir above the bundle |
| 27 | +// After `Bun.build`, `import.meta.url` becomes the bundle's path, so we |
| 28 | +// ship the four files alongside it and the asset pipeline mounts them so |
| 29 | +// the relative paths land. Phase A is responsible for placing the .tar.gz |
| 30 | +// files at parent-of-bundle on the device. |
| 31 | + |
| 32 | +import { |
| 33 | + copyFile, |
| 34 | + mkdir, |
| 35 | + readdir, |
| 36 | + rm, |
| 37 | + stat, |
| 38 | + writeFile, |
| 39 | +} from "node:fs/promises"; |
| 40 | +import { existsSync, readdirSync } from "node:fs"; |
| 41 | +import path from "node:path"; |
| 42 | +import { fileURLToPath } from "node:url"; |
| 43 | + |
| 44 | +const here = path.dirname(fileURLToPath(import.meta.url)); |
| 45 | +const agentRoot = path.resolve(here, ".."); |
| 46 | +const repoRoot = path.resolve(agentRoot, "..", "..", ".."); |
| 47 | +const outDir = path.join(agentRoot, "dist-mobile"); |
| 48 | +const stubsDir = path.join(here, "mobile-stubs"); |
| 49 | +const entry = path.join(agentRoot, "src", "bin.ts"); |
| 50 | + |
| 51 | +console.log("[build-mobile] agent root:", agentRoot); |
| 52 | +console.log("[build-mobile] output dir:", outDir); |
| 53 | + |
| 54 | +await rm(outDir, { recursive: true, force: true }); |
| 55 | +await mkdir(outDir, { recursive: true }); |
| 56 | + |
| 57 | +function findPgliteDist() { |
| 58 | + const candidates = [ |
| 59 | + path.join(repoRoot, "node_modules", "@electric-sql", "pglite", "dist"), |
| 60 | + ]; |
| 61 | + const bunDir = path.join(repoRoot, "node_modules", ".bun"); |
| 62 | + if (existsSync(bunDir)) { |
| 63 | + for (const entry of readdirSyncSafe(bunDir)) { |
| 64 | + if (entry.startsWith("@electric-sql+pglite@")) { |
| 65 | + candidates.push( |
| 66 | + path.join( |
| 67 | + bunDir, |
| 68 | + entry, |
| 69 | + "node_modules", |
| 70 | + "@electric-sql", |
| 71 | + "pglite", |
| 72 | + "dist", |
| 73 | + ), |
| 74 | + ); |
| 75 | + } |
| 76 | + } |
| 77 | + } |
| 78 | + for (const c of candidates) { |
| 79 | + if (existsSync(path.join(c, "pglite.wasm"))) return c; |
| 80 | + } |
| 81 | + return null; |
| 82 | +} |
| 83 | + |
| 84 | +function readdirSyncSafe(p) { |
| 85 | + try { |
| 86 | + return readdirSync(p); |
| 87 | + } catch { |
| 88 | + return []; |
| 89 | + } |
| 90 | +} |
| 91 | + |
| 92 | +const pgliteDist = findPgliteDist(); |
| 93 | +if (!pgliteDist) { |
| 94 | + console.error( |
| 95 | + "[build-mobile] FATAL: could not locate @electric-sql/pglite/dist. " + |
| 96 | + "Run `bun install` first.", |
| 97 | + ); |
| 98 | + process.exit(1); |
| 99 | +} |
| 100 | +console.log("[build-mobile] pglite dist:", pgliteDist); |
| 101 | + |
| 102 | +// Native deps without an Android prebuild — replace at bundle time with |
| 103 | +// throw-on-call shims. Bun.build's `--external` would leave bare-name imports |
| 104 | +// in the output; `MILADY_PLATFORM=android` would then fail at runtime when |
| 105 | +// the mobile bun process can't resolve the missing package. A plugin onResolve |
| 106 | +// that maps the bare specifier to the stub path keeps the resolution pure. |
| 107 | +// Native deps without an Android prebuild — replaced with throw-on-call shims. |
| 108 | +const nativeStubs = { |
| 109 | + "node-llama-cpp": path.join(stubsDir, "node-llama-cpp.cjs"), |
| 110 | + "@node-llama-cpp/linux-x64": path.join(stubsDir, "node-llama-cpp.cjs"), |
| 111 | + "@node-llama-cpp/linux-arm64": path.join(stubsDir, "node-llama-cpp.cjs"), |
| 112 | + "@node-llama-cpp/mac-arm64": path.join(stubsDir, "node-llama-cpp.cjs"), |
| 113 | + "@node-llama-cpp/mac-x64": path.join(stubsDir, "node-llama-cpp.cjs"), |
| 114 | + "@node-llama-cpp/win-x64": path.join(stubsDir, "node-llama-cpp.cjs"), |
| 115 | + "onnxruntime-node": path.join(stubsDir, "onnxruntime-node.cjs"), |
| 116 | + "@huggingface/transformers": path.join(stubsDir, "huggingface-transformers.cjs"), |
| 117 | + "puppeteer-core": path.join(stubsDir, "puppeteer-core.cjs"), |
| 118 | + "pty-manager": path.join(stubsDir, "pty-manager.cjs"), |
| 119 | + sharp: path.join(stubsDir, "sharp.cjs"), |
| 120 | + canvas: path.join(stubsDir, "canvas.cjs"), |
| 121 | +}; |
| 122 | + |
| 123 | +// Optional @elizaos plugins that the agent runtime statically references but |
| 124 | +// transitively pull in old/incompatible `@elizaos/core` versions. Stubbing |
| 125 | +// them keeps the bundle from carrying multiple AgentRuntime classes (the |
| 126 | +// failure mode is: plugin-sql's adapter exposes methods one runtime expects |
| 127 | +// but the OTHER runtime doesn't, then `getAgentsByIds is not a function` at |
| 128 | +// boot). The narrow list below is exactly the packages whose dependency |
| 129 | +// closure pulls in `@elizaos/core@2.0.0-alpha.3` or `2.0.0-alpha.223`. |
| 130 | +// |
| 131 | +// Other packages — including `@elizaos/app-task-coordinator`, |
| 132 | +// `@elizaos/app-companion`, `@elizaos/app-lifeops`, `@elizaos/app-training` |
| 133 | +// — are imported by `api/server.ts` as named functions (e.g. |
| 134 | +// `wireCoordinatorBridgesWhenReady`). Stubbing them with a Proxy doesn't |
| 135 | +// satisfy Bun's `__toESM` namespace builder (it iterates `ownKeys`), so we |
| 136 | +// let them bundle. The mobile plugin filter still strips them out of the |
| 137 | +// runtime load set, so they don't try to register at boot. |
| 138 | +const optionalPluginStubs = { |
| 139 | + "@elizaos/plugin-cron": path.join(stubsDir, "null-plugin.cjs"), |
| 140 | + "@elizaos/plugin-cli": path.join(stubsDir, "null-plugin.cjs"), |
| 141 | +}; |
| 142 | + |
| 143 | +const stubAliases = { ...nativeStubs, ...optionalPluginStubs }; |
| 144 | + |
| 145 | +const stubResolverPlugin = { |
| 146 | + name: "milady-mobile-stubs", |
| 147 | + setup(build) { |
| 148 | + const aliasNames = Object.keys(stubAliases); |
| 149 | + const filter = new RegExp( |
| 150 | + "^(?:" + |
| 151 | + aliasNames |
| 152 | + .map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) |
| 153 | + .join("|") + |
| 154 | + ")(?:/.*)?$", |
| 155 | + ); |
| 156 | + build.onResolve({ filter }, (args) => { |
| 157 | + // Match the longest alias that's a prefix of the importer. |
| 158 | + let best = null; |
| 159 | + for (const name of aliasNames) { |
| 160 | + if ( |
| 161 | + (args.path === name || args.path.startsWith(`${name}/`)) && |
| 162 | + (best === null || name.length > best.length) |
| 163 | + ) { |
| 164 | + best = name; |
| 165 | + } |
| 166 | + } |
| 167 | + if (best === null) return undefined; |
| 168 | + return { path: stubAliases[best], namespace: "file" }; |
| 169 | + }); |
| 170 | + }, |
| 171 | +}; |
| 172 | + |
| 173 | +// Force a single resolution for `@elizaos/core` and `@elizaos/shared`. |
| 174 | +// |
| 175 | +// `eliza/packages/agent/tsconfig.json` maps `@elizaos/core` to the source |
| 176 | +// at `../typescript/src/index.node.ts`, but `@elizaos/plugin-sql` (and other |
| 177 | +// plugin packages) compile against the prebuilt `dist/index.node.js`. Bun |
| 178 | +// then bundles BOTH copies, ending up with two distinct AgentRuntime classes |
| 179 | +// — the runtime instance receives an adapter from one copy and tries to |
| 180 | +// call methods that only exist on the other (`getAgentsByIds is not a |
| 181 | +// function`). Pin every `@elizaos/core` (and `@elizaos/shared`) import to |
| 182 | +// the same workspace `src/` entry so the bundle ships exactly one identity. |
| 183 | +const corePackages = [ |
| 184 | + "@elizaos/core", |
| 185 | + "@elizaos/shared", |
| 186 | + "@elizaos/plugin-sql", |
| 187 | +]; |
| 188 | + |
| 189 | +const dedupeTargets = { |
| 190 | + "@elizaos/core": path.resolve( |
| 191 | + repoRoot, |
| 192 | + "eliza", |
| 193 | + "packages", |
| 194 | + "typescript", |
| 195 | + "src", |
| 196 | + "index.node.ts", |
| 197 | + ), |
| 198 | + "@elizaos/shared": path.resolve( |
| 199 | + repoRoot, |
| 200 | + "eliza", |
| 201 | + "packages", |
| 202 | + "shared", |
| 203 | + "src", |
| 204 | + "index.ts", |
| 205 | + ), |
| 206 | + // Pin plugin-sql to its src as well. The published `dist/node/index.node.js` |
| 207 | + // was compiled against an older `@elizaos/core` API (pre-`getAgentsByIds`), |
| 208 | + // so the bundled `BaseDrizzleAdapter` is missing methods the current runtime |
| 209 | + // depends on. Building from src against the same `@elizaos/core` source the |
| 210 | + // runtime uses keeps the adapter and the runtime in lockstep. |
| 211 | + "@elizaos/plugin-sql": path.resolve( |
| 212 | + repoRoot, |
| 213 | + "eliza", |
| 214 | + "plugins", |
| 215 | + "plugin-sql", |
| 216 | + "typescript", |
| 217 | + "index.node.ts", |
| 218 | + ), |
| 219 | +}; |
| 220 | + |
| 221 | +for (const [pkg, target] of Object.entries(dedupeTargets)) { |
| 222 | + if (!existsSync(target)) { |
| 223 | + console.error( |
| 224 | + `[build-mobile] FATAL: dedupe target for ${pkg} not found: ${target}`, |
| 225 | + ); |
| 226 | + process.exit(1); |
| 227 | + } |
| 228 | +} |
| 229 | + |
| 230 | +const dedupePlugin = { |
| 231 | + name: "milady-mobile-core-dedupe", |
| 232 | + setup(build) { |
| 233 | + const filter = new RegExp( |
| 234 | + "^(?:" + |
| 235 | + corePackages |
| 236 | + .map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) |
| 237 | + .join("|") + |
| 238 | + ")$", |
| 239 | + ); |
| 240 | + build.onResolve({ filter }, (args) => { |
| 241 | + const target = dedupeTargets[args.path]; |
| 242 | + if (!target) return undefined; |
| 243 | + return { path: target, namespace: "file" }; |
| 244 | + }); |
| 245 | + }, |
| 246 | +}; |
| 247 | + |
| 248 | +console.log("[build-mobile] starting Bun.build..."); |
| 249 | +const buildResult = await Bun.build({ |
| 250 | + entrypoints: [entry], |
| 251 | + outdir: outDir, |
| 252 | + naming: "agent-bundle.js", |
| 253 | + target: "bun", |
| 254 | + format: "esm", |
| 255 | + // Don't minify. Bundling is already significant — this is a debugging step |
| 256 | + // to keep stack traces readable. Re-enable selectively if APK size matters. |
| 257 | + minify: false, |
| 258 | + define: { |
| 259 | + "process.env.MILADY_PLATFORM": JSON.stringify("android"), |
| 260 | + // Disable the `isDirectRun` self-invocation guard in the agent's |
| 261 | + // `runtime/eliza.ts`. After bundling, `import.meta.url` and |
| 262 | + // `process.argv[1]` both resolve to the same bundle path, so the guard |
| 263 | + // (intended to let `bun runtime/eliza.ts` run standalone) fires when the |
| 264 | + // CLI ALSO drives `startEliza`. Two concurrent boots fight over the API |
| 265 | + // port and the second one's stdin-driven chat REPL exits on EOF, taking |
| 266 | + // the whole process down. Defining the marker as `false` flattens the |
| 267 | + // branch at build time. |
| 268 | + "process.env.MILADY_DISABLE_DIRECT_RUN": JSON.stringify("1"), |
| 269 | + }, |
| 270 | + plugins: [dedupePlugin, stubResolverPlugin], |
| 271 | +}); |
| 272 | + |
| 273 | +if (!buildResult.success) { |
| 274 | + console.error("[build-mobile] Bun.build failed:"); |
| 275 | + for (const log of buildResult.logs) { |
| 276 | + console.error(" ", log.level, log.message, log.position); |
| 277 | + } |
| 278 | + process.exit(1); |
| 279 | +} |
| 280 | + |
| 281 | +const bundlePath = path.join(outDir, "agent-bundle.js"); |
| 282 | +if (!existsSync(bundlePath)) { |
| 283 | + console.error( |
| 284 | + "[build-mobile] FATAL: agent-bundle.js not produced at", |
| 285 | + bundlePath, |
| 286 | + ); |
| 287 | + console.error( |
| 288 | + "[build-mobile] outputs reported:", |
| 289 | + buildResult.outputs.map((o) => o.path), |
| 290 | + ); |
| 291 | + process.exit(1); |
| 292 | +} |
| 293 | +const bundleSize = (await stat(bundlePath)).size; |
| 294 | +console.log( |
| 295 | + `[build-mobile] bundle size: ${(bundleSize / 1024 / 1024).toFixed(2)} MB`, |
| 296 | +); |
| 297 | + |
| 298 | +// Copy PGlite assets next to the bundle. The bundle's `import.meta.url` will |
| 299 | +// resolve to its location at runtime, and `new URL("./pglite.wasm", ...)` |
| 300 | +// lands here. |
| 301 | +for (const asset of ["pglite.wasm", "pglite.data"]) { |
| 302 | + const src = path.join(pgliteDist, asset); |
| 303 | + if (!existsSync(src)) { |
| 304 | + console.error(`[build-mobile] FATAL: missing ${asset} in ${pgliteDist}`); |
| 305 | + process.exit(1); |
| 306 | + } |
| 307 | + await copyFile(src, path.join(outDir, asset)); |
| 308 | + const sz = (await stat(src)).size; |
| 309 | + console.log( |
| 310 | + `[build-mobile] copied ${asset} (${(sz / 1024 / 1024).toFixed(2)} MB)`, |
| 311 | + ); |
| 312 | +} |
| 313 | + |
| 314 | +// Copy contrib extension tarballs. They live one dir above the bundle on |
| 315 | +// device (Phase A handles placement); we surface them in dist-mobile/ so the |
| 316 | +// asset pipeline can pick them up. |
| 317 | +for (const asset of ["vector.tar.gz", "fuzzystrmatch.tar.gz"]) { |
| 318 | + const src = path.join(pgliteDist, asset); |
| 319 | + if (!existsSync(src)) { |
| 320 | + console.error(`[build-mobile] FATAL: missing ${asset} in ${pgliteDist}`); |
| 321 | + process.exit(1); |
| 322 | + } |
| 323 | + await copyFile(src, path.join(outDir, asset)); |
| 324 | + const sz = (await stat(src)).size; |
| 325 | + console.log(`[build-mobile] copied ${asset} (${(sz / 1024).toFixed(1)} KB)`); |
| 326 | +} |
| 327 | + |
| 328 | +const manifest = { |
| 329 | + generatedAt: new Date().toISOString(), |
| 330 | + bundle: "agent-bundle.js", |
| 331 | + bunTarget: "bun", |
| 332 | + platform: "android", |
| 333 | + pglite: { |
| 334 | + wasm: "pglite.wasm", |
| 335 | + data: "pglite.data", |
| 336 | + extensions: { |
| 337 | + vector: { file: "vector.tar.gz", expectedAt: "../vector.tar.gz" }, |
| 338 | + fuzzystrmatch: { |
| 339 | + file: "fuzzystrmatch.tar.gz", |
| 340 | + expectedAt: "../fuzzystrmatch.tar.gz", |
| 341 | + }, |
| 342 | + }, |
| 343 | + }, |
| 344 | + plugins: { |
| 345 | + core: ["@elizaos/plugin-sql"], |
| 346 | + optional: [ |
| 347 | + "@elizaos/plugin-anthropic", |
| 348 | + "@elizaos/plugin-openai", |
| 349 | + "@elizaos/plugin-ollama", |
| 350 | + "@elizaos/plugin-elizacloud", |
| 351 | + ], |
| 352 | + }, |
| 353 | + externalsAsStubs: Object.keys(stubAliases), |
| 354 | + notes: [ |
| 355 | + "All listed plugins are bundled via static imports in", |
| 356 | + " eliza/packages/agent/src/runtime/eliza.ts (STATIC_ELIZA_PLUGINS).", |
| 357 | + "The mobile runtime substitutes MOBILE_CORE_PLUGINS for CORE_PLUGINS", |
| 358 | + "when MILADY_PLATFORM=android.", |
| 359 | + ], |
| 360 | +}; |
| 361 | +await writeFile( |
| 362 | + path.join(outDir, "plugins-manifest.json"), |
| 363 | + JSON.stringify(manifest, null, 2), |
| 364 | +); |
| 365 | +console.log("[build-mobile] wrote plugins-manifest.json"); |
| 366 | + |
| 367 | +console.log("[build-mobile] done."); |
| 368 | +console.log("[build-mobile] outputs:"); |
| 369 | +for (const file of (await readdir(outDir)).sort()) { |
| 370 | + const s = await stat(path.join(outDir, file)); |
| 371 | + console.log(` ${file.padEnd(28)} ${(s.size / 1024).toFixed(1)} KB`); |
| 372 | +} |
0 commit comments