@tiens.nguyen/gu-cli 1.0.717 → 1.0.718
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/embed-model-command.mjs +5 -0
- package/gonext_agent_chat.py +46 -1
- package/gu-repl.mjs +40 -7
- 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,
|
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 { 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"));
|
|
@@ -6325,7 +6329,35 @@ async function chooseEmbedModel() {
|
|
|
6325
6329
|
return;
|
|
6326
6330
|
}
|
|
6327
6331
|
|
|
6328
|
-
|
|
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
|
+
});
|
|
6329
6361
|
if (!saved) {
|
|
6330
6362
|
// A FAILED WRITE MUST NOT LOOK LIKE A SUCCESS. The next turn would keep using the old
|
|
6331
6363
|
// embedder, and the user would be told their index was rebuilt for a change that never
|
|
@@ -10472,7 +10504,8 @@ async function main() {
|
|
|
10472
10504
|
"\n Nothing can run until it knows which Ollama server to use." +
|
|
10473
10505
|
"\n Start `gu`, then `/model` → change the Ollama server this machine uses." +
|
|
10474
10506
|
`\n The agent server needs a ${REQUIRED_AGENT_PREFIX} model (e.g. ${REQUIRED_AGENT_MODEL})` +
|
|
10475
|
-
`\n and
|
|
10507
|
+
`\n and any ONE of these embedders: ${os.ACCEPTED_EMBED_MODELS.map((m) => m.replace(/:latest$/, "")).join(", ")};` +
|
|
10508
|
+
"\n the agent and coding models are then yours to pick."
|
|
10476
10509
|
)
|
|
10477
10510
|
);
|
|
10478
10511
|
} else {
|
package/ollama-setup.mjs
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* returns null rather than a plausible-looking value it cannot stand behind.
|
|
20
20
|
*/
|
|
21
21
|
import { isLikelyEmbedder } from "./agent-model-command.mjs";
|
|
22
|
+
import { EMBED_CHOICES } from "./embed-model-command.mjs";
|
|
22
23
|
|
|
23
24
|
/** Ollama's native port, used when someone types a bare host or an IP with no port. */
|
|
24
25
|
export const OLLAMA_DEFAULT_PORT = 11434;
|
|
@@ -89,6 +90,30 @@ const sameModel = (a, b) => {
|
|
|
89
90
|
return bare(a) === bare(b);
|
|
90
91
|
};
|
|
91
92
|
|
|
93
|
+
/**
|
|
94
|
+
* WHICH EMBEDDERS SATISFY THE REQUIREMENT — any ONE of them, not all, and no longer one exact
|
|
95
|
+
* name (user, 2026-09-23). nomic-embed-text stays the shipped default and the suggested pull;
|
|
96
|
+
* qwen3-embedding:4b (and the rest of /embed-model's known-good list) are equally valid: what
|
|
97
|
+
* must hold is that the embedder is one of ours, because anything else does not error, it
|
|
98
|
+
* returns numbers, and corrupts every vector on disk invisibly. The list comes from
|
|
99
|
+
* embed-model-command.mjs so first-run validation and the /embed-model picker can never
|
|
100
|
+
* disagree about what "supported" means. The default is named first so seeding prefers it.
|
|
101
|
+
*/
|
|
102
|
+
export const ACCEPTED_EMBED_MODELS = [
|
|
103
|
+
REQUIRED_EMBED_MODEL,
|
|
104
|
+
...EMBED_CHOICES.map((c) => c.model).filter((m) => !sameModel(m, REQUIRED_EMBED_MODEL)),
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Which accepted embedder to seed this box with: the shipped default when present, else the
|
|
109
|
+
* first known-good one the box actually has. Returns "" when the box has none — impossible
|
|
110
|
+
* after checkRequired has passed, but callers fall back on REQUIRED_EMBED_MODEL regardless.
|
|
111
|
+
*/
|
|
112
|
+
export function defaultEmbedModel(models) {
|
|
113
|
+
const list = Array.isArray(models) ? models : [];
|
|
114
|
+
return ACCEPTED_EMBED_MODELS.find((e) => list.some((m) => sameModel(m.model, e))) ?? "";
|
|
115
|
+
}
|
|
116
|
+
|
|
92
117
|
/**
|
|
93
118
|
* Does this server have what the agent and RAG actually need?
|
|
94
119
|
*
|
|
@@ -106,9 +131,12 @@ export function checkRequired(
|
|
|
106
131
|
// `missing` is still a concrete, pullable name — `ollama pull gemma4` is not a command, so
|
|
107
132
|
// reporting the prefix would produce a line that cannot be run.
|
|
108
133
|
if (!agentCandidates(list, { prefix: agentPrefix }).length) missing.push(agentExample);
|
|
109
|
-
// The EMBEDDER is still
|
|
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.718",
|
|
4
4
|
"description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|