@tiens.nguyen/gu-cli 1.0.686

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.
Files changed (43) hide show
  1. package/README.md +52 -0
  2. package/agent-model-command.mjs +259 -0
  3. package/agent-model-label.mjs +159 -0
  4. package/clear-state.mjs +149 -0
  5. package/client-expert-api.mjs +736 -0
  6. package/client-expert-run.mjs +892 -0
  7. package/client-expert-setup.mjs +616 -0
  8. package/coding-choice-tags.mjs +69 -0
  9. package/coding-key-prompt.mjs +229 -0
  10. package/coding-provider-setup.mjs +808 -0
  11. package/completed-flush.mjs +105 -0
  12. package/daemon-control.mjs +462 -0
  13. package/device-login.mjs +212 -0
  14. package/doctor-check.mjs +239 -0
  15. package/embed-model-command.mjs +157 -0
  16. package/first-run-steps.mjs +171 -0
  17. package/gonext_agent_chat.py +12299 -0
  18. package/gonext_mlx_embed.py +155 -0
  19. package/gonext_probe_agent.py +93 -0
  20. package/gonext_transcribe.py +130 -0
  21. package/gu-cli.mjs +4930 -0
  22. package/gu-repl.mjs +10326 -0
  23. package/job-pools.mjs +89 -0
  24. package/model-doctor.mjs +1494 -0
  25. package/node-version.mjs +40 -0
  26. package/ollama-setup.mjs +832 -0
  27. package/package.json +100 -0
  28. package/platform-tools.mjs +520 -0
  29. package/poll-errors.mjs +141 -0
  30. package/proxy-command.mjs +165 -0
  31. package/proxy-config.mjs +255 -0
  32. package/proxy-dispatcher.mjs +132 -0
  33. package/proxy-selftest.mjs +234 -0
  34. package/proxy-store.mjs +69 -0
  35. package/rag-job-config.mjs +59 -0
  36. package/rag-selftest.mjs +215 -0
  37. package/s3-setup.mjs +85 -0
  38. package/terminal-copy.mjs +248 -0
  39. package/terminal-hover.mjs +153 -0
  40. package/terminal-layout.mjs +2507 -0
  41. package/terminal-viewport.mjs +602 -0
  42. package/thinking_words.txt +1003 -0
  43. package/version-check.mjs +72 -0
