Skip to content

Commit aaaecf3

Browse files
committed
perf(electron): ship optional ML/browser deps as installable packs\n\nStage 7 of the #10321 desktop efficiency roadmap: the optional ML and\nbrowser automation dependency closures (~130.8 MB compressed by the\nissue's baseline estimate) no longer ship inside the desktop bundle.\nPackaging stages them as checksummed, versioned packs that users\ninstall on demand via the new omniroute packs command group.\n\n- scripts/build/optionalPackStaging.mjs: manifest-driven staging pass\n that relocates pack members out of the Electron standalone tree into\n .build/optional-packs/ (plus gzip tarballs) and emits an\n optional-packs.index.json with per-member sha256 checksums; fails\n open per member so a missing optional dep never breaks packaging\n- scripts/packs/optionalPackManifest.mjs: pack definitions (ml-runtime,\n browser-runtime), streaming directory checksums, index entry\n build/verify helpers\n- scripts/packs/optionalPackInstaller.mjs: atomic install/remove/verify\n against the shipped index, sourcing release tarballs first\n- bin/cli/commands/packs.mjs: omniroute packs list|install|verify|remove\n (+ en locale strings)\n- open-sse/utils/optionalPacks.ts: runtime presence probes shared by\n gates and consumers; the LLMLingua worker gate now also probes\n installed pack trees before reporting the engine unavailable\n- electron/main.js: prepends installed pack node_modules dirs to the\n spawned server's NODE_PATH so packs resolve without living in the\n bundle\n\nStandalone staging-tree measurement (darwin-arm64): the closure is\n~534 MB of the 929 MB standalone node_modules (57%). Runtime behavior\nis unchanged for users who never install packs: LLMLingua and web\nexecutors already degrade gracefully when their deps are absent.
1 parent 0bd2be0 commit aaaecf3

16 files changed

Lines changed: 1564 additions & 3 deletions

File tree

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2326,6 +2326,12 @@ APP_LOG_TO_FILE=true
23262326
# intended to be published as `omniroute-secure`. See SECURITY.md.
23272327
# OMNIROUTE_BUILD_PROFILE=full
23282328

2329+
# Skip emitting `.tar.gz` tarballs during optional-pack staging for the Electron
2330+
# standalone tree (pack directories + optional-packs.index.json are still produced).
2331+
# Used by the desktop release workflow to trim artifact upload size.
2332+
# Default (when unset): 1 (tarballs emitted). Set to 0 to disable.
2333+
# OMNIROUTE_OPTIONAL_PACK_TAR=1
2334+
23292335
# Electron smoke harness (used by scripts/dev/smoke-electron-packaged.mjs).
23302336
# ELECTRON_SMOKE_URL=http://127.0.0.1:20128/login
23312337
# ELECTRON_SMOKE_TIMEOUT_MS=45000

