@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.
- package/embed-model-command.mjs +31 -0
- package/gonext_agent_chat.py +46 -1
- package/gu-repl.mjs +282 -44
- package/ollama-setup.mjs +43 -4
- package/package.json +1 -1
package/embed-model-command.mjs
CHANGED
|
@@ -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.
|
package/gonext_agent_chat.py
CHANGED
|
@@ -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=
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
6286
|
-
|
|
6287
|
-
|
|
6288
|
-
|
|
6289
|
-
|
|
6290
|
-
|
|
6291
|
-
|
|
6292
|
-
const
|
|
6293
|
-
|
|
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
|
-
|
|
6301
|
-
|
|
6302
|
-
|
|
6303
|
-
|
|
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
|
|
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
|
-
|
|
6308
|
-
|
|
6309
|
-
|
|
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
|
|
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
|
-
|
|
6329
|
-
|
|
6330
|
-
|
|
6331
|
-
|
|
6332
|
-
|
|
6333
|
-
|
|
6334
|
-
|
|
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
|
-
|
|
6337
|
-
|
|
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
|
|
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
|
|
110
|
-
// numbers, which corrupts every vector on disk invisibly and permanently.
|
|
111
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|