@@ -0,0 +1,212 @@
1
+ // Device-pairing login for the gu CLI (task #127, Phase 1). Shared by both bins
2
+ // (gu-cli.mjs and gu-repl.mjs) so `gu login` and the first-run
3
+ // auto-prompt use the exact same flow. No secrets live here — the CLI obtains a worker key
4
+ // only after an authenticated human approves in the browser.
5
+ import { execFile } from "node:child_process";
6
+ import { mkdir, writeFile, readFile } from "node:fs/promises";
7
+ import { homedir, platform } from "node:os";
8
+ import { join } from "node:path";
9
+ import dotenv from "dotenv";
10
+
11
+ export const DEFAULT_API_BASE = "https://chat-api-v2.gomarsic.cc";
12
+ const ENV_FILE = join(homedir(), ".gonext", "worker.env");
13
+ const POLL_INTERVAL_MS = 2500;
14
+ const POLL_TIMEOUT_MS = 10 * 60_000; // matches the server's pairing TTL
15
+
16
+ /** True when worker.env already has BOTH a key and an API base (task #127 BC1a). */
17
+ export async function hasWorkerConfig() {
18
+ try {
19
+ const parsed = dotenv.parse(await readFile(ENV_FILE, "utf8"));
20
+ return Boolean(
21
+ String(parsed.GONEXT_WORKER_KEY ?? "").trim() &&
22
+ String(parsed.GONEXT_API_BASE ?? "").trim()
23
+ );
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Write ~/.gonext/worker.env, PRESERVING every other setting already in it.
31
+ *
32
+ * This used to rewrite the file from scratch, keeping only GONEXT_POLL_MS — so re-running
33
+ * login (or saving a key) silently DELETED anything else the user had put there, e.g.
34
+ * GONEXT_SEARXNG_URL. Only the two values this function is actually responsible for are
35
+ * overwritten; the rest are read back and re-emitted untouched.
36
+ */
37
+ async function writeWorkerEnv(apiBase, workerKey) {
38
+ let prev = {};
39
+ try {
40
+ prev = dotenv.parse(await readFile(ENV_FILE, "utf8"));
41
+ } catch {
42
+ /* first-time write */
43
+ }
44
+ const merged = {
45
+ ...prev,
46
+ GONEXT_API_BASE: apiBase.replace(/\/+$/, ""),
47
+ GONEXT_WORKER_KEY: workerKey,
48
+ GONEXT_POLL_MS: String(prev.GONEXT_POLL_MS ?? "").trim() || "500",
49
+ };
50
+ // Stable order: the three we own first, then whatever else the user had.
51
+ const head = ["GONEXT_API_BASE", "GONEXT_WORKER_KEY", "GONEXT_POLL_MS"];
52
+ const body = [
53
+ ...head.map((k) => `${k}=${merged[k]}`),
54
+ ...Object.keys(merged)
55
+ .filter((k) => !head.includes(k))
56
+ .map((k) => `${k}=${merged[k]}`),
57
+ ].join("\n");
58
+ await mkdir(join(homedir(), ".gonext"), { recursive: true });
59
+ await writeFile(ENV_FILE, `${body}\n`, { encoding: "utf8", mode: 0o600 });
60
+ return ENV_FILE;
61
+ }
62
+
63
+ /**
64
+ * Save a worker key the user already has, WITHOUT a browser round-trip (task #135).
65
+ *
66
+ * The pairing flow needs an authenticated browser session; someone who already has a key
67
+ * (from another machine, or Settings → reveal) shouldn't have to sign in again to reuse it.
68
+ * Validates against the API before writing, so a typo fails HERE with a clear message
69
+ * instead of turning into a confusing 403 on the first poll.
70
+ *
71
+ * Returns { apiBase, workerKey, envFile }. Throws with .code:
72
+ * "KEY_REJECTED" — the API says this key isn't valid for any account
73
+ * "KEY_UNCHECKED" — couldn't reach the API to check (nothing written)
74
+ */
75
+ export async function saveWorkerKey({ apiBase = DEFAULT_API_BASE, workerKey, log = console.log } = {}) {
76
+ const base = String(apiBase || DEFAULT_API_BASE).trim().replace(/\/+$/, "");
77
+ const key = String(workerKey || "").trim();
78
+ if (!key) {
79
+ const err = new Error("No worker key given.");
80
+ err.code = "KEY_REJECTED";
81
+ throw err;
82
+ }
83
+ // GET /api/worker/settings — worker-key authed and READ-ONLY. Deliberately NOT
84
+ // POST /jobs/next (the daemon's own call): that CLAIMS a pending job, so validating with
85
+ // it would snatch real work and discard it, leaving that job stuck. A key check must have
86
+ // no side effects. 200 = valid, 401/403 = wrong key, anything else = server-side, which is
87
+ // not a verdict on the key.
88
+ let res;
89
+ try {
90
+ res = await fetch(`${base}/api/worker/settings`, {
91
+ method: "GET",
92
+ headers: { "X-Worker-Key": key },
93
+ });
94
+ } catch (e) {
95
+ const err = new Error(`Could not reach ${base} to check the key: ${e instanceof Error ? e.message : e}`);
96
+ err.code = "KEY_UNCHECKED";
97
+ throw err;
98
+ }
99
+ if (res.status === 401 || res.status === 403) {
100
+ const err = new Error("That worker key was rejected — it isn't registered to any account.");
101
+ err.code = "KEY_REJECTED";
102
+ throw err;
103
+ }
104
+ if (!res.ok) {
105
+ // 503 = persistence not configured server-side, 5xx = outage, 404 = an API too old to
106
+ // have this route. None of those say the KEY is bad, so don't claim it is.
107
+ const err = new Error(
108
+ `${base} couldn't verify the key right now (HTTP ${res.status}). Nothing was saved.`
109
+ );
110
+ err.code = "KEY_UNCHECKED";
111
+ throw err;
112
+ }
113
+ const envFile = await writeWorkerEnv(base, key);
114
+ log(` ✓ Saved ${envFile}`);
115
+ return { apiBase: base, workerKey: key, envFile };
116
+ }
117
+
118
+ function openBrowser(url) {
119
+ const cmd =
120
+ platform() === "darwin" ? "open" : platform() === "win32" ? "start" : "xdg-open";
121
+ try {
122
+ execFile(cmd, [url], () => {}); // best-effort; the URL is also printed
123
+ } catch {
124
+ /* the user can open it manually */
125
+ }
126
+ }
127
+
128
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
129
+
130
+ /**
131
+ * Run the browser device-pairing flow and write worker.env on success.
132
+ *
133
+ * Returns { apiBase, workerKey, envFile } on success.
134
+ * Throws Error with .code === "PAIRING_UNSUPPORTED" when the API has no /pair routes
135
+ * (404), so callers can fall back to the manual `set` path (task #127 BC7).
136
+ * Throws Error with .code === "PAIRING_TIMEOUT" / "PAIRING_EXPIRED" otherwise.
137
+ *
138
+ * `log` is an injectable printer (defaults to console.log) so the REPL can style it.
139
+ */
140
+ export async function deviceLogin({ apiBase = DEFAULT_API_BASE, log = console.log } = {}) {
141
+ const base = String(apiBase || DEFAULT_API_BASE).trim().replace(/\/+$/, "");
142
+ let start;
143
+ try {
144
+ const res = await fetch(`${base}/api/pair/start`, {
145
+ method: "POST",
146
+ headers: { "Content-Type": "application/json" },
147
+ body: JSON.stringify({ apiBase: base }),
148
+ });
149
+ if (res.status === 404) {
150
+ const err = new Error("This API does not support CLI login (device pairing).");
151
+ err.code = "PAIRING_UNSUPPORTED";
152
+ throw err;
153
+ }
154
+ if (!res.ok) {
155
+ throw new Error(`pairing start failed (HTTP ${res.status})`);
156
+ }
157
+ start = await res.json();
158
+ } catch (e) {
159
+ if (e && e.code === "PAIRING_UNSUPPORTED") throw e;
160
+ // A network failure to a base that has no /pair yet also can't pair — surface as
161
+ // unsupported so the caller offers manual setup rather than looping.
162
+ const err = new Error(
163
+ `Could not reach ${base} to start login: ${e instanceof Error ? e.message : e}`
164
+ );
165
+ err.code = "PAIRING_UNSUPPORTED";
166
+ throw err;
167
+ }
168
+
169
+ const { pairId, pairCode, verificationUrlBase } = start;
170
+ const url = `${verificationUrlBase || `${base}/link`}?code=${encodeURIComponent(pairCode)}`;
171
+ log("");
172
+ log(" To connect this machine, sign in and approve this code in your browser:");
173
+ log("");
174
+ log(` code: ${pairCode}`);
175
+ log(` url: ${url}`);
176
+ log("");
177
+ log(" (opening your browser… if it doesn't, paste the url above)");
178
+ log("");
179
+ openBrowser(url);
180
+
181
+ const deadline = Date.now() + POLL_TIMEOUT_MS;
182
+ while (Date.now() < deadline) {
183
+ await sleep(POLL_INTERVAL_MS);
184
+ let poll;
185
+ try {
186
+ const res = await fetch(`${base}/api/pair/poll`, {
187
+ method: "POST",
188
+ headers: { "Content-Type": "application/json" },
189
+ body: JSON.stringify({ pairId }),
190
+ });
191
+ if (!res.ok) continue; // transient — keep polling
192
+ poll = await res.json();
193
+ } catch {
194
+ continue; // network blip — keep polling
195
+ }
196
+ if (poll.status === "approved" && poll.workerApiKey) {
197
+ const finalBase = String(poll.apiBase || base).trim().replace(/\/+$/, "");
198
+ const envFile = await writeWorkerEnv(finalBase, poll.workerApiKey);
199
+ log(` ✓ Linked. Saved ${envFile}`);
200
+ return { apiBase: finalBase, workerKey: poll.workerApiKey, envFile };
201
+ }
202
+ if (poll.status === "expired") {
203
+ const err = new Error("Login code expired before it was approved. Run `gu login` again.");
204
+ err.code = "PAIRING_EXPIRED";
205
+ throw err;
206
+ }
207
+ // "pending" / "consumed" → keep waiting (consumed by a racing poll is unlikely).
208
+ }
209
+ const err = new Error("Login timed out waiting for browser approval.");
210
+ err.code = "PAIRING_TIMEOUT";
211
+ throw err;
212
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * `/doctor` — does each configured model actually ANSWER? (task #224)
3
+ *
4
+ * WHY A PROBE IS NOT ENOUGH, which is the whole reason this exists. Everything that checked
5
+ * models before this asked "is the server reachable": GET /api/tags, a TCP connect, a 200 from
6
+ * the root. On 2026-09-05 every one of those passed while the embedder was completely blocked —
7
+ * ollama2 answered /api/tags instantly, and a single embed of the word "hello" returned NOTHING
8
+ * IN 180 SECONDS, because a 30GB coder was pinned in VRAM `UNTIL: Forever` and there was no room
9
+ * to load a 274MB embedding model. Ollama queued the request rather than evicting.
10
+ *
11
+ * The user found out the way you always find that out: several minutes into a real turn, when
12
+ * the agent decided it wanted a knowledge base. Their words: "add a slash doctor to check if the
13
+ * embed is working because it will take some steps to trigger the model to call the embed."
14
+ *
15
+ * So every check here SENDS THE REAL REQUEST the agent would send, and every check has a
16
+ * deadline. A model that cannot answer in the budget is reported as such — not as "unreachable",
17
+ * which sends the reader to their network, and not as a hang.
18
+ *
19
+ * PURE-ISH: `fetchImpl` and the clock are injected, so every branch — including the ones that
20
+ * need a server that never answers — runs offline in a test.
21
+ */
22
+
23
+ /** The three roles a machine configures, in the order a turn uses them. */
24
+ export const ROLES = ["chat", "code", "embed"];
25
+
26
+ /** Ollama's native face, from an OpenAI-compatible base ("…/v1" → "…"). */
27
+ export function nativeRoot(url) {
28
+ return String(url ?? "").trim().replace(/\/+$/, "").replace(/\/v1$/i, "");
29
+ }
30
+
31
+ /**
32
+ * What this machine is configured to use, per role — read from the settings row, never
33
+ * defaulted here.
34
+ *
35
+ * A check that supplies its own URL passes happily on a machine where the agent cannot reach
36
+ * anything, which is the failure rag-selftest's header already warns about. If a role is not
37
+ * configured, that IS the finding.
38
+ */
39
+ export function rolesFromSettings(s) {
40
+ const str = (v) => String(v ?? "").trim();
41
+ return [
42
+ {
43
+ role: "chat",
44
+ label: "chat model",
45
+ why: "answers and routes every turn",
46
+ url: str(s?.agentModelUrl),
47
+ model: str(s?.agentModelName) || str(s?.agentModel),
48
+ },
49
+ {
50
+ role: "code",
51
+ label: "coding model",
52
+ why: "writes the code",
53
+ url: str(s?.agentCodingModelUrl) || str(s?.agentModelUrl),
54
+ model: str(s?.agentCodingModelName),
55
+ },
56
+ {
57
+ role: "embed",
58
+ label: "embedder",
59
+ why: "RAG — only called once a turn needs a knowledge base",
60
+ url: str(s?.ragEmbedUrl),
61
+ model: str(s?.ragEmbedModel),
62
+ },
63
+ ];
64
+ }
65
+
66
+ /**
67
+ * WHY a model that will not answer is not answering, when the host is an Ollama.
68
+ *
69
+ * The single most useful thing this whole command does, because the answer is invisible from
70
+ * the outside: /api/ps says what is RESIDENT, and a box whose VRAM is full of something else
71
+ * cannot load what you just asked for — it queues you instead. That reads identically to a slow
72
+ * model, a bad URL and a wrong model name, and it is none of them.
73
+ *
74
+ * Returns null when the question does not apply (not an Ollama, or it will not say).
75
+ */
76
+ export async function diagnoseOllamaBusy(url, { fetchImpl = fetch, timeoutMs = 8000 } = {}) {
77
+ const root = nativeRoot(url);
78
+ if (!root) return null;
79
+ let body;
80
+ try {
81
+ const res = await fetchImpl(`${root}/api/ps`, { signal: AbortSignal.timeout(timeoutMs) });
82
+ if (!res.ok) return null;
83
+ body = await res.json();
84
+ } catch {
85
+ return null; // not an Ollama, or it is not answering either — the caller already knows
86
+ }
87
+ const models = Array.isArray(body?.models) ? body.models : [];
88
+ if (!models.length) return { loaded: [], hint: "" };
89
+ const loaded = models.map((m) => ({
90
+ name: String(m?.name ?? m?.model ?? ""),
91
+ gb: Math.round(((Number(m?.size) || 0) / 1e9) * 10) / 10,
92
+ vramGb: Math.round(((Number(m?.size_vram) || 0) / 1e9) * 10) / 10,
93
+ }));
94
+ const biggest = [...loaded].sort((a, b) => b.vramGb - a.vramGb)[0];
95
+ return {
96
+ loaded,
97
+ // Named, with its size, and with the ONE command that frees it. "The GPU is busy" is a
98
+ // diagnosis; `ollama stop <name>` is a fix.
99
+ hint: biggest?.vramGb
100
+ ? `${biggest.name} is holding ${biggest.vramGb}GB of VRAM — nothing else can load ` +
101
+ `until it is released. On that host: \`ollama stop ${biggest.name}\` ` +
102
+ "(and set a finite OLLAMA_KEEP_ALIVE so it does not pin itself again)."
103
+ : "",
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Send the request the agent would send, and time it.
109
+ *
110
+ * `kind` picks the endpoint, because the roles are not interchangeable: an embedder does not
111
+ * answer /chat/completions and a chat model does not answer /embeddings. Asking the wrong one
112
+ * reports a healthy model as broken, which is the mistake this command exists to stop making.
113
+ */
114
+ export function requestFor(kind, model) {
115
+ if (kind === "embed") {
116
+ return { path: "/embeddings", body: { model, input: ["gu doctor"] } };
117
+ }
118
+ return {
119
+ path: "/chat/completions",
120
+ body: { model, messages: [{ role: "user", content: "Reply with OK." }], max_tokens: 4,
121
+ stream: false },
122
+ };
123
+ }
124
+
125
+ /** Did the body actually carry what that endpoint is supposed to return? */
126
+ export function bodyIsUsable(kind, body) {
127
+ if (kind === "embed") {
128
+ const v = body?.data?.[0]?.embedding ?? body?.embeddings?.[0];
129
+ return Array.isArray(v) && v.length > 0 ? { ok: true, detail: `${v.length}-dim vector` } : {
130
+ ok: false, detail: "answered, but returned no vector",
131
+ };
132
+ }
133
+ const text = body?.choices?.[0]?.message?.content;
134
+ return typeof text === "string"
135
+ ? { ok: true, detail: "answered" }
136
+ : { ok: false, detail: "answered, but returned no message" };
137
+ }
138
+
139
+ /**
140
+ * One role, one real round trip.
141
+ *
142
+ * The status is deliberately finer than ok/failed, because the four causes need four different
143
+ * actions and were previously all reported as "isn't responding":
144
+ *
145
+ * ok it answered, with something usable
146
+ * unset nothing is configured for this role — not a failure, a gap
147
+ * timeout it accepted the request and did not answer inside the budget. THE case this
148
+ * command was built for: a full GPU queues rather than refuses.
149
+ * unreachable nothing answered at all — DNS, a dead port, a proxy
150
+ * error it answered with a refusal (a 404 model name, a 401 key, a 400 shape)
151
+ */
152
+ export async function checkRole(
153
+ r,
154
+ { fetchImpl = fetch, timeoutMs = 25_000, now = () => Date.now() } = {},
155
+ ) {
156
+ const base = { role: r.role, label: r.label, why: r.why, url: r.url, model: r.model };
157
+ if (!r.url) return { ...base, status: "unset", ms: 0, detail: "no server configured" };
158
+ if (!r.model) return { ...base, status: "unset", ms: 0, detail: "no model name configured" };
159
+
160
+ const kind = r.role === "embed" ? "embed" : "chat";
161
+ const { path, body } = requestFor(kind, r.model);
162
+ const root = String(r.url).replace(/\/+$/, "");
163
+ const endpoint = /\/v1$/i.test(root) ? `${root}${path}` : `${root}/v1${path}`;
164
+ const t0 = now();
165
+ let res;
166
+ try {
167
+ res = await fetchImpl(endpoint, {
168
+ method: "POST",
169
+ headers: { "Content-Type": "application/json" },
170
+ body: JSON.stringify(body),
171
+ signal: AbortSignal.timeout(timeoutMs),
172
+ });
173
+ } catch (e) {
174
+ const ms = now() - t0;
175
+ // A TIMEOUT IS NOT AN OUTAGE, and conflating them is what sent the last investigation to
176
+ // the network for an hour. The connection was accepted; the model never got scheduled.
177
+ const timedOut =
178
+ e?.name === "TimeoutError" || e?.name === "AbortError" || /timed?\s?out/i.test(String(e?.message));
179
+ return timedOut
180
+ ? { ...base, status: "timeout", ms, detail: `no answer in ${Math.round(ms / 1000)}s` }
181
+ : { ...base, status: "unreachable", ms, detail: String(e?.cause?.code || e?.message || e) };
182
+ }
183
+ const ms = now() - t0;
184
+ if (!res.ok) {
185
+ let extra = "";
186
+ try {
187
+ extra = (await res.text()).slice(0, 120).replace(/\s+/g, " ").trim();
188
+ } catch { /* a body we cannot read is not worth failing over */ }
189
+ return { ...base, status: "error", ms, detail: `HTTP ${res.status}${extra ? ` — ${extra}` : ""}` };
190
+ }
191
+ let parsed;
192
+ try {
193
+ parsed = await res.json();
194
+ } catch {
195
+ return { ...base, status: "error", ms, detail: "answered with something that is not JSON" };
196
+ }
197
+ const usable = bodyIsUsable(kind, parsed);
198
+ return { ...base, status: usable.ok ? "ok" : "error", ms, detail: usable.detail };
199
+ }
200
+
201
+ /**
202
+ * Every role, in turn, plus a reason when one of them stalled.
203
+ *
204
+ * SEQUENTIAL, NOT PARALLEL, and that is a real decision rather than laziness: these share one
205
+ * GPU. Three concurrent requests make them queue behind each other and every one of them looks
206
+ * slow — the command would manufacture the symptom it is trying to measure.
207
+ */
208
+ export async function runDoctor(settings, opts = {}) {
209
+ const roles = rolesFromSettings(settings);
210
+ const results = [];
211
+ for (const r of roles) {
212
+ const res = await checkRole(r, opts);
213
+ // Only ask WHY when something actually stalled. /api/ps on a healthy box is noise, and on a
214
+ // busy one it is the answer.
215
+ if (res.status === "timeout") {
216
+ res.busy = await diagnoseOllamaBusy(r.url, opts);
217
+ }
218
+ results.push(res);
219
+ }
220
+ return { results, ok: results.every((r) => r.status === "ok") };
221
+ }
222
+
223
+ /** One line per role, for the terminal. Pure, so the wording is testable. */
224
+ export function doctorLines(results) {
225
+ const mark = { ok: "✓", unset: "·", timeout: "✗", unreachable: "✗", error: "✗" };
226
+ const out = [];
227
+ for (const r of results) {
228
+ const secs = r.ms >= 1000 ? ` ${(r.ms / 1000).toFixed(1)}s` : r.ms ? ` ${r.ms}ms` : "";
229
+ out.push(`${mark[r.status] ?? "?"} ${r.label} ${r.model || "(no model)"}${secs} — ${r.detail}`);
230
+ if (r.status === "timeout") {
231
+ // The distinction that took an hour to find, said in one line.
232
+ out.push(" it ACCEPTED the request and never answered — the server is up, the model");
233
+ out.push(" could not be scheduled. This is not a network problem.");
234
+ if (r.busy?.hint) out.push(` ${r.busy.hint}`);
235
+ }
236
+ if (r.status === "unset") out.push(" set it with /model → change the Ollama server");
237
+ }
238
+ return out;
239
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * `/embed-model` — choosing the embedder, and dealing with what that does to the index (#169).
3
+ *
4
+ * SEPARATE FROM THE REPL because the interesting part is not the picker, it is the CONSEQUENCE.
5
+ * Everything here is a pure function of (what is set, what was chosen, what is on disk); the REPL
6
+ * keeps only the fetch, the write and the printing. That split is what lets the case that matters
7
+ * — "you are about to make four knowledge bases unreadable" — be tested without indexing anything.
8
+ *
9
+ * WHY THE LIST IS HARDCODED AND /agent-model's IS NOT. An embedder is not interchangeable the way
10
+ * a chat model is: it decides the shape of every vector already on disk. Offering everything
11
+ * GET /api/tags returns would invite picking a chat model as an embedder — which does not fail at
12
+ * the point of choice, where the mistake is obvious, but much later at retrieval, as slightly
13
+ * worse answers. Two known-good embedders, named here, is the whole list on purpose.
14
+ */
15
+
16
+ /**
17
+ * The embedders we support, most capable first.
18
+ *
19
+ * `dims` is what each model actually returns, recorded so a plan can say "768 → 4096" rather than
20
+ * the vaguer "the vectors are not comparable". It is documentation, not a probe: the manifest on
21
+ * disk carries the real number for an index that already exists.
22
+ */
23
+ export const EMBED_CHOICES = [
24
+ {
25
+ model: "qwen3-embedding:8b",
26
+ dims: 4096,
27
+ note: "best recall, ~18× slower to index",
28
+ },
29
+ {
30
+ model: "nomic-embed-text:latest",
31
+ dims: 768,
32
+ note: "same recall on code, far faster (default)",
33
+ },
34
+ ];
35
+
36
+ /** The one Client Expert seeds, and the one to fall back on when nothing is set. */
37
+ export const DEFAULT_EMBED_MODEL = "nomic-embed-text:latest";
38
+
39
+ /**
40
+ * Are these the same model to Ollama?
41
+ *
42
+ * "nomic-embed-text" and "nomic-embed-text:latest" name one model, and the settings field may
43
+ * carry the tag or not depending on who wrote it. Comparing raw strings would report a change
44
+ * that never happened — and then invalidate a perfectly good index over it. Mirrors _same_model
45
+ * in gonext_agent_chat.py (_rag_index_model_matches), which is the check that actually refuses a
46
+ * mismatched index at search time; if one of the two ever moves, the other has to move with it.
47
+ */
48
+ export function sameEmbedModel(a, b) {
49
+ const norm = (x) => {
50
+ const s = String(x ?? "").trim();
51
+ return s.endsWith(":latest") ? s.slice(0, -":latest".length) : s;
52
+ };
53
+ const na = norm(a);
54
+ const nb = norm(b);
55
+ return na !== "" && na === nb;
56
+ }
57
+
58
+ /** What is set right now, as the settings row reports it (empty → the seeded default). */
59
+ export function currentEmbedModel(settings) {
60
+ return String(settings?.ragEmbedModel ?? "").trim() || DEFAULT_EMBED_MODEL;
61
+ }
62
+
63
+ /**
64
+ * The picker's rows: every supported embedder, with the active one marked (requirement 6 — a
65
+ * picker that does not say where you are makes a no-op indistinguishable from a change).
66
+ *
67
+ * A model that is configured but NOT one of ours still gets a row, marked as current. Dropping it
68
+ * would mean the list silently disagrees with the machine, and re-picking would look like a
69
+ * change when it is not. Someone set it deliberately; show it.
70
+ */
71
+ export function embedChoiceRows(current) {
72
+ const rows = EMBED_CHOICES.map((c) => ({ ...c, current: sameEmbedModel(c.model, current) }));
73
+ if (!rows.some((r) => r.current) && String(current ?? "").trim()) {
74
+ rows.push({
75
+ model: String(current).trim(),
76
+ dims: 0,
77
+ note: "configured on this machine",
78
+ current: true,
79
+ });
80
+ }
81
+ return rows;
82
+ }
83
+
84
+ /**
85
+ * WHAT SWITCHING EMBEDDERS DOES TO WHAT IS ALREADY INDEXED — the half of this command that is
86
+ * not a menu.
87
+ *
88
+ * Vectors from two models are not in the same space, and usually not even the same length. The
89
+ * failure is not an error: cosine over mismatched lengths returns a number, rag_search still
90
+ * prints its top-k with scores, and the model still reasons from them. So the question a plan has
91
+ * to answer is not "did the setting change" but "which knowledge bases stop being readable".
92
+ *
93
+ * `indexes` is listLocalRagIndexes() output — [{ source, model, dims, chunks, ... }]. An index
94
+ * whose manifest never recorded a model (`model: "?"`) counts as STALE rather than kept: unknown
95
+ * provenance is exactly the case that cannot be verified, and guessing "it is probably fine" is
96
+ * how a silent failure survives.
97
+ *
98
+ * Returns { changed, from, to, stale, kept, unknownProvenance } — the REPL turns it into prose.
99
+ */
100
+ export function embedChangePlan({ current, chosen, indexes = [] } = {}) {
101
+ const from = String(current ?? "").trim();
102
+ const to = String(chosen ?? "").trim();
103
+ const changed = Boolean(to) && !sameEmbedModel(from, to);
104
+ const rows = Array.isArray(indexes) ? indexes : [];
105
+ const stale = [];
106
+ const kept = [];
107
+ for (const r of rows) {
108
+ const builtWith = String(r?.model ?? "").trim();
109
+ // Not "no chunks, no problem": a manifest with zero chunks still keys a directory that the
110
+ // next index writes into, and reporting it keeps the count honest against `gu status`.
111
+ if (!changed) {
112
+ kept.push(r);
113
+ continue;
114
+ }
115
+ (sameEmbedModel(builtWith, to) ? kept : stale).push(r);
116
+ }
117
+ return {
118
+ changed,
119
+ from,
120
+ to,
121
+ stale,
122
+ kept,
123
+ unknownProvenance: stale.filter((r) => !String(r?.model ?? "").trim() || r.model === "?"),
124
+ };
125
+ }
126
+
127
+ /**
128
+ * The plan as lines a human reads before committing to it.
129
+ *
130
+ * Deliberately concrete — source, model, chunk count — because the abstract version ("your index
131
+ * will be invalidated") is the one people click through. Says what will HAPPEN to each index, and
132
+ * never claims the data is gone when it is only unreadable.
133
+ */
134
+ export function describeEmbedChange(plan) {
135
+ if (!plan?.changed) return [`already using ${plan?.to || plan?.from || "(none)"} — nothing to do.`];
136
+ const lines = [`embedding model ${plan.from || "(unset)"} → ${plan.to}`];
137
+ if (plan.stale.length === 0) {
138
+ lines.push("nothing indexed with the old model — no knowledge base is affected.");
139
+ return lines;
140
+ }
141
+ const chunks = plan.stale.reduce((n, r) => n + (Number(r?.chunks) || 0), 0);
142
+ lines.push(
143
+ `${plan.stale.length} knowledge base${plan.stale.length === 1 ? "" : "s"} ` +
144
+ `(${chunks} chunk${chunks === 1 ? "" : "s"}) were built with a different embedder:`
145
+ );
146
+ for (const r of plan.stale.slice(0, 8)) {
147
+ const built = String(r?.model ?? "").trim() || "?";
148
+ const dims = Number(r?.dims) || 0;
149
+ lines.push(` ${r?.source ?? "?"} · built with ${built}${dims ? ` (${dims}d)` : ""}`);
150
+ }
151
+ if (plan.stale.length > 8) lines.push(` …and ${plan.stale.length - 8} more`);
152
+ // The truthful description of what the code already does: _rag_index_model_matches makes
153
+ // rag_search treat a mismatched index as ABSENT, and the auto-index path then rebuilds it on
154
+ // the next search. Not "your data is deleted", and not "it still works".
155
+ lines.push("They will be ignored at search time and rebuilt on the next search.");
156
+ return lines;
157
+ }