bin/cli/commands/packs.mjs

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import path from "node:path";
2+
import { fileURLToPath } from "node:url";
3+
4+
import { t } from "../i18n.mjs";
5+
import { resolveDataDir } from "../data-dir.mjs";
6+
import {
7+
EXIT_CODES,
8+
emit,
9+
exitWith,
10+
printError,
11+
printInfo,
12+
printSuccess,
13+
printWarning,
14+
} from "../output.mjs";
15+
import { findPack } from "../../../scripts/packs/optionalPackManifest.mjs";
16+
import {
17+
findPackIndexFile,
18+
installPack,
19+
listPackStates,
20+
packState,
21+
packsRoot,
22+
readPackIndex,
23+
removePack,
24+
} from "../../../scripts/packs/optionalPackInstaller.mjs";
25+
26+
const CLI_DIR = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
27+
28+
/**
29+
* Locate + parse the bundle-shipped `optional-packs.index.json`.
30+
* Search order: explicit --source dir, then walking up from the CLI module
31+
* (bundle installs keep the index at the bundle root), then cwd.
32+
*/
33+
function loadIndex(sourceDir) {
34+
const indexFile = findPackIndexFile([sourceDir, CLI_DIR, process.cwd()]);
35+
if (!indexFile) return { indexFile: null, index: null };
36+
return { indexFile, index: readPackIndex(indexFile) };
37+
}
38+
39+
function stateRow(state, dataDir) {
40+
return {
41+
pack: state.name,
42+
packVersion: state.packVersion,
43+
installed: state.installed ? "yes" : "no",
44+
verified: state.verified === null ? "-" : state.verified ? "ok" : "FAILED",
45+
members: state.members.length,
46+
installDir: path.join(packsRoot(dataDir), state.name),
47+
errors: state.errors ?? [],
48+
};
49+
}
50+
51+
const STATE_SCHEMA = [
52+
{ key: "pack", header: "pack" },
53+
{ key: "packVersion", header: "packVersion" },
54+
{ key: "installed", header: "installed" },
55+
{ key: "verified", header: "verified" },
56+
{ key: "members", header: "members" },
57+
];
58+
59+
async function run(action) {
60+
try {
61+
await action();
62+
} catch (err) {
63+
exitWith(EXIT_CODES.ERROR, err instanceof Error ? err.message : String(err));
64+
}
65+
}
66+
67+
export function registerPacks(program) {
68+
const packs = program.command("packs").description(t("packs.description"));
69+
70+
packs
71+
.command("list")
72+
.description(t("packs.listDescription"))
73+
.option("--source <dir>", t("packs.sourceOpt"))
74+
.action(async (opts) => {
75+
await run(async () => {
76+
const dataDir = resolveDataDir();
77+
const { index } = loadIndex(opts.source);
78+
emit(
79+
(await listPackStates({ dataDir, index })).map((s) => stateRow(s, dataDir)),
80+
opts,
81+
STATE_SCHEMA
82+
);
83+
if (!index) printWarning(t("packs.warnNoIndex"));
84+
});
85+
});
86+
87+
packs
88+
.command("install <name>")
89+
.description(t("packs.installDescription"))
90+
.option("--source <dir>", t("packs.sourceOpt"))
91+
.action(async (name, opts) => {
92+
await run(async () => {
93+
if (!findPack(name)) exitWith(EXIT_CODES.INVALID_ARG, t("packs.errUnknown", { name }));
94+
const { indexFile, index } = loadIndex(opts.source);
95+
if (!index) exitWith(EXIT_CODES.ERROR, t("packs.errNoIndex"));
96+
const dataDir = resolveDataDir();
97+
// The payload (tarball or extracted pack dir) lives next to the index
98+
// unless the caller pointed elsewhere via --source.
99+
await installPack(name, {
100+
dataDir,
101+
index,
102+
sourceDir: opts.source || path.dirname(indexFile),
103+
log: (msg) => printInfo(msg.replace(/^\[optional-packs\]\s*/, "")),
104+
});
105+
const installDir = path.join(packsRoot(dataDir), name);
106+
printSuccess(t("packs.installed", { name, dir: installDir }));
107+
printInfo(t("packs.restartHint"));
108+
emit({ pack: name, installed: "yes", verified: "ok", installDir }, opts, STATE_SCHEMA);
109+
});
110+
});
111+
112+
packs
113+
.command("verify [name]")
114+
.description(t("packs.verifyDescription"))
115+
.option("--source <dir>", t("packs.sourceOpt"))
116+
.action(async (name, opts) => {
117+
await run(async () => {
118+
if (name && !findPack(name))
119+
exitWith(EXIT_CODES.INVALID_ARG, t("packs.errUnknown", { name }));
120+
const { index } = loadIndex(opts.source);
121+
if (!index) exitWith(EXIT_CODES.ERROR, t("packs.errNoIndex"));
122+
const dataDir = resolveDataDir();
123+
const states = name
124+
? [await packState(name, { dataDir, index })]
125+
: await listPackStates({ dataDir, index });
126+
emit(
127+
states.map((s) => stateRow(s, dataDir)),
128+
opts,
129+
STATE_SCHEMA
130+
);
131+
const broken = states.filter((s) => s.installed && s.verified !== true);
132+
if (broken.length > 0) {
133+
for (const state of broken) {
134+
for (const error of state.errors ?? []) printError(`${state.name}: ${error}`);
135+
}
136+
exitWith(EXIT_CODES.ERROR, t("packs.verifyFailed", { count: broken.length }));
137+
}
138+
if (!states.some((s) => s.installed)) {
139+
printInfo(t("packs.noneInstalled"));
140+
return;
141+
}
142+
printSuccess(t("packs.verifyOk"));
143+
});
144+
});
145+
146+
packs
147+
.command("remove <name>")
148+
.description(t("packs.removeDescription"))
149+
.action(async (name, opts) => {
150+
await run(async () => {
151+
if (!findPack(name)) exitWith(EXIT_CODES.INVALID_ARG, t("packs.errUnknown", { name }));
152+
const dataDir = resolveDataDir();
153+
const removed = removePack(name, {
154+
dataDir,
155+
log: (msg) => printInfo(msg.replace(/^\[optional-packs\]\s*/, "")),
156+
});
157+
if (removed) {
158+
printSuccess(t("packs.removed", { name }));
159+
printInfo(t("packs.restartHint"));
160+
} else {
161+
printInfo(t("packs.notInstalled", { name }));
162+
}
163+
emit({ pack: name, installed: removed ? "no" : "no" }, opts, STATE_SCHEMA);
164+
});
165+
});
166+
}

