@tiens.nguyen/gu-cli 1.0.717 → 1.0.719

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,
@@ -81,6 +86,32 @@ export function embedChoiceRows(current) {
81
86
  return rows;
82
87
  }
83
88
 
89
+ /**
90
+ * THE /embed-model MENU — the agent-model parity half (#169 follow-up).
91
+ *
92
+ * Choosing WHICH embedder is only one of the decisions; the others are WHERE it runs and HOW it
93
+ * authenticates. A chat model swap is reversible and cheap, so /agent-model offers its server its
94
+ * own row; an embedder swap rewrites every vector on disk, so here the model and the server are
95
+ * ONE decision — but the server and its credential still deserve to be set on their own, for the
96
+ * same reason /agent-model lets you point at another OpenAI-compatible provider or re-key
97
+ * ollama.com: the embedder may live on a remote Ollama, on ollama.com, or behind an
98
+ * OpenAI-compatible /v1/embeddings, and each wants a key named so it stays readable.
99
+ *
100
+ * Pure, like the rest of this module: the REPL owns prompt/save; this owns the SHAPE of the menu.
101
+ * `host` is the host the embedder is currently configured against ("" = not yet); `keyRequired`
102
+ * is the REPL's read of isOllamaCloudUrl(url) || an OpenAI row — passed in, not derived, so this
103
+ * never re-imports the network-layer predicate.
104
+ */
105
+ export function embedMenuOptions({ current = DEFAULT_EMBED_MODEL, host = "", keyRequired = false } = {}) {
106
+ const where = host || "this server";
107
+ return [
108
+ { action: "model", label: `switch the embedder model (currently ${current})` },
109
+ { action: "ollama", label: "use an Ollama server for embedding…" },
110
+ { action: "provider", label: "use another OpenAI-compatible provider…" },
111
+ ...(keyRequired ? [{ action: "key", label: `enter a new API key for ${where}…` }] : []),
112
+ ];
113
+ }
114
+
84
115
  /**
85
116
  * WHAT SWITCHING EMBEDDERS DOES TO WHAT IS ALREADY INDEXED — the half of this command that is
86
117
  * not a menu.
@@ -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 { ACCEPTED_EMBED_MODELS, 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
@@ -5075,7 +5075,7 @@ async function reconfigureOllamaServer() {
5075
5075
  agentModel: chat,
5076
5076
  ragEnabled: true,
5077
5077
  ragEmbedUrl: agent.normalized.url,
5078
- ragEmbedModel: os.REQUIRED_EMBED_MODEL,
5078
+ ragEmbedModel: os.defaultEmbedModel?.(agent.models) ?? os.REQUIRED_EMBED_MODEL,
5079
5079
  });
5080
5080
  }
5081
5081
  if (coder) {
@@ -5161,7 +5161,7 @@ async function reconfigureOllamaServer() {
5161
5161
  console.log(accent(" ✓ models saved"));
5162
5162
  if (agent) {
5163
5163
  console.log(dim(` chat ${chat} @ ${agent.normalized.host}`));
5164
- 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}`));
5165
5165
  } else {
5166
5166
  console.log(dim(" chat — not set (agent server skipped)"));
5167
5167
  }
@@ -5171,7 +5171,11 @@ async function reconfigureOllamaServer() {
5171
5171
  : dim(" code — not set (coding server skipped)")
5172
5172
  );
5173
5173
  if (extras.length) console.log(dim(` also ${extras.join(", ")} — offered by /model`));
5174
- 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) {
5175
5179
  console.log(dim(" the embedder changed — existing RAG indexes need rebuilding (/rag)."));
5176
5180
  }
5177
5181
  console.log(dim(" takes effect on your next question.\n"));
@@ -6270,9 +6274,8 @@ async function fetchWorkerSettings() {
6270
6274
  return null; // offline — callers fall back to showing what they know
6271
6275
  }
6272
6276
  }
6273
-
6274
6277
  /**
6275
- * `/embed-model` — switch the RAG embedder (task #169).
6278
+ * `/embed-model` — switch the RAG embedder, the embed server, or the embed key (task #169).
6276
6279
  *
6277
6280
  * NOT THE SAME SHAPE AS /model, and the difference is the point. A coding model can be swapped
6278
6281
  * between turns with no consequences; an embedder decides the shape of every vector already on
@@ -6280,71 +6283,305 @@ async function fetchWorkerSettings() {
6280
6283
  * over two models returns a number, rag_search prints its top-k, and the model answers from it.
6281
6284
  * So this command shows what it is about to invalidate and asks, instead of just writing.
6282
6285
  *
6286
+ * Same menu shape as /agent-model (#44) now that one embed setup is three decisions: WHICH model
6287
+ * (model), WHERE it runs (ollama / an OpenAI-compatible provider) and HOW it authenticates (key).
6288
+ * Only a model change rebuilds indexes — moving the SAME embedder to another server, or changing
6289
+ * only its key, leaves every vector valid, so those paths save without the plan wall. The worker
6290
+ * already speaks both dialects: gonext_agent_chat._embed() POSTs {base}/v1/embeddings first, then
6291
+ * Ollama /api/embed and /api/embeddings, and sends the "embed-ollama" store key as a Bearer when
6292
+ * one exists — so pointing at an OpenAI-compatible provider here needs no worker-side change.
6293
+ *
6283
6294
  * The decisions live in embed-model-command.mjs; this is the terminal half only.
6284
6295
  */
