@tiens.nguyen/gu-cli 1.0.718 → 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.
@@ -86,6 +86,32 @@ export function embedChoiceRows(current) {
86
86
  return rows;
87
87
  }
88
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
+
89
115
  /**
90
116
  * WHAT SWITCHING EMBEDDERS DOES TO WHAT IS ALREADY INDEXED — the half of this command that is
91
117
  * not a menu.
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 } 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.
@@ -6274,9 +6274,8 @@ async function fetchWorkerSettings() {
6274
6274
  return null; // offline — callers fall back to showing what they know
6275
6275
  }
6276
6276
  }
6277
-
6278
6277
  /**
6279
- * `/embed-model` — switch the RAG embedder (task #169).
6278
+ * `/embed-model` — switch the RAG embedder, the embed server, or the embed key (task #169).
6280
6279
  *
6281
6280
  * NOT THE SAME SHAPE AS /model, and the difference is the point. A coding model can be swapped
6282
6281
  * between turns with no consequences; an embedder decides the shape of every vector already on
@@ -6284,99 +6283,305 @@ async function fetchWorkerSettings() {
6284
6283
  * over two models returns a number, rag_search prints its top-k, and the model answers from it.
6285
6284
  * So this command shows what it is about to invalidate and asks, instead of just writing.
6286
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
+ *
6287
6294
  * The decisions live in embed-model-command.mjs; this is the terminal half only.
6288
6295
  */
6289
- async function chooseEmbedModel() {
6290
- const ec = await import("./embed-model-command.mjs");
6291
- const settings = await withPendingStatus("reading your embedder", fetchWorkerSettings);
6292
- if (!settings) {
6293
- console.log(red(" couldn't reach the API to read your current embedder.\n"));
6294
- return;
6295
- }
6296
- const current = ec.currentEmbedModel(settings);
6297
- const rows = ec.embedChoiceRows(current);
6298
- console.log(dim(` Embedding model (currently ${current})`));
6299
- console.log(dim(` Choose the embedder (${PICK_HINT}):`));
6300
- const labels = rows.map(
6301
- (r) => `${r.model}${r.dims ? dim(` · ${r.dims}d`) : ""}${dim(` · ${r.note}`)}` +
6302
- (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)
6303
6305
  );
6304
- const idx = await pickFromList(labels, Math.max(0, rows.findIndex((r) => r.current)));
6305
- if (idx < 0) {
6306
- console.log(dim(" (cancelled)\n"));
6307
- 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: "" };
6308
6344
  }
6309
- 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
+ }
6310
6353
 
6311
- // READ THE MANIFESTS BEFORE ASKING. "Are you sure?" with nothing behind it is a prompt people
6312
- // learn to dismiss; naming the four projects that stop working is a decision they can make.
6313
- 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) {
6314
6359
  let indexes = [];
6315
6360
  try {
6361
+ const md = await import("./model-doctor.mjs");
6316
6362
  indexes = await md.listLocalRagIndexes();
6317
6363
  } catch {
6318
- /* 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 */
6319
6365
  }
6320
6366
  const plan = ec.embedChangePlan({ current, chosen, indexes });
6321
6367
  if (!plan.changed) {
6322
6368
  console.log(dim(` already using ${current} — nothing changed.\n`));
6323
- return;
6369
+ return { changed: false, plan: null };
6324
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.
6325
6373
  for (const l of ec.describeEmbedChange(plan)) console.log(GUTTER + dim(l));
6326
6374
  const go = await pickFromList([`switch to ${chosen}`, `keep ${current}`], plan.stale.length ? 1 : 0);
6327
6375
  if (go !== 0) {
6328
6376
  console.log(dim(" (kept)\n"));
6329
- return;
6377
+ return { changed: false, plan: null };
6330
6378
  }
6379
+ return { changed: true, plan };
6380
+ }
6331
6381
 
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
- });
6361
- if (!saved) {
6362
- // A FAILED WRITE MUST NOT LOOK LIKE A SUCCESS. The next turn would keep using the old
6363
- // embedder, and the user would be told their index was rebuilt for a change that never
6364
- // happened — worse than the original confusion, because now they trust it.
6365
- console.log(red(` couldn't save that to your account — still using ${current}.\n`));
6366
- 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 };
6367
6394
  }
6368
- console.log(accent(` ✓ embedding model → ${chosen}`));
6369
- 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) {
6370
6408
  console.log(
6371
6409
  dim(` ${plan.stale.length} knowledge base${plan.stale.length === 1 ? "" : "s"} ` +
6372
6410
  "will rebuild on the next search — the first one will be slower.")
6373
6411
  );
6374
6412
  }
6375
- // Ollama serves the embedder itself, so a model that was never pulled fails at the first
6376
- // search rather than here. Say it once, rather than let it surface as "RAG is broken".
6377
6413
  console.log(dim(` Make sure it is pulled there: ollama pull ${chosen}\n`));
6378
6414
  }
6379
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
+
6380
6585
  /** Save the agent model on this machine: the URL the API sends turns to, and the model name. */
6381
6586
  async function saveAgentModel(agentUrl, model, host) {
6382
6587
  const md = await import("./model-doctor.mjs");
@@ -10504,7 +10709,7 @@ async function main() {
10504
10709
  "\n Nothing can run until it knows which Ollama server to use." +
10505
10710
  "\n Start `gu`, then `/model` → change the Ollama server this machine uses." +
10506
10711
  `\n The agent server needs a ${REQUIRED_AGENT_PREFIX} model (e.g. ${REQUIRED_AGENT_MODEL})` +
10507
- `\n and any ONE of these embedders: ${os.ACCEPTED_EMBED_MODELS.map((m) => m.replace(/:latest$/, "")).join(", ")};` +
10712
+ `\n and any ONE of these embedders: ${ACCEPTED_EMBED_MODELS.map((m) => m.replace(/:latest$/, "")).join(", ")};` +
10508
10713
  "\n the agent and coding models are then yours to pick."
10509
10714
  )
10510
10715
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.718",
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",