bin/cli/commands/registry.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ import { registerTokens } from "./tokens.mjs";
7878
import { registerConfigure } from "./configure.mjs";
7979
import { registerApiCommands } from "../api-commands/registry.mjs";
8080
import { registerPlugin } from "./plugin.mjs";
81+
import { registerPacks } from "./packs.mjs";
8182

8283
export function registerCommands(program) {
8384
registerMemory(program);
@@ -161,4 +162,5 @@ export function registerCommands(program) {
161162
registerConfigure(program);
162163
registerApiCommands(program);
163164
registerPlugin(program);
165+
registerPacks(program);
164166
}

bin/cli/locales/en.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1299,5 +1299,23 @@
12991299
},
13001300
"setupCodex": {
13011301
"description": "Generate ~/.codex profile files from OmniRoute live model catalog"
1302+
},
1303+
"packs": {
1304+
"description": "Manage optional runtime packs (ML / browser automation)",
1305+
"listDescription": "List optional packs and their install state",
1306+
"installDescription": "Install an optional pack into DATA_DIR",
1307+
"verifyDescription": "Verify installed packs against the shipped checksum index",
1308+
"removeDescription": "Remove an installed optional pack",
1309+
"sourceOpt": "Directory holding pack payloads and the pack index",
1310+
"warnNoIndex": "optional-packs.index.json not found — install/verify are unavailable in this checkout (desktop bundles ship it)",
1311+
"errUnknown": "unknown pack: {name}",
1312+
"errNoIndex": "pack index not found; pass --source <dir> holding the pack payload (desktop bundles ship it next to the app)",
1313+
"installed": "pack \"{name}\" installed and verified at {dir}",
1314+
"restartHint": "restart the OmniRoute server (or desktop app) so the runtime picks the pack up",
1315+
"removed": "pack \"{name}\" removed",
1316+
"notInstalled": "pack \"{name}\" was not installed",
1317+
"verifyOk": "all installed packs verified",
1318+
"verifyFailed": "{count} pack(s) failed verification",
1319+
"noneInstalled": "no optional packs installed"
13021320
}
13031321
}

docs/reference/ENVIRONMENT.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1479,6 +1479,7 @@ These settings were introduced after the previous environment-contract snapshot.
14791479
| `TELEGRAM_DEFAULT_MODEL` | `auto/chat` | `src/lib/telegram/chatProxy.ts` | Model used for Telegram chat replies. |
14801480
| `TELEGRAM_BOT_API_BASE` | `https://api.telegram.org` | `src/lib/telegram/config.ts` | Bot API base URL override for proxies or self-hosted Bot API servers. |
14811481
| `TELEGRAM_WEBHOOK_TIMEOUT_MS` | `60000` | `src/lib/telegram/config.ts` | Timeout in milliseconds for outbound Bot API calls. |
1482+
| `OMNIROUTE_OPTIONAL_PACK_TAR` | `1` (enabled) | `scripts/build/optionalPackStaging.mjs` | Set `0` to skip emitting `.tar.gz` tarballs while staging optional ML/browser packs for the Electron standalone tree (pack directories and `optional-packs.index.json` are still produced). Used by the desktop release workflow to trim artifact upload size. |
14821483
### ChatGPT Web (Codex)
14831484

14841485
Globale Defaults für den headless Browser und den ausgehenden Tool-Tunnel. Im Dashboard gesetzte Connection-Werte haben Vorrang.

electron/main.js

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,32 @@ function resolveNodeExecutable(env = process.env) {
112112
return process.execPath;
113113
}
114114

