create-op-node 0.12.4 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,18 +32,21 @@ Configures macOS power settings, installs Homebrew + the CLI tool list, sets up
32
32
 
33
33
  When you run `bootstrap` interactively (no `-y`, no `--llm-model` flag),
34
34
  you'll get a curated picker that **pre-selects** a model based on the
35
- Studio's detected unified memory:
36
-
37
- - `qwen2.5:72b` — 72B, ~50 GB Ollama RAM. Pre-selected on 96 GB+ Studios.
38
- Best Spanish + multilingual quality.
39
- - `qwen2.5:32b` — 32B, ~22 GB Ollama RAM. Pre-selected on 48–95 GB Studios
40
- (e.g. 64 GB).
41
- - `qwen3.5:9b` — 9B, ~8 GB Ollama RAM. Pre-selected on smaller Studios
42
- (< 48 GB), and the default for non-interactive `-y` runs (see below).
35
+ node's detected unified memory. Tiers are conservative — the LLM shares
36
+ memory with ~22 containers + Postgres, so they size on *total* footprint,
37
+ not param count:
38
+
39
+ - `qwen3.6:35b-a3b` — 35B MoE (~3B active, fast on Apple Silicon),
40
+ ~24 GB footprint. Pre-selected on 64 GB+ nodes. Best Spanish +
41
+ multilingual quality.
42
+ - `qwen3:14b` ~9 GB footprint. Pre-selected on 32–64 GB nodes.
43
+ - `qwen2.5:7b` — ~5 GB footprint. Pre-selected on 16–32 GB nodes, and
44
+ the default for non-interactive `-y` runs (see below).
45
+ - `qwen2.5:3b` — ~2–3 GB footprint. Pre-selected on ≤ 16 GB nodes (Mac Mini).
43
46
  - `Other…` — any Ollama model name you specify.
44
47
 
45
- (If memory detection fails, the picker falls back to pre-selecting
46
- `qwen2.5:72b`.)
48
+ (If memory detection fails, the picker falls back to pre-selecting the
49
+ conservative scripted default, `qwen2.5:7b`.)
47
50
 
48
51
  The picker is Qwen-only because the Opus Populi platform serves
49
52
  Spanish-speaking civic users and Qwen has the strongest Spanish (and
@@ -51,7 +54,7 @@ broader multilingual) capability of the open-weight models in this
51
54
  size class. Pick "Other…" for non-Qwen models.
52
55
 
53
56
  For non-interactive runs (`-y`) or scripted invocations, the LLM
54
- defaults to `qwen3.5:9b` (small, fast) — pass `--llm-model` to override:
57
+ defaults to `qwen2.5:7b` (small, fast) — pass `--llm-model` to override:
55
58
 
