@tiens.nguyen/gu-cli 1.0.716 → 1.0.718

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.
@@ -26,6 +26,11 @@ export const EMBED_CHOICES = [
26
26
  dims: 4096,
27
27
  note: "best recall, ~18× slower to index",
28
28
  },
29
+ {
30
+ model: "qwen3-embedding:4b",
31
+ dims: 2560,
32
+ note: "strong recall, ~3× faster to index than 8b",
33
+ },
29
34
  {
30
35
  model: "nomic-embed-text:latest",
31
36
  dims: 768,
@@ -5316,6 +5316,39 @@ def _rag_embed_failure_hint(err) -> str:
5316
5316
  return f"{name}: {_clip(text, 120)}"
5317
5317
 
5318
5318
 
5319
+ # The API key for the EMBEDDING endpoint, resolved once per job from the API
5320
+ # (backend id "embed-ollama" - where /embed-model stores it, same store as coding/agent
5321
+ # keys). A module global because _embed() is a tiny hot path reached through the rag_*
5322
+ # tools - passing a key down that chain just for a header would churn every signature
5323
+ # above it for no behaviour.
5324
+ _RAG_EMBED_API_KEY = ""
5325
+
5326
+
5327
+ def _rag_resolve_embed_api_key(api_base: str, worker_key: str) -> str:
5328
+ """Fetch the embedding-server API key from the shared key store, or "" when none is set.
5329
+
5330
+ The key was typed at /embed-model and POSTed to /api/worker/coding-keys under
5331
+ backendId="embed-ollama" - reusing the coding-keys store means it already lands in
5332
+ every mode (local file in Client Expert, Mongo for cloud users) with no new persistence.
5333
+ The API masks keys in settings reads but returns the real secret here precisely so the
5334
+ daemon that holds the worker key can authenticate to the embedder. Never raises: an
5335
+ unreachable store means "" and the embed call goes unsigned, right for a keyless local
5336
+ Ollama.
5337
+ """
5338
+ base = (api_base or "").rstrip("/")
5339
+ if not base or not worker_key:
5340
+ return ""
5341
+ try:
5342
+ from urllib.parse import urlencode
5343
+ req = urllib.request.Request(
5344
+ f"{base}/api/worker/coding-key?{urlencode({'backendId': 'embed-ollama'})}",
5345
+ headers={"X-Worker-Key": worker_key}, method="GET")
5346
+ with urllib.request.urlopen(req, timeout=8, context=_ssl_context()) as resp:
5347
+ data = json.loads(resp.read().decode("utf-8", "replace") or "{}")
5348
+ return (data.get("apiKey") or "").strip()
5349
+ except Exception: # noqa: BLE001
5350
+ return ""
5351
+
5319
5352
  def _embed(base: str, model: str, texts: list) -> list:
5320
5353
  """Embed a batch of texts. Auto-detects the endpoint from `base` (a bare host:port,
5321
5354
  an .../v1, or an Ollama host): tries OpenAI-compatible /v1/embeddings first (MLX
@@ -5328,9 +5361,15 @@ def _embed(base: str, model: str, texts: list) -> list:
5328
5361
  last_err = None
5329
5362
 
5330
5363
  def _post(url, payload, timeout):
5364
+ # The embedding endpoint may be a KEYED server (cloud Ollama, a gateway) - the user
5365
+ # typed its key at /embed-model; it rides as a Bearer token. A keyless local Ollama
5366
+ # simply has _RAG_EMBED_API_KEY empty and the header is absent as before.
5367
+ headers = {"Content-Type": "application/json"}
5368
+ if _RAG_EMBED_API_KEY:
5369
+ headers["Authorization"] = f"Bearer {_RAG_EMBED_API_KEY}"
5331
5370
  req = urllib.request.Request(
5332
5371
  url, data=json.dumps(payload).encode("utf-8"),
5333
- headers={"Content-Type": "application/json"}, method="POST",
5372
+ headers=headers, method="POST",
5334
5373
  )
5335
5374
  with _proxy_urlopen(req, timeout=timeout, context=ctx) as resp:
5336
5375
  return json.loads(resp.read().decode("utf-8", "replace"))
@@ -6948,6 +6987,12 @@ def run_agent_chat(cfg):
6948
6987
  # the user never chose into the logs and the status table.
6949
6988
  rag_embed_model = (cfg.get("ragEmbedModel") or "").strip()
6950
6989
  rag_embed_url = (cfg.get("ragEmbedUrl") or "").strip()
6990
+ # The RAG embedder may be a KEYED server (task: embed api key in /embed-model). Resolve
6991
+ # its key once, from the shared key store (backendId "embed-ollama"), so every _embed()
6992
+ # call this job carries the Bearer header. Empty for a keyless local Ollama - _embed()
6993
+ # then sends no Authorization, exactly the old behaviour.
6994
+ global _RAG_EMBED_API_KEY
6995
+ _RAG_EMBED_API_KEY = _rag_resolve_embed_api_key(worker_api_base, worker_api_key) if rag_embed_url else ""
6951
6996
  try:
6952
6997
  rag_top_k = int(cfg.get("ragTopK") or 6)
6953
6998
  except (TypeError, ValueError):
package/gu-repl.mjs CHANGED
@@ -132,7 +132,7 @@ import {
132
132
  import { createHoverController, applyToggleShift } from "./terminal-hover.mjs";
133
133
  // The required pair, imported rather than re-typed: the startup "no models" message names them,
134
134
  // and a second copy of these strings would drift from the check that enforces them.
135
- import { REQUIRED_AGENT_MODEL, REQUIRED_AGENT_PREFIX, REQUIRED_EMBED_MODEL } from "./ollama-setup.mjs";
135
+ import { REQUIRED_AGENT_MODEL, REQUIRED_AGENT_PREFIX } from "./ollama-setup.mjs";
136
136
  // The brand accent — indigo, the same token the web app paints its wordmark with. It is NOT a
137
137
  // plain SGR code: terminals disagree about colour, so accent.mjs picks 24-bit, 256 or the
138
138
  // sixteen-colour magenta from what this one actually claims to support.
@@ -781,7 +781,7 @@ const COMMANDS = [
781
781
  // what made /rag confusing, and it did the same here: reported as "why do we need /provider
782
782
  // outside, it is confused". The command still works and still does the same thing.
783
783
  { name: "/agent-model", desc: "switch the AGENT model (the one that talks) — saved on this machine" },
784
- { name: "/embed-model", desc: "switch the RAG EMBEDDING model — re-indexes what is already indexed" },
784
+ { name: "/embed-model", desc: "switch the RAG EMBEDDING model (nomic-embed-text / qwen3-embedding) and its server — re-indexes what is already indexed; supports a remote URL + API key" },
785
785
  { name: "/test-auto", desc: "toggle auto-test (verify & fix until it passes) for this folder" },
786
786
  // ONE command, three answers. /rag-local and /rag-cloud still work — they are in people's
787
787
  // fingers — but they are HIDDEN_COMMANDS now, because offering three doors into one setting
@@ -4473,6 +4473,26 @@ async function loadSessionMaxCodeTokens(cwd) {
4473
4473
  * and the URL is what the global output-token total is keyed to (task #125) — restoring a
4474
4474
  * subset would leave the footer describing one backend while the turns went to another.
4475
4475
  */
4476
+ /**
4477
+ * Mirror this folder's coding choice to the SAME database that stores the key
4478
+ * material (task #225) — cloud Mongo in hosted mode, localhost Mongo in Client
4479
+ * Expert — so the choice and the credentials always live in one consistent store.
4480
+ * The local session file stays as a fast offline cache. A failure is reported,
4481
+ * never silent, so a lost connection is visible rather than a stale choice later.
4482
+ */
4483
+ async function persistCodingChoiceToServer(cwd) {
4484
+ try {
4485
+ await saveCodingChoice(folderHashFor(cwd), {
4486
+ codingKeyId: sessionCodingKeyId,
4487
+ codingModel: sessionCodingModel,
4488
+ codingLabel: sessionCodingLabel,
4489
+ codingUrl: sessionCodingUrl,
4490
+ });
4491
+ } catch (err) {
4492
+ console.error(dim(`(coding choice could not reach the database: ${err.message})`));
4493
+ }
4494
+ }
4495
+
4476
4496
  /**
4477
4497
  * Persist the /model pick THE MOMENT IT IS MADE.
4478
4498
  *
@@ -4497,6 +4517,8 @@ async function saveSessionCoding(cwd) {
4497
4517
  // carry on with the pick applied for this session.
4498
4518
  console.error(dim(`(coding model save failed: ${err.message})`));
4499
4519
  }
4520
+ // ALSO mirrored to the database store (task #225), same place the credentials live.
4521
+ await persistCodingChoiceToServer(cwd);
4500
4522
  }
4501
4523
 
4502
4524
  /**
@@ -4516,6 +4538,8 @@ async function saveSessionCodingKeyId(cwd) {
4516
4538
  } catch (err) {
4517
4539
  console.error(dim(`(coding key choice save failed: ${err.message})`));
4518
4540
  }
4541
+ // ALSO mirrored to the same database store that holds the credentials (task #225).
4542
+ await persistCodingChoiceToServer(cwd);
4519
4543
  }
4520
4544
 
4521
4545
  async function loadSessionCodingKeyId(cwd) {
@@ -5051,7 +5075,7 @@ async function reconfigureOllamaServer() {
5051
5075
  agentModel: chat,
5052
5076
  ragEnabled: true,
5053
5077
  ragEmbedUrl: agent.normalized.url,
5054
- ragEmbedModel: os.REQUIRED_EMBED_MODEL,
5078
+ ragEmbedModel: os.defaultEmbedModel?.(agent.models) ?? os.REQUIRED_EMBED_MODEL,
5055
5079
  });
5056
5080
  }
5057
5081
  if (coder) {
@@ -5137,7 +5161,7 @@ async function reconfigureOllamaServer() {
5137
5161
  console.log(accent(" ✓ models saved"));
5138
5162
  if (agent) {
5139
5163
  console.log(dim(` chat ${chat} @ ${agent.normalized.host}`));
5140
- console.log(dim(` RAG ${os.REQUIRED_EMBED_MODEL} @ ${agent.normalized.host}`));
5164
+ console.log(dim(` RAG ${os.defaultEmbedModel?.(agent.models) ?? os.REQUIRED_EMBED_MODEL} @ ${agent.normalized.host}`));
5141
5165
  } else {
5142
5166
  console.log(dim(" chat — not set (agent server skipped)"));
5143
5167
  }
@@ -5147,7 +5171,11 @@ async function reconfigureOllamaServer() {
5147
5171
  : dim(" code — not set (coding server skipped)")
5148
5172
  );
5149
5173
  if (extras.length) console.log(dim(` also ${extras.join(", ")} — offered by /model`));
5150
- if (agent && String(settings.ragEmbedModel ?? "") !== os.REQUIRED_EMBED_MODEL) {
5174
+ // Compare to the embedder this box was seeded with — an accepted OTHER embedder (a
5175
+ // qwen3-embedding-only box) was written to settings above, so REQUIRED_EMBED_MODEL would
5176
+ // falsely scream "changed" while the seeded value is exactly what settings now hold.
5177
+ const seededEmbed = agent ? (os.defaultEmbedModel?.(agent.models) ?? os.REQUIRED_EMBED_MODEL) : "";
5178
+ if (agent && String(settings.ragEmbedModel ?? "") !== seededEmbed) {
5151
5179
  console.log(dim(" the embedder changed — existing RAG indexes need rebuilding (/rag)."));
5152
5180
  }
5153
5181
  console.log(dim(" takes effect on your next question.\n"));
@@ -6301,7 +6329,35 @@ async function chooseEmbedModel() {
6301
6329
  return;
6302
6330
  }
6303
6331
 
6304
- const saved = await md.saveWorkerSettings(apiBase, workerKey, { ragEmbedModel: chosen });
6332
+ // The model is decided. Now WHERE to embed: a local Ollama answers at 127.0.0.1:11434 with
6333
+ // no credential; a remote Ollama needs its URL — and a cloud host (ollama.com) ALSO needs an
6334
+ // API key. Same addition coding and agent models already have (#127): the key is named and
6335
+ // stored in the shared account store (settings.agentCodingApiKeys) tagged "embed-ollama", and
6336
+ // this command records its id in ragEmbedKeyId so the embedder can name what it sends. Enter
6337
+ // keeps the current value; the prompts match /coding-key's flow (ask + keyName + askSecret).
6338
+ const currentUrl = settings.ragEmbedUrl || "http://127.0.0.1:11434";
6339
+ const urlIn = await ask(` Ollama server URL for embedding [${currentUrl}] — 'none' clears the key: `);
6340
+ if (urlIn === null) { console.log(dim(" (cancelled)\n")); return; }
6341
+ const embedUrl = urlIn.trim() === "" ? (settings.ragEmbedUrl ?? "") : urlIn.trim();
6342
+ let embedKeyId = settings.ragEmbedKeyId ?? "";
6343
+ if (/^none$/i.test(urlIn.trim())) {
6344
+ embedKeyId = "";
6345
+ } else if (embedUrl && isOllamaCloudUrl(embedUrl)) {
6346
+ console.log(GUTTER + dim('A name just tells keys apart — "embed", "work" '));
6347
+ const name = (await askKeyName()).trim();
6348
+ console.log(GUTTER + dim(SECRET_KEY_HINT));
6349
+ const key = (await askSecret(` API key for ${embedUrl}${name ? ` (${name})` : ""}: `)).trim();
6350
+ if (key) {
6351
+ const id = await withPendingStatus("saving the key", () => addCodingKey(name, key, "embed-ollama"));
6352
+ if (!id) console.log(red(" couldn't save the embed key — searching stays unauthenticated.\n"));
6353
+ else { embedKeyId = id; console.log(GUTTER + dim(`embed key "${name}" saved.\n`)); }
6354
+ }
6355
+ }
6356
+ const saved = await md.saveWorkerSettings(apiBase, workerKey, {
6357
+ ragEmbedModel: chosen,
6358
+ ragEmbedUrl: embedUrl,
6359
+ ragEmbedKeyId: embedKeyId,
6360
+ });
6305
6361
  if (!saved) {
6306
6362
  // A FAILED WRITE MUST NOT LOOK LIKE A SUCCESS. The next turn would keep using the old
6307
6363
  // embedder, and the user would be told their index was rebuilt for a change that never
@@ -7500,6 +7556,46 @@ async function deleteCodingKey(id) {
7500
7556
  }
7501
7557
  }
7502
7558
 
7559
+ /**
7560
+ * This folder's stored coding choice, from the SAME Mongo that holds the key secrets
7561
+ * (task #225). Returns {codingKeyId, codingModel, codingLabel, codingUrl} or null when none
7562
+ * is stored for this folder. THROWS on a network/HTTP failure so the caller can say WHY —
7563
+ * the choice and the secret now live in one store, so "cannot reach it" is a real error,
7564
+ * not a silent fall-back.
7565
+ */
7566
+ async function fetchCodingChoice(folderHash) {
7567
+ const r = await fetch(`${apiBase}/api/worker/coding-choice?folder=${encodeURIComponent(folderHash)}`, {
7568
+ headers: { "X-Worker-Key": workerKey },
7569
+ signal: AbortSignal.timeout(API_CALL_TIMEOUT_MS),
7570
+ });
7571
+ if (!r.ok) throw new Error(`coding-choice GET failed: HTTP ${r.status}`);
7572
+ const j = await r.json().catch(() => ({}));
7573
+ const c = j?.choice;
7574
+ if (!c || typeof c !== "object") return null;
7575
+ const str = (x) => (typeof x === "string" ? x : "");
7576
+ return {
7577
+ codingKeyId: str(c.codingKeyId),
7578
+ codingModel: str(c.codingModel),
7579
+ codingLabel: str(c.codingLabel),
7580
+ codingUrl: str(c.codingUrl),
7581
+ };
7582
+ }
7583
+
7584
+ /** Persist this folder's coding choice to the SAME Mongo that holds the key secrets (task #225). */
7585
+ async function saveCodingChoice(folderHash, choice) {
7586
+ const r = await fetch(`${apiBase}/api/worker/coding-choice`, {
7587
+ method: "POST",
7588
+ headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
7589
+ body: JSON.stringify({ folder: folderHash, ...choice }),
7590
+ signal: AbortSignal.timeout(API_CALL_TIMEOUT_MS),
7591
+ });
7592
+ if (!r.ok) throw new Error(`coding-choice POST failed: HTTP ${r.status}`);
7593
+ }
7594
+
7595
+ /** The folder-hash key the choice is stored under — the SAME sha256-32 the session file uses. */
7596
+ function folderHashFor(cwd) {
7597
+ return createHash("sha256").update(cwd).digest("hex").slice(0, 32);
7598
+ }
7503
7599
  /**
7504
7600
  * Ask for a name, then the secret, and store it. Returns the new id or "".
7505
7601
  *
@@ -10000,15 +10096,56 @@ async function main() {
10000
10096
  sessionMaxCodeTokens = await loadSessionMaxCodeTokens(resolve(process.cwd()));
10001
10097
  sessionRagStorage = await loadSessionRagStorage(resolve(process.cwd())); // task #133/#180
10002
10098
  // Restored here, VALIDATED below once the probe says what this account actually allows.
10099
+ // The CHOICE lives in the SAME Mongo as the key secrets (task #225) — the local session file
10100
+ // is only a cache. The database is read FIRST; a failure to reach it is a real error, said
10101
+ // aloud, never a silent fall-back to a stale file.
10003
10102
  {
10004
- const c = await loadSessionCoding(resolve(process.cwd()));
10005
- if (c) {
10006
- sessionCodingModel = c.model;
10007
- sessionCodingLabel = c.label;
10008
- sessionCodingUrl = c.url;
10103
+ const folderHash = folderHashFor(resolve(process.cwd()));
10104
+ let dbChoice = null;
10105
+ let dbUnreachable = false;
10106
+ try {
10107
+ dbChoice = await fetchCodingChoice(folderHash);
10108
+ } catch (err) {
10109
+ // Local mode has no "offline" — the database is on THIS machine, so a failure means the
10110
+ // local mongodb is unreachable. Cloud mode failing to reach the hosted API means the
10111
+ // computer has lost its internet connection. Two different messages, one per mode.
10112
+ dbUnreachable = true;
10113
+ if (isLocalApiBase(apiBase)) {
10114
+ console.error(dim("(cannot access local mongodb — your saved model/key choice is unavailable)"));
10115
+ } else {
10116
+ console.error(dim("(your computer has lost its internet connection — your saved model/key choice is unavailable)"));
10117
+ }
10118
+ }
10119
+ if (dbChoice) {
10120
+ if (dbChoice.codingModel) {
10121
+ sessionCodingModel = dbChoice.codingModel;
10122
+ sessionCodingLabel = dbChoice.codingLabel;
10123
+ sessionCodingUrl = dbChoice.codingUrl;
10124
+ }
10125
+ if (dbChoice.codingKeyId) sessionCodingKeyId = dbChoice.codingKeyId;
10126
+ // Refresh the local cache so the file matches the database.
10127
+ try {
10128
+ await updateSessionFile(resolve(process.cwd()), (raw) => {
10129
+ raw.codingKeyId = dbChoice.codingKeyId;
10130
+ raw.codingModel = dbChoice.codingModel;
10131
+ raw.codingLabel = dbChoice.codingLabel;
10132
+ raw.codingUrl = dbChoice.codingUrl;
10133
+ raw.updatedAt = new Date().toISOString();
10134
+ });
10135
+ } catch { /* cache write is best-effort */ }
10136
+ } else if (!dbUnreachable) {
10137
+ // The database WAS reached but has no choice for this folder — fall back to the local file
10138
+ // (a folder chosen before task #225 existed has its choice ONLY in the file). When the
10139
+ // database could NOT be reached, the error above already said so and there is no fallback.
10140
+ const c = await loadSessionCoding(resolve(process.cwd()));
10141
+ if (c) {
10142
+ sessionCodingModel = c.model;
10143
+ sessionCodingLabel = c.label;
10144
+ sessionCodingUrl = c.url;
10145
+ }
10146
+ sessionCodingKeyId = await loadSessionCodingKeyId(resolve(process.cwd())); // task #222
10009
10147
  }
10010
10148
  }
10011
- sessionCodingKeyId = await loadSessionCodingKeyId(resolve(process.cwd())); // task #222
10012
10149
  // Fetch the API key tail after loading the session, if a key is configured
10013
10150
  if (sessionCodingKeyId) {
10014
10151
  try {
@@ -10367,7 +10504,8 @@ async function main() {
10367
10504
  "\n Nothing can run until it knows which Ollama server to use." +
10368
10505
  "\n Start `gu`, then `/model` → change the Ollama server this machine uses." +
10369
10506
  `\n The agent server needs a ${REQUIRED_AGENT_PREFIX} model (e.g. ${REQUIRED_AGENT_MODEL})` +
10370
- `\n and ${REQUIRED_EMBED_MODEL}; the agent and coding models are then yours to pick.`
10507
+ `\n and any ONE of these embedders: ${os.ACCEPTED_EMBED_MODELS.map((m) => m.replace(/:latest$/, "")).join(", ")};` +
10508
+ "\n the agent and coding models are then yours to pick."
10371
10509
  )
10372
10510
  );
10373
10511
  } else {
package/ollama-setup.mjs CHANGED
@@ -19,6 +19,7 @@
19
19
  * returns null rather than a plausible-looking value it cannot stand behind.
20
20
  */
21
21
  import { isLikelyEmbedder } from "./agent-model-command.mjs";
22
+ import { EMBED_CHOICES } from "./embed-model-command.mjs";
22
23
 
23
24
  /** Ollama's native port, used when someone types a bare host or an IP with no port. */
24
25
  export const OLLAMA_DEFAULT_PORT = 11434;
@@ -89,6 +90,30 @@ const sameModel = (a, b) => {
89
90
  return bare(a) === bare(b);
90
91
  };
91
92
 
93
+ /**
94
+ * WHICH EMBEDDERS SATISFY THE REQUIREMENT — any ONE of them, not all, and no longer one exact
95
+ * name (user, 2026-09-23). nomic-embed-text stays the shipped default and the suggested pull;
96
+ * qwen3-embedding:4b (and the rest of /embed-model's known-good list) are equally valid: what
97
+ * must hold is that the embedder is one of ours, because anything else does not error, it
98
+ * returns numbers, and corrupts every vector on disk invisibly. The list comes from
99
+ * embed-model-command.mjs so first-run validation and the /embed-model picker can never
100
+ * disagree about what "supported" means. The default is named first so seeding prefers it.
101
+ */
102
+ export const ACCEPTED_EMBED_MODELS = [
103
+ REQUIRED_EMBED_MODEL,
104
+ ...EMBED_CHOICES.map((c) => c.model).filter((m) => !sameModel(m, REQUIRED_EMBED_MODEL)),
105
+ ];
106
+
107
+ /**
108
+ * Which accepted embedder to seed this box with: the shipped default when present, else the
109
+ * first known-good one the box actually has. Returns "" when the box has none — impossible
110
+ * after checkRequired has passed, but callers fall back on REQUIRED_EMBED_MODEL regardless.
111
+ */
112
+ export function defaultEmbedModel(models) {
113
+ const list = Array.isArray(models) ? models : [];
114
+ return ACCEPTED_EMBED_MODELS.find((e) => list.some((m) => sameModel(m.model, e))) ?? "";
115
+ }
116
+
92
117
  /**
93
118
  * Does this server have what the agent and RAG actually need?
94
119
  *
@@ -106,9 +131,12 @@ export function checkRequired(
106
131
  // `missing` is still a concrete, pullable name — `ollama pull gemma4` is not a command, so
107
132
  // reporting the prefix would produce a line that cannot be run.
108
133
  if (!agentCandidates(list, { prefix: agentPrefix }).length) missing.push(agentExample);
109
- // The EMBEDDER is still exact, and deliberately: a different one does not error, it returns
110
- // numbers, which corrupts every vector on disk invisibly and permanently.
111
- if (!list.some((m) => sameModel(m.model, embed))) missing.push(embed);
134
+ // The EMBEDDER is still a closed list, and deliberately: a different one does not error, it
135
+ // returns numbers, which corrupts every vector on disk invisibly and permanently. But ONE
136
+ // accepted embedder is ENOUGH (user, 2026-09-23) — a box holding qwen3-embedding:4b and no
137
+ // nomic-embed-text is ready; it just embeds with qwen. Only a box with NONE of ours is told
138
+ // to pull, and the name pushed is the shipped default — the line that can actually be run.
139
+ if (!list.some((m) => ACCEPTED_EMBED_MODELS.some((e) => sameModel(m.model, e)))) missing.push(embed);
112
140
  return { ok: missing.length === 0, missing };
113
141
  }
114
142
 
@@ -123,6 +151,14 @@ export function describeNotReady(host, missing) {
123
151
  for (const m of missing ?? []) lines.push(` ${m}`);
124
152
  lines.push("Pull them there, then load again:");
125
153
  for (const m of missing ?? []) lines.push(` ollama pull ${String(m).replace(/:latest$/, "")}`);
154
+ // ONE embedder suffices, so when the embedder is the missing piece say what the OTHER
155
+ // acceptable ones are — telling someone who already has qwen3-embedding:4b to also pull
156
+ // nomic-embed-text would be asking for a model they do not need.
157
+ if ((missing ?? []).some((m) => sameModel(m, REQUIRED_EMBED_MODEL))) {
158
+ lines.push(
159
+ ` (any ONE embedder is enough — ${ACCEPTED_EMBED_MODELS.map((m) => m.replace(/:latest$/, "")).join(" / ")})`
160
+ );
161
+ }
126
162
  return lines;
127
163
  }
128
164
 
@@ -687,7 +723,10 @@ export async function askOllamaSetup(
687
723
  // and gemma4:12b for quality, and only the person running the machine knows which they want.
688
724
  // Not asked when there is exactly one candidate: a menu with a single row is a question with
689
725
  // one answer, and answering it is pure ceremony.
690
- const embed = agent ? REQUIRED_EMBED_MODEL : null;
726
+ // Seed the embedder the box ACTUALLY has: the shipped default when present, else whichever
727
+ // accepted embedder passed the gate — a qwen3-embedding-only box must not be told it now
728
+ // embeds with a model it never had.
729
+ const embed = agent ? defaultEmbedModel(agent.models) || REQUIRED_EMBED_MODEL : null;
691
730
  let chat = "";
692
731
  if (agent) {
693
732
  const cands = agentCandidates(agent.models);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.716",
3
+ "version": "1.0.718",
4
4
  "description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
5
5
  "type": "module",
6
6
  "license": "MIT",