115-
function resolveServerNodePath(env = process.env) {
115+
// Stage 7 (issue #10321): optional runtime packs are installed under
116+
// `${DATA_DIR}/packs/<name>/node_modules` (see open-sse/utils/optionalPacks.ts —
117+
// this is the plain-JS mirror; keep semantics identical). Prepending their
118+
// node_modules to NODE_PATH lets the server's dynamic imports (playwright, the
119+
// LLMLingua closure) resolve pack members while the default bundle stays slim.
120+
function resolvePackNodePaths(dataDir) {
121+
const packsRoot = path.join(dataDir, "packs");
122+
let names;
123+
try {
124+
names = fs.readdirSync(packsRoot);
125+
} catch {
126+
return []; // No packs dir yet — nothing installed.
127+
}
128+
const dirs = [];
129+
for (const name of names) {
130+
const candidate = path.join(packsRoot, name, "node_modules");
131+
try {
132+
if (fs.statSync(candidate).isDirectory()) dirs.push(candidate);
133+
} catch {
134+
// Unreadable entry — treat as not installed.
135+
}
136+
}
137+
return dirs;
138+
}
139+
140+
function resolveServerNodePath(env = process.env, extraDirs = []) {
116141
const seen = new Set();
117142
const entries = [];
118143

@@ -134,6 +159,12 @@ function resolveServerNodePath(env = process.env) {
134159
addEntry(existing);
135160
}
136161

162+
// Optional packs take precedence over bundle-resident copies so an installed
163+
// pack can never be shadowed by a stale bundled duplicate.
164+
for (const packDir of extraDirs) {
165+
addEntry(packDir);
166+
}
167+
137168
// Electron-builder installs native modules like better-sqlite3 under
138169
// app.asar.unpacked, while the standalone bundle still carries helper deps
139170
// such as bindings/file-uri-to-path inside resources/app/node_modules.
@@ -770,7 +801,7 @@ function startNextServer() {
770801
PORT: String(serverPort),
771802
NODE_ENV: "production",
772803
ELECTRON_RUN_AS_NODE: "1",
773-
NODE_PATH: resolveServerNodePath(serverEnv),
804+
NODE_PATH: resolveServerNodePath(serverEnv, resolvePackNodePaths(dataDir)),
774805
NODE_OPTIONS: serverNodeOptions,
775806
},
776807
stdio: "pipe",

open-sse/services/compression/engines/llmlingua/worker.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { pathToFileURL } from "node:url";
3737

3838
import { LLMLINGUA_WORKER_TIMEOUT_MS, LLMLINGUA_WORKER_IDLE_MS } from "./constants.ts";
3939
import { resolveLlmlinguaModel } from "./modelStore.ts";
40+
import { packMemberInstalled } from "../../../../utils/optionalPacks.ts";
4041
import type { LlmlinguaBackend } from "./index.ts";
4142

4243
/** One-time model-load budget on the first call for a given model (tinybert ~2s, bert-base ~27s). */
@@ -121,7 +122,12 @@ let _depsAvailable: boolean | null = null;
121122
*/
122123
export function depsAvailable(): boolean {
123124
if (_depsAvailable !== null) return _depsAvailable;
124-
_depsAvailable = firstAncestorWith(runtimeAnchors(), GATE_DEP_REL) !== null;
125+
// Stage 7 (issue #10321): the desktop bundle ships the LLMLingua closure as an
126+
// optional pack installed under `${DATA_DIR}/packs/ml-runtime/node_modules`
127+
// (prepended to NODE_PATH by electron/main.js), so also probe the pack dirs —
128+
// the ancestor walk only covers bundle-resident installs (npm/Docker).
129+
_depsAvailable =
130+
firstAncestorWith(runtimeAnchors(), GATE_DEP_REL) !== null || packMemberInstalled(GATE_DEP_REL);
125131
return _depsAvailable;
126132
}
127133

open-sse/utils/optionalPacks.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* Optional runtime pack resolution (Stage 7 of the Electron efficiency roadmap,
3+
* issue #10321).
4+
*
5+
* The desktop bundle ships WITHOUT the heavy optional ML/browser dependency
6+
* closure; users install versioned packs (`omniroute packs install ml-runtime`)
7+
* into `${DATA_DIR}/packs/<name>/node_modules`. `electron/main.js` prepends
8+
* those directories to the spawned server's NODE_PATH, which is how dynamic
9+
* imports (`await import("playwright")`, the LLMLingua worker) resolve pack
10+
* members at runtime.
11+
*
12+
* This module is the runtime side and deliberately does NOT import the
13+
* build-side manifest (`scripts/packs/optionalPackManifest.mjs`) — the
14+
* standalone server must stay decoupled from build tooling. It embeds only the
15+
* pack names and the index filename.
16+
*
17+
* Fail-open: every helper returns "absent" rather than throwing, so a missing
18+
* or corrupt pack degrades the optional feature instead of the server.
19+
*/
20+
21+
import os from "node:os";
22+
import path from "node:path";
23+
import fs from "node:fs";
24+
25+
/** Pack names — must match OPTIONAL_PACKS in scripts/packs/optionalPackManifest.mjs. */
26+
export const OPTIONAL_PACK_NAMES = ["ml-runtime", "browser-runtime"] as const;
27+
28+
export type OptionalPackName = (typeof OPTIONAL_PACK_NAMES)[number];
29+
30+
/** Index filename — must match PACK_INDEX_FILENAME in the manifest module. */
31+
export const PACK_INDEX_FILENAME = "optional-packs.index.json";
32+
33+
/** Resolve DATA_DIR exactly like the rest of the runtime (modelStore.ts precedent). */
34+
function resolveDataDir(override?: string): string {
35+
return override || process.env.DATA_DIR || path.join(os.homedir(), ".omniroute");
36+
}
37+
38+
/** `${DATA_DIR}/packs` — root of installed packs. */
39+
export function packsRootDir(dataDirOverride?: string): string {
40+
return path.join(resolveDataDir(dataDirOverride), "packs");
41+
}
42+
43+
/** Install dir for one pack: `${DATA_DIR}/packs/<name>` (contains node_modules/). */
44+
export function packInstallDir(name: string, dataDirOverride?: string): string {
45+
return path.join(packsRootDir(dataDirOverride), name);
46+
}
47+
48+
/** `node_modules` dir of an installed pack, whether or not it exists. */
49+
export function packNodeModulesDir(name: string, dataDirOverride?: string): string {
50+
return path.join(packInstallDir(name, dataDirOverride), "node_modules");
51+
}
52+
53+
/**
54+
* NODE_PATH entries for every INSTALLED pack (manifest order, deterministic).
55+
* `electron/main.js` consumes this via its own plain-JS mirror — keep the
56+
* semantics identical (existence check, no throw).
57+
*/
58+
export function installedPackNodePaths(dataDirOverride?: string): string[] {
59+
const entries: string[] = [];
60+
for (const name of OPTIONAL_PACK_NAMES) {
61+
const dir = packNodeModulesDir(name, dataDirOverride);
62+
try {
63+
if (fs.statSync(dir).isDirectory()) entries.push(dir);
64+
} catch {
65+
// Not installed (or unreadable) — absent, not an error.
66+
}
67+
}
68+
return entries;
69+
}
70+
71+
/**
72+
* Probe a pack member by its path relative to a `node_modules` root, e.g.
73+
* `@atjsh/llmlingua-2/package.json`. Checks every installed pack first, so an
74+
* installed pack lights the feature up even though the bundle tree (walked by
75+
* the legacy firstAncestorWith gate) no longer carries the member.
76+
*/
77+
export function packMemberInstalled(memberRelPath: string, dataDirOverride?: string): boolean {
78+
for (const nodeModulesDir of installedPackNodePaths(dataDirOverride)) {
79+
// Accept both separators: callers build rel paths with path.join (Windows
80+
// yields backslashes) or forward slashes (portable constants).
81+
if (fs.existsSync(path.join(nodeModulesDir, ...memberRelPath.split(/[\\/]/)))) return true;
82+
}
83+
return false;
84+
}

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040
"scripts/build/native-binary-compat.mjs",
4141
"scripts/build/build-next-isolated.mjs",
4242
"scripts/build/runtime-env.mjs",
43+
"scripts/packs/optionalPackManifest.mjs",
44+
"scripts/packs/optionalPackInstaller.mjs",
4345
"README.md",
4446
"LICENSE",
4547
"!**/node_modules/**",

0 commit comments

Comments
 (0)