56
59
  ```bash
57
60
  # Just swap the LLM, keep the default embedding model:
@@ -69,29 +72,43 @@ The chosen models flow two places:
69
72
  1. **Ollama**: bootstrap pulls + warms them so the daemon has them
70
73
  resident before the stack comes up. The embedding model pulls first
71
74
  (small, fast feedback); the LLM pulls second (can be tens of GB).
72
- 2. **LaunchAgent**: the plist exports `LLM_MODEL` and `EMBEDDINGS_MODEL`
73
- into the launchd session, which Docker Desktop inherits so compose
74
- services read them via env, no `.env.production` edits needed.
75
-
76
- > **`--embedding-model` only takes effect when the knowledge service
77
- > runs with `EMBEDDINGS_PROVIDER=ollama`.** The default provider is
78
- > `xenova` (in-process), which bundles its own embedding model and
79
- > ignores both `EMBEDDINGS_MODEL` and the local Ollama model. Setting
80
- > `EMBEDDINGS_PROVIDER=ollama` is a separate decision (set in your
81
- > region repo's `.env.production`) see `docs/provider-pattern.md`.
82
-
83
- > **Template contract**: for `--llm-model` to actually change the
84
- > running model, the region repo's `docker-compose-prod.yml` must use
85
- > `${LLM_MODEL:-qwen3.5:9b}` (or similar) on the knowledge service's
86
- > `environment:` block. The current `opuspopuli-node` template does;
87
- > a fork that hardcodes the value would ignore the flag silently.
75
+ 2. **The node `.env`**: bootstrap writes `LLM_MODEL`, `EMBEDDINGS_MODEL`,
76
+ and `EMBEDDINGS_PROVIDER` into a managed block of the region repo's
77
+ `.env`, which docker compose auto-loads. This is the **single source of
78
+ truth** — every service (and every future partial recreate) resolves
79
+ `${LLM_MODEL:-…}` to the same value, regardless of shell/launchd env.
80
+ Model config is deliberately *not* exported via the LaunchAgent: a
81
+ `launchctl setenv` value would shadow `.env` at interpolation time
82
+ (shell env > `.env`), reintroducing the exact drift the `.env` prevents.
83
+
84
+ > **Managed block, not full ownership.** bootstrap owns only the region
85
+ > between `# >>> op-node managed >>>` and `# <<< op-node managed <<<` in
86
+ > your `.env` anything you hand-add outside it is preserved. On first
87
+ > run against a pre-existing `.env`, your existing model values are adopted
88
+ > into the block rather than overwritten; the block is only rewritten when
89
+ > you explicitly (re)select a model. Secrets never go in `.env` they stay
90
+ > in the macOS Keychain and are hydrated by `bin/op-compose`.
91
+
92
+ > **`--embeddings-provider` picks where embeddings run.** The default is
93
+ > `xenova` (in-process), which bundles its own embedding model and ignores
94
+ > `EMBEDDINGS_MODEL`. Pass `--embeddings-provider ollama` to use the host
95
+ > daemon with `--embedding-model` — the value is written to `.env` as the
96
+ > single source of truth. See `docs/provider-pattern.md`.
97
+
98
+ > **Template contract**: for `LLM_MODEL` to actually change the running
99
+ > model, the region repo's `docker-compose-prod.yml` must use
100
+ > `${LLM_MODEL:-…}` on the knowledge service's `environment:` block. The
101
+ > current `opuspopuli-node` template does; a fork that hardcodes the value
102
+ > would ignore the `.env` silently.
88
103
 
89
104
  For RAM sizing, the [Docker resources doc](https://github.com/OpusPopuli/opuspopuli-node/blob/main/docs/docker-resources.md)
90
- has a tier table: 9B-class needs ~8 GB Ollama; 70B-class needs ~50 GB;
91
- frontier MoE needs ~80 GB. Allocate Docker the remainder.
105
+ has a tier table: a 7B-class model needs ~5 GB, the 35B-a3b MoE ~24 GB.
106
+ Allocate Docker the remainder of unified memory.
92
107
 
93
- To switch models post-bootstrap, re-run with the new flag and
94
- `docker compose down && up -d` to pick up the changed env.
108
+ To switch models post-bootstrap, either re-run bootstrap with the new
109
+ `--llm-model` (rewrites the managed block) or edit `LLM_MODEL` in the
110
+ managed block of your `.env` directly, then `./bin/op-compose -f
111
+ docker-compose-prod.yml up -d` to pick up the change.
95
112
 
96
113
  ### Local-only mode (no Cloudflare)
97
114
 
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { Octokit } from '@octokit/rest';
7
7
  import _sodium from 'libsodium-wrappers';
8
8
  import { randomBytes, createHmac } from 'crypto';
9
9
  import { join, resolve, dirname } from 'path';
10
- import { readFile, mkdir, writeFile, access, chmod, rename, rm } from 'fs/promises';
10
+ import { readFile, mkdir, writeFile, access, chmod, rename, stat, rm } from 'fs/promises';
11
11
  import { homedir } from 'os';
12
12
  import { readFileSync } from 'fs';
13
13
  import * as tls from 'tls';
@@ -1502,7 +1502,6 @@ async function writePgsodiumKeyFile(key, keyFile) {
1502
1502
  return { ok: false, reason: `writing ${keyFile} failed: ${err.message}` };
1503
1503
  }
1504
1504
  }