6285
- async function chooseEmbedModel() {
6286
- const ec = await import("./embed-model-command.mjs");
6287
- const settings = await withPendingStatus("reading your embedder", fetchWorkerSettings);
6288
- if (!settings) {
6289
- console.log(red(" couldn't reach the API to read your current embedder.\n"));
6290
- return;
6291
- }
6292
- const current = ec.currentEmbedModel(settings);
6293
- const rows = ec.embedChoiceRows(current);
6294
- console.log(dim(` Embedding model (currently ${current})`));
6295
- console.log(dim(` Choose the embedder (${PICK_HINT}):`));
6296
- const labels = rows.map(
6297
- (r) => `${r.model}${r.dims ? dim(` · ${r.dims}d`) : ""}${dim(` · ${r.note}`)}` +
6298
- (r.current ? accent(" (current)") : "")
6296
+
6297
+ // THE SAVE, ONE WAY. Every sub-flow ends at the same write so the failure wording and the
6298
+ // red-not-dim treatment can't drift: a failed write must never look like a success, or the next
6299
+ // turn keeps using the old embedder while the user was told the index was rebuilt for a change
6300
+ // that never landed — worse than the original confusion (#169 again).
6301
+ async function saveEmbedSettings(fields) {
6302
+ const md = await import("./model-doctor.mjs");
6303
+ const saved = await withPendingStatus("saving your embedder", () =>
6304
+ md.saveWorkerSettings(apiBase, workerKey, fields)
6299
6305
  );
6300
- const idx = await pickFromList(labels, Math.max(0, rows.findIndex((r) => r.current)));
6301
- if (idx < 0) {
6302
- console.log(dim(" (cancelled)\n"));
6303
- return;
6306
+ if (!saved) console.log(red(" couldn't save that to your account — nothing changed.\n"));
6307
+ return saved;
6308
+ }
6309
+
6310
+ // WHERE TO EMBED, ASKED ONCE, SHARED. The model sub-flow and the ollama-server action both need
6311
+ // "which Ollama serves the embeddings"; it used to live inline, which meant a second copy could
6312
+ // never exist. Enter keeps the current URL; Ctrl+C/Escape returns null and the caller just stops.
6313
+ // Anything that is not an Ollama-shaped host is rejected with the reason, then asked AGAIN —
6314
+ // ollama-host-snippets' why line IS the retry message.
6315
+ async function pickEmbedServerUrl({ currentUrl }) {
6316
+ const ohs = await import("./ollama-host-snippets.mjs");
6317
+ for (;;) {
6318
+ const urlIn = await ask(` Ollama server URL for embedding [${currentUrl}]: `);
6319
+ if (urlIn === null) return null;
6320
+ const typed = urlIn.trim();
6321
+ if (!typed) return currentUrl;
6322
+ const embedUrl = /^https?:\/\//i.test(typed) ? typed : `http://${typed}`;
6323
+ const r = ohs.checkOllamaHostInput(embedUrl);
6324
+ if (r.ok) return embedUrl;
6325
+ console.log(GUTTER + dim(` "${typed}" is ${r.why} — try host:port, e.g. 127.0.0.1:11434`));
6326
+ }
6327
+ }
6328
+
6329
+ // THE KEY, ASKED ONCE, SHARED. ollama.com cannot proceed without one; the OpenAI-compatible
6330
+ // provider row asks the same way (Enter skips it — a local vLLM answers anonymously). All embed
6331
+ // keys land under the fixed backendId "embed-ollama" because that is the id
6332
+ // gonext_agent_chat._rag_resolve_embed_api_key() fetches, whatever the server is; ragEmbedKeyId
6333
+ // is just the human label the settings panel shows. Returns { cancelled, keyId } — a cancelled
6334
+ // prompt writes NOTHING, and a failed store keeps the key out of the settings so the user is not
6335
+ // told they authenticated when they did not.
6336
+ async function askEmbedKey(url, { optional = false } = {}) {
6337
+ console.log(GUTTER + dim('A name just tells keys apart — "embed", "work".'));
6338
+ const name = (await askKeyName()).trim();
6339
+ console.log(GUTTER + dim(SECRET_KEY_HINT));
6340
+ const key = (await askSecret(` API key for ${url}${name ? ` (${name})` : ""}: `)).trim();
6341
+ if (!key) {
6342
+ if (optional) console.log(GUTTER + dim("(no key — the provider will be called anonymously)"));
6343
+ return { cancelled: !optional, keyId: "", key: "" };
6304
6344
  }
6305
- const chosen = rows[idx].model;
6345
+ const id = await withPendingStatus("saving the key", () => addCodingKey(name || "embed", key, "embed-ollama"));
6346
+ if (!id) {
6347
+ console.log(red(" couldn't save the embed key — searching stays unauthenticated.\n"));
6348
+ return { cancelled: true, keyId: "", key: "" };
6349
+ }
6350
+ console.log(GUTTER + dim(`embed key "${name || id}" saved.\n`));
6351
+ return { cancelled: false, keyId: id, key };
6352
+ }
6306
6353
 
6307
- // READ THE MANIFESTS BEFORE ASKING. "Are you sure?" with nothing behind it is a prompt people
6308
- // learn to dismiss; naming the four projects that stop working is a decision they can make.
6309
- const md = await import("./model-doctor.mjs");
6354
+ // THE RECOGNITION STEP, ONE WAY. Switching embedders invalidates the indexes; naming the projects
6355
+ // that stop working is the decision the user is actually making, so the plan is read BEFORE the
6356
+ // "switch / keep" picker — never after, never implied. Returns { changed:false } when the user
6357
+ // keeps the current embedder or the choice is a no-op.
6358
+ async function confirmEmbedSwitch(ec, current, chosen) {
6310
6359
  let indexes = [];
6311
6360
  try {
6361
+ const md = await import("./model-doctor.mjs");
6312
6362
  indexes = await md.listLocalRagIndexes();
6313
6363
  } catch {
6314
- /* unreadable store — the plan degrades to "we can't tell", handled below */
6364
+ /* unreadable store — the plan degrades to "we can't tell", handled by embedChangePlan */
6315
6365
  }
6316
6366
  const plan = ec.embedChangePlan({ current, chosen, indexes });
6317
6367
  if (!plan.changed) {
6318
6368
  console.log(dim(` already using ${current} — nothing changed.\n`));
6319
- return;
6369
+ return { changed: false, plan: null };
6320
6370
  }
6371
+ // "Are you sure?" with nothing behind it is a prompt people learn to dismiss; the four projects
6372
+ // that stop working are a decision they can make.
6321
6373
  for (const l of ec.describeEmbedChange(plan)) console.log(GUTTER + dim(l));
6322
6374
  const go = await pickFromList([`switch to ${chosen}`, `keep ${current}`], plan.stale.length ? 1 : 0);
6323
6375
  if (go !== 0) {
6324
6376
  console.log(dim(" (kept)\n"));
6325
- return;
6377
+ return { changed: false, plan: null };
6326
6378
  }
6379
+ return { changed: true, plan };
6380
+ }
6327
6381
 
6328
- const saved = await md.saveWorkerSettings(apiBase, workerKey, { ragEmbedModel: chosen });
6329
- if (!saved) {
6330
- // A FAILED WRITE MUST NOT LOOK LIKE A SUCCESS. The next turn would keep using the old
6331
- // embedder, and the user would be told their index was rebuilt for a change that never
6332
- // happened — worse than the original confusion, because now they trust it.
6333
- console.log(red(` couldn't save that to your account — still using ${current}.\n`));
6334
- return;
6382
+ // WHICH EMBEDDER, ASKED ONCE. The model sub-flow of /embed-model: the curated picker, then the
6383
+ // shared recognition step. Returns { changed, chosen, plan }.
6384
+ async function pickEmbedModel(ec, current) {
6385
+ const rows = ec.embedChoiceRows(current);
6386
+ console.log(dim(` Choose the embedder (${PICK_HINT}):`));
6387
+ const labels = rows.map(
6388
+ (r) => `${r.model}${r.dims ? dim(` · ${r.dims}d`) : ""}${dim(` · ${r.note}`)}` + (r.current ? accent(" (current)") : "")
6389
+ );
6390
+ const idx = await pickFromList(labels, Math.max(0, rows.findIndex((r) => r.current)));
6391
+ if (idx < 0) {
6392
+ console.log(dim(" (cancelled)\n"));
6393
+ return { changed: false, chosen: "", plan: null };
6335
6394
  }
6336
- console.log(accent(` ✓ embedding model → ${chosen}`));
6337
- if (plan.stale.length) {
6395
+ const chosen = rows[idx].model;
6396
+ const sw = await confirmEmbedSwitch(ec, current, chosen);
6397
+ return { changed: sw.changed, chosen: sw.changed ? chosen : "", plan: sw.plan };
6398
+ }
6399
+
6400
+ // WHAT A SAVED CHANGE SOUNDS LIKE. Success lines used to live at the end of one long function;
6401
+ // four exit paths now say them, so they are said once here: the embedder line, the rebuild note
6402
+ // that only a REAL model change earns, and the pull hint — Ollama serves the embedder itself, so
6403
+ // a model that was never pulled fails at the first search, not here. Say it once rather than let
6404
+ // it surface as "RAG is broken".
6405
+ function announceEmbedSaved({ chosen, plan, url }) {
6406
+ console.log(accent(` ✓ embedding model → ${chosen}` + (url ? ` on ${hostOf(url)}` : "")));
6407
+ if (plan?.stale.length) {
6338
6408
  console.log(
6339
6409
  dim(` ${plan.stale.length} knowledge base${plan.stale.length === 1 ? "" : "s"} ` +
6340
6410
  "will rebuild on the next search — the first one will be slower.")
6341
6411
  );
6342
6412
  }
6343
- // Ollama serves the embedder itself, so a model that was never pulled fails at the first
6344
- // search rather than here. Say it once, rather than let it surface as "RAG is broken".
6345
6413
  console.log(dim(` Make sure it is pulled there: ollama pull ${chosen}\n`));
6346
6414
  }
6347
6415
 
6416
+ async function chooseEmbedModel() {
6417
+ const ec = await import("./embed-model-command.mjs");
6418
+ const settings = await withPendingStatus("reading your embedder", fetchWorkerSettings);
6419
+ if (!settings) {
6420
+ console.log(red(" couldn't reach the API to read your current embedder.\n"));
6421
+ return;
6422
+ }
6423
+ const current = ec.currentEmbedModel(settings);
6424
+ const currentUrl = String(settings.ragEmbedUrl || "").trim() || "http://127.0.0.1:11434";
6425
+ const host = hostOf(currentUrl);
6426
+ // WHEN TO SHOW THE KEY ROW (embedMenuOptions' keyRequired): a key is clearly the question when
6427
+ // the server is ollama.com (it rejects anonymous calls) or already looks OpenAI-compatible (the
6428
+ // /v1 shape), or when one is already bound — replacing the key people can no longer read is the
6429
+ // very thing they come back for. A keyless localhost Ollama sees three rows instead.
6430
+ const keyRequired = isOllamaCloudUrl(currentUrl) || /\/v1\/?$/i.test(currentUrl) ||
6431
+ Boolean(String(settings.ragEmbedKeyId ?? "").trim());
6432
+ const menu = ec.embedMenuOptions({ current, host, keyRequired });
6433
+ console.log(dim(` Embedding model (currently ${current} on ${host || currentUrl})`));
6434
+ const mi = await pickFromList(menu.map((o) => o.label), 0);
6435
+ const action = mi >= 0 ? menu[mi].action : "cancel";
6436
+ if (action === "cancel") {
6437
+ console.log(dim(" (cancelled)\n"));
6438
+ return;
6439
+ }
6440
+
6441
+ if (action === "model") {
6442
+ // WHICH + WHERE, one pass: the recognition wall first (it is the expensive decision), then
6443
+ // the server prompt, then — only on ollama.com — the key. Same addition coding and agent
6444
+ // models already have (#127): the key is named, stored in the shared account store
6445
+ // (settings.agentCodingApiKeys) tagged "embed-ollama", and its id recorded in ragEmbedKeyId so
6446
+ // the embedder can name what it sends. Cancelled url/key prompts abort cleanly: the key, if
6447
+ // typed, is already in the store and the settings write is simply skipped.
6448
+ const { changed, chosen, plan } = await pickEmbedModel(ec, current);
6449
+ if (!changed) return;
6450
+ const embedUrl = await pickEmbedServerUrl({ currentUrl });
6451
+ if (embedUrl === null) {
6452
+ console.log(dim(" (cancelled)\n"));
6453
+ return;
6454
+ }
6455
+ let embedKeyId = settings.ragEmbedKeyId ?? "";
6456
+ if (isOllamaCloudUrl(embedUrl)) {
6457
+ const k = await askEmbedKey(embedUrl);
6458
+ if (k.cancelled) return;
6459
+ embedKeyId = k.keyId;
6460
+ }
6461
+ // A FAILED WRITE MUST NOT LOOK LIKE A SUCCESS (#169): the next turn would keep using the old
6462
+ // embedder while the user is told the index was rebuilt for a change that never happened.
6463
+ if (!(await saveEmbedSettings({ ragEmbedModel: chosen, ragEmbedUrl: embedUrl, ragEmbedKeyId: embedKeyId }))) return;
6464
+ announceEmbedSaved({ chosen, plan, url: embedUrl });
6465
+ return;
6466
+ }
6467
+
6468
+ if (action === "ollama") {
6469
+ // SAME MODEL, NEW OLLAMA. Vectors come from the model, not the box — moving nomic-embed-text
6470
+ // from 127.0.0.1 to another Ollama changes no numbers, so no wall and no rebuild. The
6471
+ // ollama.com arm still collects the key; a keyless host clears the stale ragEmbedKeyId label.
6472
+ const embedUrl = await pickEmbedServerUrl({ currentUrl });
6473
+ if (embedUrl === null) {
6474
+ console.log(dim(" (cancelled)\n"));
6475
+ return;
6476
+ }
6477
+ let embedKeyId = "";
6478
+ if (isOllamaCloudUrl(embedUrl)) {
6479
+ const k = await askEmbedKey(embedUrl);
6480
+ if (k.cancelled) return;
6481
+ embedKeyId = k.keyId;
6482
+ }
6483
+ // Same box, no new key: keep the no-op honest instead of writing noise over the settings.
6484
+ if (embedUrl === currentUrl && !embedKeyId) {
6485
+ console.log(dim(` already embedding on ${host || currentUrl} — nothing changed.\n`));
6486
+ return;
6487
+ }
6488
+ if (!(await saveEmbedSettings({ ragEmbedUrl: embedUrl, ragEmbedKeyId: embedKeyId }))) return;
6489
+ console.log(accent(` ✓ embedding server → ${hostOf(embedUrl)}`));
6490
+ console.log(dim(` ${current} did not change, so no knowledge base rebuilds — the same model just answers from another box.\n`));
6491
+ return;
6492
+ }
6493
+
6494
+ if (action === "provider") {
6495
+ // ANOTHER OPENAI-COMPATIBLE PROVIDER. _embed() tries {base}/v1/embeddings first, so any
6496
+ // /v1-shaped server works — vLLM, OpenRouter, LM Studio, a hosted gateway. The key comes
6497
+ // BEFORE the list: most providers 401 an anonymous /v1/models, and a keyless local server
6498
+ // just answers, so Enter skips the key. The model id then comes from the provider's own
6499
+ // /v1/models when it answers, typed EXACTLY as the provider calls it when it does not — the
6500
+ // worker POSTs the string verbatim, so both are supported.
6501
+ const ps = await import("./coding-provider-setup.mjs");
6502
+ let base = "";
6503
+ for (;;) {
6504
+ const raw = await ask(` provider base URL (e.g. https://openrouter.ai/api/v1): `);
6505
+ if (raw === null || !raw.trim()) {
6506
+ console.log(dim(" (cancelled)\n"));
6507
+ return;
6508
+ }
6509
+ const typed = raw.trim();
6510
+ base = /^https?:\/\//i.test(typed) ? typed : `https://${typed}`;
6511
+ try {
6512
+ new URL(base);
6513
+ break;
6514
+ } catch {
6515
+ console.log(yellow(" that is not a URL — try https://host or https://host/v1"));
6516
+ }
6517
+ }
6518
+ base = ps.ensureV1Root(base);
6519
+ const providerHost = hostOf(base);
6520
+ const k = await askEmbedKey(base, { optional: true });
6521
+ const embedKeyId = k.keyId;
6522
+ const { proxyFetch } = await import("./proxy-dispatcher.mjs");
6523
+ let chosen = "";
6524
+ let listed = [];
6525
+ try {
6526
+ const am = await import("./agent-model-command.mjs");
6527
+ const r = await withPendingStatus(`asking ${providerHost} for its models`, () =>
6528
+ proxyFetch(`${base}/models`, { headers: am.agentListHeaders(k.key || ""), signal: AbortSignal.timeout(12000) })
6529
+ );
6530
+ if (r.ok) listed = am.agentModelsFromBody("openai", await r.json().catch(() => null));
6531
+ } catch { /* an unreachable list is not a dead end — typing is fully supported, below */ }
6532
+ if (listed.length) {
6533
+ // Embedders first, the rest after, each once: the provider's catalog is mostly chat models,
6534
+ // and burying "text-embedding-3-large" under 200 of them is a menu nobody can use.
6535
+ const am = await import("./agent-model-command.mjs");
6536
+ const ordered = [...listed].sort((a, b) =>
6537
+ Number(am.isLikelyEmbedder(b)) - Number(am.isLikelyEmbedder(a)) || a.model.localeCompare(b.model));
6538
+ const labels = ordered.map((r) =>
6539
+ `${r.model}${r.note ? dim(` · ${r.note}`) : ""}` + (ec.sameEmbedModel(r.model, current) ? accent(" (current)") : ""));
6540
+ const li = await pickFromList(labels, Math.max(0, ordered.findIndex((r) => ec.sameEmbedModel(r.model, current))));
6541
+ if (li < 0) {
6542
+ console.log(dim(" (cancelled)\n"));
6543
+ return;
6544
+ }
6545
+ chosen = ordered[li].model;
6546
+ } else {
6547
+ console.log(dim(` ${providerHost} did not list its models — type the embedding model id exactly as ${providerHost} calls it.`));
6548
+ const raw = await ask(` embedding model id [${current}]: `);
6549
+ if (raw === null) {
6550
+ console.log(dim(" (cancelled)\n"));
6551
+ return;
6552
+ }
6553
+ chosen = raw.trim() || current;
6554
+ }
6555
+ // A MODEL CHANGE THROUGH THE PROVIDER ROW IS STILL A REINDEX — same wall, same wording. Same
6556
+ // model = a pure server/key move, and no knowledge base needs a rebuild.
6557
+ let plan = null;
6558
+ if (!ec.sameEmbedModel(chosen, current)) {
6559
+ const sw = await confirmEmbedSwitch(ec, current, chosen);
6560
+ if (!sw.changed) return;
6561
+ plan = sw.plan;
6562
+ }
6563
+ const fields = { ragEmbedUrl: base, ragEmbedKeyId: embedKeyId };
6564
+ if (chosen) fields.ragEmbedModel = chosen;
6565
+ if (!(await saveEmbedSettings(fields))) return;
6566
+ if (plan) announceEmbedSaved({ chosen, plan, url: base });
6567
+ else {
6568
+ console.log(accent(` ✓ embedding provider → ${providerHost}`));
6569
+ console.log(dim(` ${chosen} did not change, so no knowledge base rebuilds — the same model just answers from ${providerHost}.\n`));
6570
+ }
6571
+ return;
6572
+ }
6573
+
6574
+ if (action === "key") {
6575
+ // A NEW KEY FOR THE SERVER ALREADY CHOSEN. The model and the URL stay; only the credential
6576
+ // rotates — same three steps as everywhere else (name → secret → store under "embed-ollama").
6577
+ const k = await askEmbedKey(host || currentUrl);
6578
+ if (k.cancelled) return;
6579
+ if (!(await saveEmbedSettings({ ragEmbedKeyId: k.keyId }))) return;
6580
+ console.log(accent(` ✓ embedding key → "${k.keyId}"`));
6581
+ console.log(dim(` ${current} still embeds on ${host || currentUrl} — the new key signs the next request.\n`));
6582
+ }
6583
+ }
6584
+
6348
6585
  /** Save the agent model on this machine: the URL the API sends turns to, and the model name. */
6349
6586
  async function saveAgentModel(agentUrl, model, host) {
6350
6587
  const md = await import("./model-doctor.mjs");
@@ -10472,7 +10709,8 @@ async function main() {
10472
10709
  "\n Nothing can run until it knows which Ollama server to use." +
10473
10710
  "\n Start `gu`, then `/model` → change the Ollama server this machine uses." +
10474
10711
  `\n The agent server needs a ${REQUIRED_AGENT_PREFIX} model (e.g. ${REQUIRED_AGENT_MODEL})` +
10475
- `\n and ${REQUIRED_EMBED_MODEL}; the agent and coding models are then yours to pick.`
10712
+ `\n and any ONE of these embedders: ${ACCEPTED_EMBED_MODELS.map((m) => m.replace(/:latest$/, "")).join(", ")};` +
10713
+ "\n the agent and coding models are then yours to pick."
10476
10714
  )
10477
10715
  );
10478
10716
  } 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.717",
3
+ "version": "1.0.719",
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",