1505
- var MODEL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/;
1506
1505
  function renderLaunchAgentPlist(input) {
1507
1506
  validatePlistInput(input);
1508
1507
  const command = buildSetenvCommand(input);
@@ -1528,16 +1527,6 @@ function validatePlistInput(input) {
1528
1527
  if (input.tunnelToken !== void 0 && !TUNNEL_TOKEN_RE.test(input.tunnelToken)) {
1529
1528
  throw new Error("Tunnel token contains characters outside the expected base64-url set");
1530
1529
  }
1531
- if (input.llmModel !== void 0 && !MODEL_NAME_RE.test(input.llmModel)) {
1532
- throw new Error(
1533
- `llmModel ${JSON.stringify(input.llmModel)} contains characters not allowed in a launchd setenv value`
1534
- );
1535
- }
1536
- if (input.embeddingModel !== void 0 && !MODEL_NAME_RE.test(input.embeddingModel)) {
1537
- throw new Error(
1538
- `embeddingModel ${JSON.stringify(input.embeddingModel)} contains characters not allowed in a launchd setenv value`
1539
- );
1540
- }
1541
1530
  if (!SAFE_PATH_RE.test(input.keyFilePath)) {
1542
1531
  throw new Error(
1543
1532
  `keyFilePath ${JSON.stringify(input.keyFilePath)} contains characters not allowed in a launchd path interpolation`
@@ -1571,8 +1560,6 @@ function buildSetenvCommand(input) {
1571
1560
  // of — this is word-split safety, not new injection surface. (#36)
1572
1561
  `launchctl setenv PGSODIUM_ROOT_KEY "$(cat "${input.keyFilePath}")"`,
1573
1562
  ...input.tunnelToken !== void 0 ? [`launchctl setenv TUNNEL_TOKEN "${input.tunnelToken}"`] : [],
1574
- ...input.llmModel !== void 0 ? [`launchctl setenv LLM_MODEL "${input.llmModel}"`] : [],
1575
- ...input.embeddingModel !== void 0 ? [`launchctl setenv EMBEDDINGS_MODEL "${input.embeddingModel}"`] : [],
1576
1563
  ...input.postgresPassword !== void 0 ? [`launchctl setenv POSTGRES_PASSWORD "${input.postgresPassword}"`] : [],
1577
1564
  ...input.jwtSecret !== void 0 ? [`launchctl setenv JWT_SECRET "${input.jwtSecret}"`] : [],
1578
1565
  ...input.supabaseAnonKey !== void 0 ? [`launchctl setenv SUPABASE_ANON_KEY "${input.supabaseAnonKey}"`] : [],
@@ -1611,8 +1598,6 @@ function plistInputFromSetup(input, keyFilePath) {
1611
1598
  return {
1612
1599
  keyFilePath,
1613
1600
  ...input.tunnelToken !== void 0 ? { tunnelToken: input.tunnelToken } : {},
1614
- ...input.llmModel !== void 0 ? { llmModel: input.llmModel } : {},
1615
- ...input.embeddingModel !== void 0 ? { embeddingModel: input.embeddingModel } : {},
1616
1601
  ...input.postgresPassword !== void 0 ? { postgresPassword: input.postgresPassword } : {},
1617
1602
  ...input.jwtSecret !== void 0 ? { jwtSecret: input.jwtSecret } : {},
1618
1603
  ...input.supabaseAnonKey !== void 0 ? { supabaseAnonKey: input.supabaseAnonKey } : {},
@@ -2107,8 +2092,9 @@ async function cosignVerifyImage(input) {
2107
2092
  var OLLAMA_URL = "http://localhost:11434";
2108
2093
  var OLLAMA_HEALTH_TIMEOUT_MS = 5e3;
2109
2094
  var OLLAMA_WARM_TIMEOUT_MS = 12e4;
2110
- var DEFAULT_LLM_MODEL = "qwen3.5:9b";
2095
+ var DEFAULT_LLM_MODEL = "qwen2.5:7b";
2111
2096
  var DEFAULT_EMBEDDING_MODEL = "nomic-embed-text";
2097
+ var DEFAULT_EMBEDDINGS_PROVIDER = "xenova";
2112
2098
  var PROBE_ALPINE_TAG = "3.20";
2113
2099
  async function startOllamaService() {
2114
2100
  const res = await safeExeca("brew", ["services", "start", "ollama"]);
@@ -2227,6 +2213,138 @@ async function setupModels(models, onProgress) {
2227
2213
  }
2228
2214
  return report;
2229
2215
  }
2216
+ var MANAGED_BEGIN = "# >>> op-node managed >>>";
2217
+ var MANAGED_END = "# <<< op-node managed <<<";
2218
+ var MANAGED_KEYS = [
2219
+ "LLM_MODEL",
2220
+ "EMBEDDINGS_PROVIDER",
2221
+ "EMBEDDINGS_MODEL",
2222
+ "NODE_ENV"
2223
+ ];
2224
+ var ENV_VALUE_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/;
2225
+ function selectionToPairs(sel) {
2226
+ return [
2227
+ ["LLM_MODEL", sel.llmModel],
2228
+ ["EMBEDDINGS_PROVIDER", sel.embeddingsProvider],
2229
+ ["EMBEDDINGS_MODEL", sel.embeddingModel],
2230
+ ["NODE_ENV", sel.nodeEnv]
2231
+ ];
2232
+ }
2233
+ function parseEnvContent(content) {
2234
+ const out = /* @__PURE__ */ new Map();
2235
+ for (const raw of content.split("\n")) {
2236
+ const line = raw.trim();
2237
+ if (line.length === 0 || line.startsWith("#")) continue;
2238
+ const eq = line.indexOf("=");
2239
+ if (eq <= 0) continue;
2240
+ out.set(line.slice(0, eq).trim(), line.slice(eq + 1).trim());
2241
+ }
2242
+ return out;
2243
+ }
2244
+ function splitManaged(content) {
2245
+ const lines = content.split("\n");
2246
+ const beginIdx = lines.findIndex((l) => l.trim() === MANAGED_BEGIN);
2247
+ if (beginIdx === -1) {
2248
+ return { before: lines, after: [], blockValues: /* @__PURE__ */ new Map(), hasBlock: false };
2249
+ }
2250
+ const endIdx = lines.findIndex((l, i) => i > beginIdx && l.trim() === MANAGED_END);
2251
+ if (endIdx === -1) {
2252
+ return {
2253
+ before: lines.slice(0, beginIdx),
2254
+ after: [],
2255
+ blockValues: parseEnvContent(lines.slice(beginIdx + 1).join("\n")),
2256
+ hasBlock: true
2257
+ };
2258
+ }
2259
+ return {
2260
+ before: lines.slice(0, beginIdx),
2261
+ after: lines.slice(endIdx + 1),
2262
+ blockValues: parseEnvContent(lines.slice(beginIdx + 1, endIdx).join("\n")),
2263
+ hasBlock: true
2264
+ };
2265
+ }
2266
+ function stripManagedKeyLines(region) {
2267
+ return region.filter((raw) => {
2268
+ const line = raw.trim();
2269
+ if (line.length === 0 || line.startsWith("#")) return true;
2270
+ const eq = line.indexOf("=");
2271
+ if (eq <= 0) return true;
2272
+ return !MANAGED_KEYS.includes(line.slice(0, eq).trim());
2273
+ });
2274
+ }
2275
+ function renderBlock(values) {
2276
+ return [
2277
+ MANAGED_BEGIN,
2278
+ "# Auto-generated by `create-op-node bootstrap`. Non-secret model config,",
2279
+ "# read by docker compose via `.env` auto-load. Edit LLM_MODEL here to",
2280
+ "# change the model for every service; re-running bootstrap preserves your",
2281
+ "# edits unless you explicitly re-select a model. Secrets do NOT live here.",
2282
+ ...values.map(([k, v]) => `${k}=${v}`),
2283
+ MANAGED_END
2284
+ ];
2285
+ }
2286
+ function buildManagedEnvContent(existing, selection, opts = {}) {
2287
+ const split = splitManaged(existing);
2288
+ const importedBefore = filterManaged(parseEnvContent(split.before.join("\n")));
2289
+ const importedAfter = filterManaged(parseEnvContent(split.after.join("\n")));
2290
+ const resolved = [];
2291
+ for (const [key, selected] of selectionToPairs(selection)) {
2292
+ const existingVal = split.blockValues.get(key) ?? importedBefore.get(key) ?? importedAfter.get(key);
2293
+ const final = opts.overwrite ? selected ?? existingVal : existingVal ?? selected;
2294
+ if (final === void 0) continue;
2295
+ if (!ENV_VALUE_RE.test(final)) {
2296
+ return { error: `${key} value ${JSON.stringify(final)} is not safe for a .env line` };
2297
+ }
2298
+ resolved.push([key, final]);
2299
+ }
2300
+ const before = stripManagedKeyLines(split.before);
2301
+ const after = stripManagedKeyLines(split.after);
2302
+ const block = renderBlock(resolved);
2303
+ const parts = [joinTrimmed(before), block.join("\n"), joinTrimmed(after)].filter(
2304
+ (s) => s.length > 0
2305
+ );
2306
+ return { content: parts.join("\n\n") + "\n" };
2307
+ }
2308
+ function filterManaged(all) {
2309
+ const out = /* @__PURE__ */ new Map();
2310
+ for (const key of MANAGED_KEYS) {
2311
+ const v = all.get(key);
2312
+ if (v !== void 0) out.set(key, v);
2313
+ }
2314
+ return out;
2315
+ }
2316
+ function joinTrimmed(region) {
2317
+ const joined = region.join("\n");
2318
+ let start = 0;
2319
+ let end = joined.length;
2320
+ while (start < end && joined[start] === "\n") start++;
2321
+ while (end > start && joined[end - 1] === "\n") end--;
2322
+ return joined.slice(start, end);
2323
+ }
2324
+ async function writeManagedEnv(repoDir, selection, opts = {}) {
2325
+ const target = join(repoDir, ".env");
2326
+ let existing = "";
2327
+ let existingMode;
2328
+ try {
2329
+ existing = await readFile(target, "utf8");
2330
+ existingMode = (await stat(target)).mode & 511;
2331
+ } catch {
2332
+ }
2333
+ const built = buildManagedEnvContent(existing, selection, opts);
2334
+ if ("error" in built) return { ok: false, reason: built.error };
2335
+ if (built.content === existing) return { ok: true, path: target, unchanged: true };
2336
+ const mode = existingMode ?? 420;
2337
+ const tmp = `${target}.tmp.${process.pid}`;
2338
+ try {
2339
+ await mkdir(dirname(target), { recursive: true });
2340
+ await writeFile(tmp, built.content, { mode });
2341
+ await chmod(tmp, mode);
2342
+ await rename(tmp, target);
2343
+ return { ok: true, path: target };
2344
+ } catch (err) {
2345
+ return { ok: false, reason: `writing ${target} failed: ${err.message}` };
2346
+ }
2347
+ }
2230
2348
  var NODE_REPO_MARKERS = [
2231
2349
  "docker-compose-prod.yml",
2232
2350
  "supabase/init/pgsodium_getkey_env.sh"
@@ -2300,6 +2418,11 @@ var bootstrapCommand = new Command("bootstrap").description(
2300
2418
  "--embedding-model <model>",
2301
2419
  `Ollama embedding model. Default: ${DEFAULT_EMBEDDING_MODEL}. Only takes effect when the knowledge service runs with EMBEDDINGS_PROVIDER=ollama (otherwise embeddings are computed in-process via xenova).`
2302
2420
  )
2421
+ ).addOption(
2422
+ new Option(
2423
+ "--embeddings-provider <provider>",
2424
+ `Where embeddings are computed. Default: ${DEFAULT_EMBEDDINGS_PROVIDER} (in-process). \`ollama\` uses the host daemon with the embedding model. Written to the node's .env as the single source of truth.`
2425
+ ).choices(["xenova", "ollama"])
2303
2426
  ).addOption(
2304
2427
  new Option(
2305
2428
  "--supabase-url <url>",
@@ -2342,6 +2465,7 @@ var bootstrapCommand = new Command("bootstrap").description(
2342
2465
  llmModel: llmModelChoice,
2343
2466
  ...opts.embeddingModel !== void 0 ? { embeddingModel: opts.embeddingModel } : {}
2344
2467
  });
2468
+ const embeddingsProvider = opts.embeddingsProvider ?? DEFAULT_EMBEDDINGS_PROVIDER;
2345
2469
  await runSystemChecksPhase();
2346
2470
  await runBrewPhase(opts);
2347
2471
  await ensureGhAuth();
@@ -2349,11 +2473,18 @@ var bootstrapCommand = new Command("bootstrap").description(
2349
2473
  const repoPath = await locateRegionRepoPhase(opts, owner, repoName);
2350
2474
  await unlockKeychainPhase();
2351
2475
  const secrets = await collectSecretsPhase({ region, nodeType, opts });
2352
- await runLaunchAgentPhase({ opts, secrets, llmModel, embeddingModel });
2476
+ await runLaunchAgentPhase({ opts, secrets });
2353
2477
  await installWrapperPhase({ repoPath, region, promptServiceUrl: secrets.promptServiceUrl });
2354
2478
  await loginGhcrPhase();
2355
2479
  await runOllamaPhase({ opts, embeddingModel, llmModel });
2356
- await runStackPhase({ opts, repoPath, region, composeFile, secrets, llmModel, embeddingModel });
2480
+ await runEnvFilePhase({
2481
+ repoPath,
2482
+ llmModel,
2483
+ embeddingModel,
2484
+ embeddingsProvider,
2485
+ localOnly: Boolean(opts.localOnly)
2486
+ });
2487
+ await runStackPhase({ opts, repoPath, region, composeFile, secrets });
2357
2488
  });
2358
2489
  function defaultComposeFiles(opts) {
2359
2490
  return opts.localOnly ? ["docker-compose-prod.yml"] : ["docker-compose-prod.yml", "docker-compose-backup.yml"];
@@ -2717,15 +2848,13 @@ async function collectSecretsPhase(args) {
2717
2848
  return { ...core, ...promptSecrets, promptServiceUrl, supabaseUrl };
2718
2849
  }
2719
2850
  async function runLaunchAgentPhase(args) {
2720
- const { opts, secrets, llmModel, embeddingModel } = args;
2851
+ const { opts, secrets } = args;
2721
2852
  if (opts.skipLaunchAgent) return;
2722
2853
  const laSpin = p3.spinner();
2723
2854
  laSpin.start("Writing pgsodium key file + LaunchAgent plist\u2026");
2724
2855
  const la = await setupLaunchAgent({
2725
2856
  pgsodiumKey: secrets.pgsodiumKey,
2726
2857
  ...secrets.tunnelToken !== void 0 ? { tunnelToken: secrets.tunnelToken } : {},
2727
- llmModel,
2728
- embeddingModel,
2729
2858
  postgresPassword: secrets.postgresPassword,
2730
2859
  jwtSecret: secrets.jwtSecret,
2731
2860
  supabaseAnonKey: secrets.supabaseAnonKey,
@@ -2810,8 +2939,33 @@ async function runOllamaPhase(args) {
2810
2939
  p3.note(`${pc2.yellow("\u26A0")} ${probe.reason}`, "Docker host networking");
2811
2940
  }
2812
2941
  }
2942
+ async function runEnvFilePhase(args) {
2943
+ const { repoPath, llmModel, embeddingModel, embeddingsProvider, localOnly } = args;
2944
+ const envSpin = p3.spinner();
2945
+ envSpin.start("Writing model config to the node .env\u2026");
2946
+ const res = await writeManagedEnv(
2947
+ repoPath,
2948
+ {
2949
+ llmModel,
2950
+ embeddingModel,
2951
+ embeddingsProvider,
2952
+ // NODE_ENV=development only for local-dev nodes; production nodes leave
2953
+ // it unset (the compose default / container images decide).
2954
+ ...localOnly ? { nodeEnv: "development" } : {}
2955
+ },
2956
+ { overwrite: true }
2957
+ );
2958
+ if (!res.ok) {
2959
+ envSpin.stop(pc2.red("\u2717 Failed to write the node .env."));
2960
+ p3.cancel(res.reason ?? "writing .env failed.");
2961
+ process.exit(1);
2962
+ }
2963
+ envSpin.stop(
2964
+ res.unchanged ? pc2.green(`\u2713 Model config already current in ${res.path}.`) : pc2.green(`\u2713 Wrote model config to ${res.path} (LLM_MODEL=${llmModel}).`)
2965
+ );
2966
+ }
2813
2967
  function buildComposeEnv(args) {
2814
- const { secrets, llmModel, embeddingModel } = args;
2968
+ const { secrets } = args;
2815
2969
  return {
2816
2970
  PGSODIUM_ROOT_KEY: secrets.pgsodiumKey,
2817
2971
  POSTGRES_PASSWORD: secrets.postgresPassword,
@@ -2839,9 +2993,12 @@ function buildComposeEnv(args) {
2839
2993
  ...secrets.promptsDbPassword !== void 0 ? { PROMPTS_DB_PASSWORD: secrets.promptsDbPassword } : {},
2840
2994
  ...secrets.promptServiceApiKey !== void 0 ? { PROMPT_SERVICE_API_KEY: secrets.promptServiceApiKey } : {},
2841
2995
  ...secrets.promptServiceApiKeys !== void 0 ? { PROMPT_SERVICE_API_KEYS: secrets.promptServiceApiKeys } : {},
2842
- ...secrets.promptServiceAdminApiKeys !== void 0 ? { PROMPT_SERVICE_ADMIN_API_KEYS: secrets.promptServiceAdminApiKeys } : {},
2843
- ...llmModel ? { LLM_MODEL: llmModel } : {},
2844
- ...embeddingModel ? { EMBEDDINGS_MODEL: embeddingModel } : {}
2996
+ ...secrets.promptServiceAdminApiKeys !== void 0 ? { PROMPT_SERVICE_ADMIN_API_KEYS: secrets.promptServiceAdminApiKeys } : {}
2997
+ // Model config (LLM_MODEL / EMBEDDINGS_MODEL / NODE_ENV) is intentionally
2998
+ // NOT injected here it lives in the node's `.env` (runEnvFilePhase),
2999
+ // which docker compose auto-loads. Injecting it into the subprocess env
3000
+ // would shadow `.env` (shell env > .env) and reintroduce the drift the
3001
+ // single-source `.env` exists to eliminate.
2845
3002
  };
2846
3003
  }
2847
3004
  function checkPublicProfileSecrets(env) {
@@ -2921,7 +3078,7 @@ cosign is required for the fail-closed signature gate \u2014 install it (\`brew
2921
3078
  );
2922
3079
  }
2923
3080
  async function runStackPhase(args) {
2924
- const { opts, repoPath, region, composeFile, secrets, llmModel, embeddingModel } = args;
3081
+ const { opts, repoPath, region, composeFile, secrets } = args;
2925
3082
  if (opts.skipStack) {
2926
3083
  const profileFlag = opts.localOnly ? "" : "--profile public ";
2927
3084
  p3.outro(
@@ -2932,7 +3089,7 @@ async function runStackPhase(args) {
2932
3089
  return;
2933
3090
  }
2934
3091
  const composeFiles = resolveComposeFiles(repoPath, composeFile);
2935
- const composeEnv = buildComposeEnv({ secrets, llmModel, embeddingModel });
3092
+ const composeEnv = buildComposeEnv({ secrets });
2936
3093
  const composeOpts = {
2937
3094
  files: composeFiles,
2938
3095
  cwd: repoPath,
@@ -3056,32 +3213,38 @@ function resolveModels(opts) {
3056
3213
  }
3057
3214
  var LLM_MODEL_CHOICES = [
3058
3215
  {
3059
- value: "qwen2.5:72b",
3060
- label: "qwen2.5:72b",
3061
- hint: "72B, ~50 GB Ollama RAM. Pre-selected on 96 GB+ Studios. Best Spanish + multilingual quality. Pull ~40 GB, 30\u201360 min."
3216
+ value: "qwen3.6:35b-a3b",
3217
+ label: "qwen3.6:35b-a3b",
3218
+ hint: "35B MoE, ~3B active \u2014 fast on Apple Silicon. ~24 GB footprint. Pre-selected on 64 GB+ nodes. Best Spanish + multilingual quality. Pull ~24 GB, 30\u201360 min."
3219
+ },
3220
+ {
3221
+ value: "qwen3:14b",
3222
+ label: "qwen3:14b",
3223
+ hint: "14B, ~9 GB footprint. Pre-selected on 32\u201364 GB nodes. Solid Spanish, faster than the 35B MoE. Pull ~9 GB, 10\u201320 min."
3062
3224
  },
3063
3225
  {
3064
- value: "qwen2.5:32b",
3065
- label: "qwen2.5:32b",
3066
- hint: "32B, ~22 GB Ollama RAM. Pre-selected on 48\u201395 GB Studios. Solid Spanish, faster than 72B. Pull ~20 GB, 15\u201330 min."
3226
+ value: "qwen2.5:7b",
3227
+ label: "qwen2.5:7b",
3228
+ hint: "7B, ~5 GB footprint. Pre-selected on 16\u201332 GB nodes; also the -y / scripted default. Pull ~5 GB, 3\u20135 min."
3067
3229
  },
3068
3230
  {
3069
- value: "qwen3.5:9b",
3070
- label: "qwen3.5:9b",
3071
- hint: "9B, ~8 GB Ollama RAM. Pre-selected on smaller Studios (< 48 GB); also the -y / scripted default. Pull ~5 GB, 3\u20135 min."
3231
+ value: "qwen2.5:3b",
3232
+ label: "qwen2.5:3b",
3233
+ hint: "3B, ~2\u20133 GB footprint. Pre-selected on \u2264 16 GB nodes (Mac Mini). Lightest option. Pull ~2 GB, 2\u20133 min."
3072
3234
  }
3073
3235
  ];
3074
3236
  var OTHER_SENTINEL = "__OTHER__";
3075
3237
  function recommendLlmModel(ramGB) {
3076
3238
  if (ramGB === null) return null;
3077
- if (ramGB >= 96) return "qwen2.5:72b";
3078
- if (ramGB >= 48) return "qwen2.5:32b";
3079
- return "qwen3.5:9b";
3239
+ if (ramGB >= 64) return "qwen3.6:35b-a3b";
3240
+ if (ramGB >= 32) return "qwen3:14b";
3241
+ if (ramGB >= 16) return "qwen2.5:7b";
3242
+ return "qwen2.5:3b";
3080
3243
  }
3081
3244
  async function selectLlmModel(opts) {
3082
3245
  if (opts.yes) return DEFAULT_LLM_MODEL;
3083
3246
  const ramGB = await detectUnifiedMemoryGB();
3084
- const recommended = recommendLlmModel(ramGB) ?? "qwen2.5:72b";
3247
+ const recommended = recommendLlmModel(ramGB) ?? DEFAULT_LLM_MODEL;
3085
3248
  const ramNote = ramGB !== null ? `Detected ${ramGB} GB unified memory \u2014 pre-selecting ${recommended}.` : `Couldn't detect Studio memory \u2014 defaulting to ${recommended} (override if your config differs).`;
3086
3249
  p3.note(ramNote, "Hardware");
3087
3250
  const choice = unwrap(
@@ -5155,7 +5318,7 @@ function withDefaultSubcommand(argv, known) {
5155
5318
  }
5156
5319
 
5157
5320
  // src/cli.ts
5158
- var VERSION = "0.12.4";
5321
+ var VERSION = "0.13.0";
5159
5322
  var program = new Command();
5160
5323
  program.name("create-op-node").description(
5161
5324
  "Interactive bootstrap for an Opus Populi federation node.\nCloudflare infrastructure \u2192 Mac Studio \u2192 live public API."