@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,832 @@
1
+ /**
2
+ * Pointing a Client Expert machine at ITS OWN Ollama box (task #220).
3
+ *
4
+ * Setup used to seed https://ollama1.gomarsic.cc and three model names, so anyone whose box is
5
+ * somewhere else — a company LAN, http://192.168.1.40:11434, their own dnsname — had to know to
6
+ * override environment variables they had never heard of. This module is the pure half of asking
7
+ * instead: normalise whatever URL they type, and turn the list that box actually reports into the
8
+ * three role choices gu needs.
9
+ *
10
+ * PURE ON PURPOSE. Every decision below is a function from strings to strings, so the whole
11
+ * "what does this URL mean / which model should each role default to" table is a test rather
12
+ * than a thing you find out by re-running setup against a real box.
13
+ *
14
+ * THE ONE RULE THIS MODULE EXISTS TO ENFORCE, from client-expert-api.mjs:457 — a setup question
15
+ * was deliberately REMOVED once, because the alternative was "a URL that answers nothing" and a
16
+ * machine that reported success and then failed its first question. Re-adding the question is
17
+ * only safe because the box is PROVEN to answer before setup accepts it. Nothing here invents a
18
+ * model name, guesses a host, or fills a role from a list that came back empty: every function
19
+ * returns null rather than a plausible-looking value it cannot stand behind.
20
+ */
21
+ import { isLikelyEmbedder } from "./agent-model-command.mjs";
22
+
23
+ /** Ollama's native port, used when someone types a bare host or an IP with no port. */
24
+ export const OLLAMA_DEFAULT_PORT = 11434;
25
+
26
+ /**
27
+ * THE TWO MODELS A SERVER MUST ACTUALLY HAVE, and why these two are not a free choice while the
28
+ * coder is (user, 2026-09-04).
29
+ *
30
+ * · AGENT — gemma4:e4b routes every turn and writes every plain reply. Swapping it does not
31
+ * just change the answers, it changes whether the ROUTER works: the agent/chat decision, the
32
+ * tool-call shapes and the thought normalising were all tuned against this family, and a
33
+ * model that cannot follow them fails as an agent that answers confidently and does nothing.
34
+ * · EMBED — nomic-embed-text decides the shape of every vector on disk. A different embedder
35
+ * does not error, it returns numbers: cosine over two models still ranks, rag_search still
36
+ * prints a top-k, and the model still answers from it. That failure is invisible and
37
+ * permanent, which is why /embed-model's list is two known-good options rather than live.
38
+ *
39
+ * The CODER is deliberately not on this list. It is the one model that IS interchangeable —
40
+ * `/model` exists to swap it — so it is chosen from whatever the box has.
41
+ *
42
+ * WHAT IS REQUIRED IS THE FAMILY, NOT THE TAG (user, 2026-09-06). The reasoning above is about
43
+ * gemma4 — the router, the tool-call shapes and the thought normalising were tuned against that
44
+ * family — and nothing in it singles out `:e4b` over `:12b` or a local `gemma4-quadro` build.
45
+ * Demanding one exact tag turned a box that had three perfectly good gemma4s into "not ready",
46
+ * and told the reader to pull a fourth. So the gate is the PREFIX, and which of the matching
47
+ * models fills the role is the user's choice, the same way the coder already is.
48
+ */
49
+ export const REQUIRED_AGENT_PREFIX = "gemma4";
50
+ /** The shipped default, and the name `ollama pull` is suggested for when the family is absent. */
51
+ export const REQUIRED_AGENT_MODEL = "gemma4:e4b";
52
+ export const REQUIRED_EMBED_MODEL = "nomic-embed-text:latest";
53
+
54
+ /** Is this one of the models allowed to fill the AGENT role? */
55
+ const hasAgentPrefix = (name, prefix = REQUIRED_AGENT_PREFIX) =>
56
+ String(name ?? "").trim().toLowerCase().startsWith(String(prefix).toLowerCase());
57
+
58
+ /**
59
+ * The models on this box that may fill the AGENT role — the gemma4 family, minus any embedder.
60
+ *
61
+ * Embedders are excluded rather than merely sorted last: one cannot route a turn at all, so
62
+ * offering it as a choice offers a way to break the machine.
63
+ */
64
+ export function agentCandidates(models, { prefix = REQUIRED_AGENT_PREFIX } = {}) {
65
+ return (Array.isArray(models) ? models : []).filter(
66
+ (m) => !isLikelyEmbedder(m) && hasAgentPrefix(m.model, prefix)
67
+ );
68
+ }
69
+
70
+ /**
71
+ * Which candidate to offer first: what the machine is already set to, else the shipped default,
72
+ * else the SMALLEST — the agent runs on every step of every turn, so its latency is the one a
73
+ * person feels (the same reasoning defaultRoles applies to `chat`).
74
+ */
75
+ export function defaultAgentModel(models, preferred = "") {
76
+ const cands = agentCandidates(models);
77
+ if (!cands.length) return "";
78
+ const has = (name) => cands.some((m) => m.model === name);
79
+ if (preferred && has(preferred)) return preferred;
80
+ if (has(REQUIRED_AGENT_MODEL)) return REQUIRED_AGENT_MODEL;
81
+ return [...cands].sort(
82
+ (a, b) => (a.sizeBytes || 0) - (b.sizeBytes || 0) || a.model.localeCompare(b.model)
83
+ )[0].model;
84
+ }
85
+
86
+ /** "nomic-embed-text" and "nomic-embed-text:latest" are the same model to a person. */
87
+ const sameModel = (a, b) => {
88
+ const bare = (s) => String(s ?? "").trim().toLowerCase().replace(/:latest$/, "");
89
+ return bare(a) === bare(b);
90
+ };
91
+
92
+ /**
93
+ * Does this server have what the agent and RAG actually need?
94
+ *
95
+ * Returns the MISSING names, so the caller can print the exact `ollama pull` lines rather than
96
+ * "some models are missing" — the difference between a message someone can act on and one they
97
+ * have to decode.
98
+ */
99
+ export function checkRequired(
100
+ models,
101
+ { agentPrefix = REQUIRED_AGENT_PREFIX, agentExample = REQUIRED_AGENT_MODEL, embed = REQUIRED_EMBED_MODEL } = {},
102
+ ) {
103
+ const list = Array.isArray(models) ? models : [];
104
+ const missing = [];
105
+ // The AGENT is satisfied by any model in the family (see REQUIRED_AGENT_PREFIX). What goes in
106
+ // `missing` is still a concrete, pullable name — `ollama pull gemma4` is not a command, so
107
+ // reporting the prefix would produce a line that cannot be run.
108
+ if (!agentCandidates(list, { prefix: agentPrefix }).length) missing.push(agentExample);
109
+ // The EMBEDDER is still exact, and deliberately: a different one does not error, it returns
110
+ // numbers, which corrupts every vector on disk invisibly and permanently.
111
+ if (!list.some((m) => sameModel(m.model, embed))) missing.push(embed);
112
+ return { ok: missing.length === 0, missing };
113
+ }
114
+
115
+ /**
116
+ * "This server is not ready", said as the two commands that make it ready.
117
+ *
118
+ * NOT a refusal to continue — the caller decides that. A box missing these can still be used as
119
+ * a CODER host, which is exactly the split this flow supports.
120
+ */
121
+ export function describeNotReady(host, missing) {
122
+ const lines = [`${host || "That server"} is not ready for the agent — it is missing:`];
123
+ for (const m of missing ?? []) lines.push(` ${m}`);
124
+ lines.push("Pull them there, then load again:");
125
+ for (const m of missing ?? []) lines.push(` ollama pull ${String(m).replace(/:latest$/, "")}`);
126
+ return lines;
127
+ }
128
+
129
+ /**
130
+ * What people actually type, turned into one canonical URL.
131
+ *
132
+ * The field accepts every shape someone reasonably offers for "my Ollama box":
133
+ *
134
+ * 192.168.1.40 → http://192.168.1.40:11434/v1
135
+ * 192.168.1.40:11434 → http://192.168.1.40:11434/v1
136
+ * ollama.local → http://ollama.local:11434/v1
137
+ * http://box:11434/v1 → http://box:11434/v1
138
+ * https://ollama1.gomarsic.cc → https://ollama1.gomarsic.cc/v1
139
+ * https://ollama1.gomarsic.cc/v1/ → https://ollama1.gomarsic.cc/v1
140
+ *
141
+ * TWO DEFAULTS, AND THEY ARE NOT SYMMETRICAL:
142
+ *
143
+ * · SCHEME — http, not https, when none is given. A bare host or an IP is a LAN box, and LAN
144
+ * boxes overwhelmingly do not have a certificate. Defaulting to https would fail TLS on the
145
+ * common case and report it as "unreachable", sending the reader to debug their network
146
+ * instead of typing four more characters. An explicit https:// is of course kept.
147
+ * · PORT — added only when the host has none AND no scheme was given. `https://host` means a
148
+ * reverse proxy on 443 (that is what ollama1 is); pinning :11434 onto it would break the
149
+ * exact deployment we ship against.
150
+ *
151
+ * Returns null for anything that is not a host at all, so the caller can say "that is not a URL"
152
+ * rather than proceeding with a mangled one.
153
+ */
154
+ export function normalizeOllamaUrl(raw) {
155
+ const text = String(raw ?? "").trim();
156
+ if (!text) return null;
157
+ // A path, a query, or whitespace inside means this is not a host we can reason about.
158
+ if (/\s/.test(text)) return null;
159
+
160
+ const hadScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(text);
161
+ // Reject a scheme we cannot speak BEFORE prefixing, or "ftp://box" silently becomes
162
+ // "http://ftp://box" and fails with a baffling message.
163
+ if (hadScheme && !/^https?:\/\//i.test(text)) return null;
164
+ const withScheme = hadScheme ? text : `http://${text}`;
165
+
166
+ let u;
167
+ try {
168
+ u = new URL(withScheme);
169
+ } catch {
170
+ return null;
171
+ }
172
+ if (!u.hostname) return null;
173
+ // A trailing-dot FQDN ("box.local.") is legal DNS but breaks string comparison against the
174
+ // same host written without it, and every later locality/proxy check compares hostnames.
175
+ const host = u.hostname.replace(/\.$/, "");
176
+ if (!host) return null;
177
+
178
+ const port = u.port || (hadScheme ? "" : String(OLLAMA_DEFAULT_PORT));
179
+ const origin = `${u.protocol}//${host}${port ? `:${port}` : ""}`;
180
+
181
+ // The path is discarded rather than preserved. Ollama serves /v1 and /api at the ROOT of the
182
+ // box; a user who pastes ".../v1/chat/completions" (copied from a log or a curl) means the
183
+ // box, not that endpoint, and keeping the path would build /v1/chat/completions/api/tags.
184
+ return {
185
+ /** The OpenAI-compatible base, which is what every settings field in gu stores. */
186
+ url: `${origin}/v1`,
187
+ /** Ollama's NATIVE face, where /api/tags and /api/ps live. */
188
+ root: origin,
189
+ host,
190
+ /** True when we supplied the scheme — worth saying, since we guessed http for them. */
191
+ schemeAssumed: !hadScheme,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Every address a bare hostname could reasonably mean, in the order worth trying.
197
+ *
198
+ * ONE GUESS IS NOT ENOUGH, and this was found by typing a real hostname at a real prompt:
199
+ * `ollama2.gomarsic.cc` became `http://ollama2.gomarsic.cc:11434` and timed out, because that
200
+ * box is behind a reverse proxy on HTTPS:443 — exactly like ollama1. The single default is
201
+ * right for a LAN box and wrong for a public name, and the reader has no way to know which
202
+ * assumption we made beyond a timeout that looks like their server is down.
203
+ *
204
+ * So when the user gave NEITHER a scheme NOR a port, we produce both readings and let the LOAD
205
+ * decide — which costs one extra request, and only when the first guess was wrong. The order is
206
+ * a heuristic and nothing rests on it:
207
+ * · an IP, a single-label host, or a .local/.lan/.internal name is a LAN box → http:11434 first
208
+ * · anything else looks like a public FQDN behind a proxy → https:443 first
209
+ *
210
+ * Anything explicit — a scheme, a port, or both — is taken at its word and produces exactly one
211
+ * candidate. Guessing past what someone typed is how you end up connecting somewhere they did
212
+ * not ask for.
213
+ */
214
+ export function urlCandidates(raw) {
215
+ const text = String(raw ?? "").trim();
216
+ if (!text) return [];
217
+ const explicit = /^[a-z][a-z0-9+.-]*:\/\//i.test(text) || /:\d+(\/|$)/.test(text);
218
+ const first = normalizeOllamaUrl(text);
219
+ if (!first) return [];
220
+ if (explicit) return [first];
221
+
222
+ const host = first.host;
223
+ const isIp = /^\d{1,3}(\.\d{1,3}){3}$/.test(host);
224
+ const lanish = isIp || !host.includes(".") || /\.(local|lan|internal|home|localdomain)$/i.test(host);
225
+ const lan = normalizeOllamaUrl(`http://${host}:${OLLAMA_DEFAULT_PORT}`);
226
+ const proxied = normalizeOllamaUrl(`https://${host}`);
227
+ return lanish ? [lan, proxied] : [proxied, lan];
228
+ }
229
+
230
+ /**
231
+ * WHEN THE ADDRESS IS NOT AN OLLAMA AT ALL, BUT A HOSTED PROVIDER.
232
+ *
233
+ * Reported while setting up Client Expert: `https://api.moonshot.ai` typed at the coding-model
234
+ * question, answered with "could not reach api.moonshot.ai: HTTP 404 — check the address, or
235
+ * that Ollama is running there". The address was perfect. It is simply not an Ollama: Moonshot
236
+ * speaks the OpenAI dialect, and this prompt only ever asks for /api/tags.
237
+ *
238
+ * Telling someone to check a correct address is the worst kind of error message, so the failure
239
+ * path asks one more question — does /v1/models answer? — and says something true instead.
240
+ *
241
+ * 401 AND 403 COUNT AS YES. A hosted provider without a key answers "unauthorised", not "not
242
+ * found": that response IS the proof the endpoint exists and speaks OpenAI. Measured on
243
+ * api.moonshot.ai — /api/tags 404, /v1/models 401. Requiring 200 here would recognise only the
244
+ * providers that need no credential, which is nearly none of them.
245
+ */
246
+ /** The real probe: the status of GET <root>/v1/models, or 0 if it could not be reached. */
247
+ async function defaultProbeStatus(url) {
248
+ try {
249
+ const res = await fetch(url, { signal: AbortSignal.timeout(4000) });
250
+ return res.status;
251
+ } catch {
252
+ return 0;
253
+ }
254
+ }
255
+
256
+ export const OPENAI_PROBE_PATH = "/v1/models";
257
+
258
+ /** Pure: does this HTTP status mean "an OpenAI-compatible endpoint is here"? */
259
+ export function isOpenAiStatus(status) {
260
+ const n = Number(status);
261
+ if (!Number.isFinite(n)) return false;
262
+ // 2xx = it answered with a list. 401/403 = it is there and wants a key, which is the normal
263
+ // reply from every hosted provider and the common case for this message.
264
+ return (n >= 200 && n < 300) || n === 401 || n === 403;
265
+ }
266
+
267
+ /** The root to probe, from whatever the person typed. `/v1` is added, never doubled. */
268
+ export function openAiProbeUrl(raw) {
269
+ const base = String(raw ?? "").trim().replace(/\/+$/, "");
270
+ if (!base) return "";
271
+ const withScheme = /^https?:\/\//i.test(base) ? base : `https://${base}`;
272
+ return withScheme.replace(/\/v1$/i, "") + OPENAI_PROBE_PATH;
273
+ }
274
+
275
+ /**
276
+ * What to say when the coding-model question was given a hosted provider.
277
+ *
278
+ * It names the ONE path that actually works today. Moonshot is already registered as an
279
+ * openai-kind coding backend by codingBackendsForClientExpert — with the key asked for only if
280
+ * you switch to it — so the answer is not "you cannot", it is "not here, and here is where".
281
+ */
282
+ export function describeOpenAiCoder(host) {
283
+ return [
284
+ `${host} is an OpenAI-compatible provider, not an Ollama server.`,
285
+ "This question wants an Ollama address — it asks the box which models it holds, and a",
286
+ "hosted provider will not answer that without a key.",
287
+ "Kimi (api.moonshot.ai) is already set up as a coding backend on this machine. Press Enter",
288
+ "to finish with the local coder, then run `/model` to switch to it and enter your API key.",
289
+ ];
290
+ }
291
+
292
+ /** And for the agent question, where a hosted provider genuinely cannot do the job. */
293
+ export function describeOpenAiAgent(host) {
294
+ return [
295
+ `${host} is an OpenAI-compatible provider, not an Ollama server.`,
296
+ "This role needs an Ollama: it runs the embedder for RAG as well as the agent model, and",
297
+ "an embedding model is not something a chat provider serves.",
298
+ ];
299
+ }
300
+
301
+ /**
302
+ * THIS MACHINE'S OWN OLLAMA.
303
+ *
304
+ * http, not https: nothing is listening for TLS on a loopback port, and 127.0.0.1 rather than
305
+ * "localhost" because that name can resolve to ::1 first on a Mac while Ollama binds IPv4 only —
306
+ * a connection refused against a server that is plainly running.
307
+ */
308
+ export const LOCAL_OLLAMA_URL = `http://127.0.0.1:${OLLAMA_DEFAULT_PORT}`;
309
+
310
+ /**
311
+ * What the setup question should OFFER as its default: this machine's Ollama if it is actually
312
+ * there, else whatever was going to be offered anyway.
313
+ *
314
+ * WHY IT IS A PROBE AND NOT JUST A NEW CONSTANT. The shared server is the default today for a
315
+ * documented reason — the code before it "seeded whatever it could infer, a local Ollama URL on
316
+ * a machine with no Ollama, so setup finished successfully and the first question failed with a
317
+ * model that was never going to answer". Swapping one guess for another would walk straight back
318
+ * into that. Asking the port settles it in a few milliseconds, and a machine with nothing
319
+ * listening is offered exactly what it is offered today.
320
+ *
321
+ * IT DOES NOT REQUIRE THE gemma4 FAMILY. A local Ollama that answers but has no models yet is
322
+ * still the right default for a machine whose whole mode is "nothing it does is shared": the
323
+ * prompt then names the models that are missing, which is a better thing to learn than that we
324
+ * quietly pointed a private install at somebody else's server.
325
+ */
326
+ export async function localOllamaDefault(fallbackUrl, fetchTags, { log } = {}) {
327
+ const normalized = normalizeOllamaUrl(LOCAL_OLLAMA_URL);
328
+ if (!normalized || typeof fetchTags !== "function") return fallbackUrl;
329
+ try {
330
+ const models = await fetchTags(tagsUrlFor(normalized));
331
+ if (!usableModelList(models).ok) return fallbackUrl;
332
+ log?.(` found Ollama on this machine (${models.length} model${models.length === 1 ? "" : "s"})`);
333
+ return normalized.url;
334
+ } catch {
335
+ // Not running, not installed, a different port — all the same answer, and none worth a line
336
+ // of output on a machine that was never going to use it.
337
+ return fallbackUrl;
338
+ }
339
+ }
340
+
341
+ /**
342
+ * The same server, written the way a person should type it — WITHOUT /v1.
343
+ *
344
+ * DISPLAY ONLY. Every settings field in gu stores the /v1 base, because that is the
345
+ * OpenAI-compatible endpoint the API actually calls; nothing about what is SAVED changes here.
346
+ * What changes is what we show: a default of "https://ollama1.gomarsic.cc/v1" reads as an
347
+ * instruction, and the reader is left wondering whether their own address needs the suffix too
348
+ * (reported 2026-09-04: "it makes user confused if he/she should input v1 to the url").
349
+ *
350
+ * They do not need it, and never did — normalizeOllamaUrl accepts the address with /v1, without
351
+ * it, with a trailing slash, or with a whole endpoint pasted on the end. Showing the bare host
352
+ * is simply the honest version of a field that was always that forgiving.
353
+ *
354
+ * SAFE TO FEED BACK IN, which is the property that matters: strip → normalize returns exactly
355
+ * the URL we started with, so the displayed default can be passed straight back as the default
356
+ * without drifting. Pinned by a round-trip test.
357
+ */
358
+ export function displayUrl(url) {
359
+ return String(url ?? "").trim().replace(/\/+$/, "").replace(/\/v1$/i, "");
360
+ }
361
+
362
+ /**
363
+ * GET /api/tags for a normalised URL.
364
+ *
365
+ * Deliberately built from `root`, not from the /v1 base: /api/tags is on Ollama's native face
366
+ * and does not exist under /v1. agentTagsUrl() does the same job starting from a settings
367
+ * object; this one starts from what the user just typed, before anything has been saved.
368
+ */
369
+ export function tagsUrlFor(normalized) {
370
+ return normalized?.root ? `${normalized.root}/api/tags` : "";
371
+ }
372
+
373
+ /**
374
+ * Is this list good enough to finish setup with?
375
+ *
376
+ * THE GUARD THE WHOLE TASK TURNS ON. Reachable-but-empty is not success: it is a box with no
377
+ * models pulled, or a proxy politely answering for something that is not Ollama. Accepting it
378
+ * would produce exactly the machine the removed setup question used to produce — configured,
379
+ * reported as fine, and unable to answer the first thing asked of it.
380
+ *
381
+ * Also rejects a list with NOTHING BUT EMBEDDERS: nomic-embed-text alone cannot chat or write
382
+ * code, so a box holding only that cannot fill two of the three roles, and finding out at the
383
+ * first turn is worse than finding out here.
384
+ */
385
+ export function usableModelList(models) {
386
+ const list = Array.isArray(models) ? models : [];
387
+ if (list.length === 0) return { ok: false, why: "empty" };
388
+ if (list.every((m) => isLikelyEmbedder(m))) return { ok: false, why: "embedders-only" };
389
+ return { ok: true, why: "" };
390
+ }
391
+
392
+ /** Say what an unusable list means, in the reader's terms rather than ours. */
393
+ export function describeUnusableList(why, url) {
394
+ const where = url || "that server";
395
+ if (why === "embedders-only") {
396
+ return [
397
+ `${where} answered, but every model on it is an embedder.`,
398
+ "An embedder cannot chat or write code — pull a chat model there (e.g. `ollama pull",
399
+ "gemma4:e4b`) and load again.",
400
+ ];
401
+ }
402
+ return [
403
+ `${where} answered, but has no models pulled.`,
404
+ "Pull at least one there (e.g. `ollama pull gemma4:e4b`), then load again.",
405
+ ];
406
+ }
407
+
408
+ /**
409
+ * Which model each role should START on, given what the box actually has.
410
+ *
411
+ * A DEFAULT, NOT A DECISION — the user confirms all three, and every one of them is a plain
412
+ * single-select over the same list. The point of choosing well here is that Enter-Enter-Enter
413
+ * should be right on a normal box, not that the guess is authoritative.
414
+ *
415
+ * The rules, and why:
416
+ * · EMBED takes the first embedder. isLikelyEmbedder is the same test /agent-model uses to sink
417
+ * them out of the chat list, so the two cannot disagree about what an embedder is.
418
+ * · CHAT takes the SMALLEST non-embedder. Chat runs on every step of every turn, so its latency
419
+ * is the one a person feels — the reasoning already written down for SHARED_CHAT_MODEL, here
420
+ * applied to whatever this box happens to hold rather than to a hardcoded name.
421
+ * · CODE takes the LARGEST non-embedder, for the mirror reason: it writes the code and is worth
422
+ * waiting for.
423
+ * · With exactly one non-embedder, chat and code are the same model. That is correct and not a
424
+ * bug — one model is what that box has.
425
+ *
426
+ * Sizes can be 0 (a list that reports none), in which case both fall back to list order, which
427
+ * parseOllamaTags has already made stable and alphabetical.
428
+ */
429
+ export function defaultRoles(models, { preferred = {} } = {}) {
430
+ const list = Array.isArray(models) ? models : [];
431
+ const chatable = list.filter((m) => !isLikelyEmbedder(m));
432
+ const embedders = list.filter((m) => isLikelyEmbedder(m));
433
+ if (chatable.length === 0) return null;
434
+
435
+ // DETERMINISTIC, which is all a tie-break can honestly buy here. Found live on ollama2:
436
+ // gemma4-quadro:latest and gemma4-quadro-49k:latest are byte-identical (28052911405), so
437
+ // "largest wins" is a tie and without a second key the winner depends on the order the server
438
+ // happened to list them in — the same box could seed two machines differently. It does NOT
439
+ // pick the right one: only `preferred` can, because "right" here means the shipped default
440
+ // (the -49k build, with the configured 49152 context), which is knowledge this function does
441
+ // not have and should not hardcode. Callers pass it; see client-expert-run.mjs.
442
+ const bySize = [...chatable].sort(
443
+ (a, b) => (a.sizeBytes || 0) - (b.sizeBytes || 0) || a.model.localeCompare(b.model)
444
+ );
445
+ const has = (name) => list.some((m) => m.model === name);
446
+
447
+ // A model the machine is ALREADY set to wins its role, when the box still has it. Re-running
448
+ // this flow to change one thing must not silently re-pick the other two.
449
+ const keep = (want, fallback) =>
450
+ want && has(want) ? want : fallback;
451
+
452
+ return {
453
+ chat: keep(preferred.chat, bySize[0].model),
454
+ code: keep(preferred.code, bySize[bySize.length - 1].model),
455
+ // null, never a guess: a box with no embedder cannot do RAG, and naming a chat model here
456
+ // would produce an index of meaningless vectors that fails silently at search time.
457
+ embed: keep(preferred.embed, embedders[0]?.model ?? null),
458
+ };
459
+ }
460
+
461
+ /**
462
+ * The models object the rest of Client Expert already understands.
463
+ *
464
+ * Shaped exactly like CLIENT_EXPERT_MODELS so settingsPatch() can take either — the seeded
465
+ * defaults when nobody was asked, or this, when they were.
466
+ *
467
+ * `extras` are the additional coders `/model` should offer later. Filtered to models the box
468
+ * actually reported and to things that are not the primary coder, so the list cannot contain a
469
+ * duplicate or a name that came from a previous, different box.
470
+ */
471
+ export function modelsFromRoles({ url, roles, extras = [], available = [], codeUrl = "" } = {}) {
472
+ if (!url || !roles?.chat || !roles?.code) return null;
473
+ const known = new Set((available ?? []).map((m) => m.model));
474
+ const seen = new Set([roles.code]);
475
+ const alt = [];
476
+ for (const name of extras ?? []) {
477
+ if (!known.has(name) || seen.has(name)) continue;
478
+ seen.add(name);
479
+ alt.push(name);
480
+ }
481
+ return {
482
+ url,
483
+ chat: roles.chat,
484
+ code: roles.code,
485
+ // TWO SERVERS ARE ALLOWED (user, 2026-09-04): the agent + embedder on one box, the coder on
486
+ // another. `codeUrl` is ABSENT when they are the same, so every existing caller and every
487
+ // stored settings row that assumes one host keeps working unchanged — a second field that
488
+ // is only there when it differs cannot break the single-host case.
489
+ ...(codeUrl && codeUrl !== url ? { codeUrl } : {}),
490
+ // Absent rather than empty: seedSettings must be able to tell "no embedder on this box, do
491
+ // not enable RAG" from "an embedder called empty string".
492
+ ...(roles.embed ? { embed: roles.embed } : {}),
493
+ altCoders: alt,
494
+ };
495
+ }
496
+
497
+ /**
498
+ * One line per role, for the confirmation the user reads before Save.
499
+ *
500
+ * Says the HOST too. A setup that reports three model names without saying which box they are on
501
+ * is exactly the report someone re-running this flow cannot check.
502
+ */
503
+ export function summarizeChoice(models, { host = "", codeHost = "" } = {}) {
504
+ if (!models) return [];
505
+ const at = host ? ` @ ${host}` : "";
506
+ // The coder may be on a DIFFERENT box, and if it is, that is the single most important thing
507
+ // on this screen — a summary that showed one host would be quietly wrong about where half the
508
+ // work happens.
509
+ const codeAt = models.codeUrl ? (codeHost ? ` @ ${codeHost}` : " @ (other server)") : at;
510
+ const rows = [
511
+ `chat ${models.chat}${at}`,
512
+ `code ${models.code}${codeAt}`,
513
+ ];
514
+ rows.push(models.embed ? `RAG ${models.embed}${at}` : "RAG — no embedder on this box");
515
+ if (models.altCoders?.length) {
516
+ rows.push(`also ${models.altCoders.join(", ")}${dimNote(models.altCoders.length)}`);
517
+ }
518
+ return rows;
519
+ }
520
+
521
+ const dimNote = (n) => ` — offered by /model (${n} more)`;
522
+
523
+ // ---------------------------------------------------------------------------------------
524
+ // THE ASKING. Numbered menus and line input, NOT the REPL's ↑/↓ picker.
525
+ //
526
+ // This runs during first-run setup, where there is no REPL, no raw mode and no frozen zone —
527
+ // `ask` is a plain line reader and every question in client-expert-run.mjs is a numbered list
528
+ // answered by typing a digit (see askMode). Matching that idiom is not a compromise, it is the
529
+ // only thing available here; a picker would need raw stdin that setup does not own. The same
530
+ // choice re-run later from `/model` CAN use the real picker, because by then the REPL exists.
531
+ //
532
+ // Multi-select therefore reads as "1,3" rather than as ticked boxes — the line-input equivalent
533
+ // of a checkbox list, and the only one this context supports.
534
+ //
535
+ // Everything below is pure except askOllamaSetup, whose `ask` and `fetchTags` are injected, so
536
+ // the entire flow — including every failure branch — runs offline in tests.
537
+ // ---------------------------------------------------------------------------------------
538
+
539
+ /** Menu rows for one role, current/default marked. Pure. */
540
+ export function renderModelMenu(models, { defaultModel = "", formatSize = () => "" } = {}) {
541
+ return (models ?? []).map((m, i) => {
542
+ const n = String(i + 1).padStart(2, " ");
543
+ const size = formatSize(m.sizeBytes);
544
+ const mark = m.model === defaultModel ? " ←" : "";
545
+ const kind = isLikelyEmbedder(m) ? " (embedder)" : "";
546
+ return ` ${n} ${m.model}${size ? ` ${size}` : ""}${kind}${mark}`;
547
+ });
548
+ }
549
+
550
+ /**
551
+ * A typed answer → the model it names, or the default on empty.
552
+ *
553
+ * Accepts the NUMBER or the NAME. People who can see a list of names type names, and refusing
554
+ * that in favour of "you must type the digit" is the kind of small rudeness that makes a setup
555
+ * flow feel hostile. Returns null for anything that matches neither, so the caller re-asks
556
+ * instead of silently taking the default — which would configure a model nobody chose.
557
+ */
558
+ export function parseMenuChoice(typed, models, fallback = "") {
559
+ const text = String(typed ?? "").trim();
560
+ if (!text) return fallback || null;
561
+ const list = models ?? [];
562
+ const n = Number(text);
563
+ if (Number.isInteger(n) && n >= 1 && n <= list.length) return list[n - 1].model;
564
+ const hit = list.find((m) => m.model.toLowerCase() === text.toLowerCase());
565
+ return hit ? hit.model : null;
566
+ }
567
+
568
+ /**
569
+ * "1,3" or "1 3" or "big:27b, mid:13b" → the models named. Unknown entries are DROPPED.
570
+ *
571
+ * Silent on junk, unlike parseMenuChoice, and deliberately: this list is optional extras. A typo
572
+ * in an optional field should cost the typo, not the whole flow — whereas a role must never be
573
+ * filled by accident, which is why the single-choice parser refuses instead.
574
+ */
575
+ export function parseMultiChoice(typed, models) {
576
+ const text = String(typed ?? "").trim();
577
+ if (!text) return [];
578
+ const list = models ?? [];
579
+ const out = [];
580
+ for (const part of text.split(/[,\s]+/).filter(Boolean)) {
581
+ const name = parseMenuChoice(part, list, "");
582
+ if (name && !out.includes(name)) out.push(name);
583
+ }
584
+ return out;
585
+ }
586
+
587
+ /**
588
+ * Ask for the box, prove it answers, and pick the three roles.
589
+ *
590
+ * Returns the models object for settingsPatch, or null when the user declines Ollama — which is
591
+ * a real answer, not a failure: the caller then follows today's Kimi path unchanged.
592
+ *
593
+ * THE URL IS PREFILLED with the shared box, so Enter keeps exactly today's behaviour. That is
594
+ * what makes adding this question safe for existing machines: the default path is one keypress
595
+ * and cannot be got wrong, and typing over it is the new capability.
596
+ *
597
+ * NOTHING IS ACCEPTED UNTIL THE BOX HAS LISTED A USABLE MODEL. See usableModelList — this is the
598
+ * guard that lets the setup question exist at all (client-expert-api.mjs:457).
599
+ */
600
+ export async function askOllamaSetup(
601
+ ask,
602
+ {
603
+ defaultUrl,
604
+ fetchTags,
605
+ log = console.log,
606
+ formatSize = () => "",
607
+ preferred = {},
608
+ attempts = 3,
609
+ // Asked ONLY when the Ollama probe has already failed, so the common path costs nothing.
610
+ // Injected so the tests can drive it; defaults to a real request with a short deadline,
611
+ // because this runs while someone is waiting at a prompt.
612
+ probeStatus = defaultProbeStatus,
613
+ } = {},
614
+ ) {
615
+ // ---- SERVER 1: the agent model and the embedder -------------------------------------
616
+ // These two are REQUIRED and not a free choice (see REQUIRED_AGENT_MODEL). A box without
617
+ // them is "not ready" rather than "wrong": it may still be a perfectly good coder host, which
618
+ // is why this refusal does not end the flow.
619
+ let agent = null;
620
+ for (let attempt = 0; attempt < attempts && !agent; attempt++) {
621
+ const typed = (await ask(` Agent + embedding server [${displayUrl(defaultUrl)}]: `)).trim();
622
+ if (isDecline(typed)) break;
623
+ const tried = await loadFirstThatAnswers(typed || defaultUrl, fetchTags, log);
624
+ if (!tried || tried.failed) {
625
+ // One explanation, the best one available: the provider message when we recognise the
626
+ // address, otherwise the reachability advice we were going to give anyway.
627
+ const explained = await explainIfOpenAi(typed || defaultUrl, probeStatus, log, describeOpenAiAgent);
628
+ if (!explained) for (const l of tried?.failed ?? []) log(` ${l}`);
629
+ continue;
630
+ }
631
+ const { normalized, models } = tried;
632
+ const usable = usableModelList(models);
633
+ if (!usable.ok) {
634
+ for (const l of describeUnusableList(usable.why, normalized.host)) log(` ${l}`);
635
+ continue;
636
+ }
637
+ const need = checkRequired(models);
638
+ if (!need.ok) {
639
+ for (const l of describeNotReady(normalized.host, need.missing)) log(` ${l}`);
640
+ log(" (or press Enter with no address to skip — you can set it later with /model)");
641
+ continue;
642
+ }
643
+ log(` ✓ ${normalized.host} has the ${REQUIRED_AGENT_PREFIX} family and the embedder`);
644
+ agent = { normalized, models };
645
+ }
646
+
647
+ // ---- SERVER 2: the coder ------------------------------------------------------------
648
+ // Defaults to the agent server, because one box is the common case and Enter should mean
649
+ // "same". A DIFFERENT address here is the whole point of asking separately: the coder is the
650
+ // heavy model and is often on other hardware than the little one that routes every turn.
651
+ const coderDefault = agent?.normalized?.url ?? defaultUrl;
652
+ let coder = null;
653
+ for (let attempt = 0; attempt < attempts && !coder; attempt++) {
654
+ const typed = (await ask(` Coding-model server [${displayUrl(coderDefault)}]: `)).trim();
655
+ if (isDecline(typed)) break;
656
+ // Same box as the agent server → reuse the list we already have rather than fetching twice.
657
+ const same = normalizeOllamaUrl(typed || coderDefault);
658
+ const tried =
659
+ agent && same && same.url === agent.normalized.url
660
+ ? { normalized: same, models: agent.models }
661
+ : await loadFirstThatAnswers(typed || coderDefault, fetchTags, log);
662
+ if (!tried || tried.failed) {
663
+ // One explanation, the best one available: the provider message when we recognise the
664
+ // address, otherwise the reachability advice we were going to give anyway.
665
+ const explained = await explainIfOpenAi(typed || coderDefault, probeStatus, log, describeOpenAiCoder);
666
+ if (!explained) for (const l of tried?.failed ?? []) log(` ${l}`);
667
+ continue;
668
+ }
669
+ const { normalized, models } = tried;
670
+ const usable = usableModelList(models);
671
+ if (!usable.ok) {
672
+ for (const l of describeUnusableList(usable.why, normalized.host)) log(` ${l}`);
673
+ continue;
674
+ }
675
+ coder = { normalized, models };
676
+ }
677
+
678
+ // ---- NOTHING CHOSEN ------------------------------------------------------------------
679
+ // A real answer, not a failure. The machine finishes setup unconfigured, the bar shows the
680
+ // roles as unset, and the first question says so instead of hanging — which is the entire
681
+ // reason declining is allowed to be this easy.
682
+ if (!agent && !coder) return null;
683
+
684
+ // ---- PICK THE MODELS -----------------------------------------------------------------
685
+ // THE AGENT IS CHOSEN, not assumed (user, 2026-09-06). The gate above proved the family is
686
+ // there; which member fills the role is a real decision — a box may hold gemma4:e4b for speed
687
+ // and gemma4:12b for quality, and only the person running the machine knows which they want.
688
+ // Not asked when there is exactly one candidate: a menu with a single row is a question with
689
+ // one answer, and answering it is pure ceremony.
690
+ const embed = agent ? REQUIRED_EMBED_MODEL : null;
691
+ let chat = "";
692
+ if (agent) {
693
+ const cands = agentCandidates(agent.models);
694
+ const agentDefault = defaultAgentModel(agent.models, preferred.chat);
695
+ if (cands.length <= 1) {
696
+ chat = agentDefault;
697
+ } else {
698
+ log("");
699
+ log(" AGENT model — routes every turn and writes the plain replies");
700
+ for (const row of renderModelMenu(cands, { defaultModel: agentDefault, formatSize })) log(row);
701
+ for (let t = 0; t < attempts && !chat; t++) {
702
+ const answer = await ask(` agent [${agentDefault}]: `);
703
+ chat = parseMenuChoice(answer, cands, agentDefault) ?? "";
704
+ if (!chat) log(` no model called that — type its number, or Enter for ${agentDefault}`);
705
+ }
706
+ chat = chat || agentDefault;
707
+ }
708
+ }
709
+ let code = "";
710
+ let extras = [];
711
+ if (coder) {
712
+ const roles = defaultRoles(coder.models, { preferred });
713
+ log("");
714
+ log(" CODING model — writes the code; worth waiting for");
715
+ for (const row of renderModelMenu(coder.models, { defaultModel: roles.code, formatSize })) log(row);
716
+ for (let t = 0; t < attempts && !code; t++) {
717
+ const answer = await ask(` code [${roles.code}]: `);
718
+ code = parseMenuChoice(answer, coder.models, roles.code) ?? "";
719
+ if (!code) log(` no model called that — type its number, or Enter for ${roles.code}`);
720
+ }
721
+ code = code || roles.code;
722
+
723
+ const rest = coder.models.filter((m) => !isLikelyEmbedder(m) && m.model !== code);
724
+ if (rest.length) {
725
+ log("");
726
+ log(" Also offer in /model? (numbers separated by commas, or Enter for none)");
727
+ for (const row of renderModelMenu(rest, { formatSize })) log(row);
728
+ extras = parseMultiChoice(await ask(" also: "), rest);
729
+ }
730
+ }
731
+
732
+ // HALF-CONFIGURED IS A REAL STATE and must be representable. Someone with an agent box but no
733
+ // coder yet, or a coder but no agent box, has made progress worth keeping — forcing both or
734
+ // nothing would throw away the half they got right and is exactly the all-or-nothing shape
735
+ // that makes people abandon a setup flow.
736
+ return {
737
+ url: agent?.normalized?.url ?? coder.normalized.url,
738
+ chat,
739
+ code,
740
+ ...(agent && coder && coder.normalized.url !== agent.normalized.url
741
+ ? { codeUrl: coder.normalized.url }
742
+ : {}),
743
+ ...(!agent && coder ? { codeUrl: coder.normalized.url } : {}),
744
+ ...(embed ? { embed } : {}),
745
+ altCoders: (extras ?? []).filter((n) => n !== code),
746
+ ready: Boolean(agent && coder),
747
+ };
748
+ }
749
+
750
+ /** "no", "skip", "-" — a decline, not an address. */
751
+ function isDecline(typed) {
752
+ return /^(no|n|skip|-)$/i.test(String(typed ?? "").trim());
753
+ }
754
+
755
+ /**
756
+ * Node's fetch says "fetch failed" for everything and puts the actual cause one level down.
757
+ *
758
+ * ENOTFOUND (the name does not resolve) and ECONNREFUSED (it resolves, nothing is listening) are
759
+ * different problems with different fixes — a typo versus a stopped server — and "fetch failed"
760
+ * tells the reader neither.
761
+ */
762
+ export function networkReason(err) {
763
+ const cause = err?.cause;
764
+ const code = cause?.code || err?.code;
765
+ if (code === "ENOTFOUND") return "no such host — check the name";
766
+ if (code === "ECONNREFUSED") return "nothing is listening there — is Ollama running?";
767
+ if (code === "ETIMEDOUT" || err?.name === "TimeoutError") return "timed out — no answer";
768
+ if (code === "CERT_HAS_EXPIRED" || /certificate/i.test(cause?.message ?? "")) {
769
+ return "the TLS certificate was rejected";
770
+ }
771
+ return String(cause?.message || err?.message || err);
772
+ }
773
+
774
+ /**
775
+ * Try each reading of what was typed and keep the first that answers.
776
+ *
777
+ * Returns {normalized, models} or null (having already said why). Only the LAST failure is
778
+ * reported: telling someone "http://host:11434 failed AND https://host failed" when they typed
779
+ * one address is two errors for one mistake, and neither names the address they gave.
780
+ */
781
+ /**
782
+ * After an Ollama probe has failed, say something TRUE about what the address actually is.
783
+ *
784
+ * Silent unless it recognises a hosted provider — a genuinely unreachable box has already had
785
+ * the right message printed by loadFirstThatAnswers, and a second guess on top of it would only
786
+ * muddy a correct one.
787
+ */
788
+ async function explainIfOpenAi(raw, probeStatus, log, describe) {
789
+ if (typeof probeStatus !== "function") return false;
790
+ const url = openAiProbeUrl(raw);
791
+ if (!url) return false;
792
+ let status = 0;
793
+ try {
794
+ status = await probeStatus(url);
795
+ } catch {
796
+ return false;
797
+ }
798
+ if (!isOpenAiStatus(status)) return false;
799
+ let host = String(raw ?? "").trim();
800
+ try {
801
+ host = new URL(url).host;
802
+ } catch {
803
+ /* keep what was typed */
804
+ }
805
+ for (const line of describe(host)) log(` ${line}`);
806
+ return true;
807
+ }
808
+
809
+ async function loadFirstThatAnswers(raw, fetchTags, log) {
810
+ const candidates = urlCandidates(raw);
811
+ if (!candidates.length) {
812
+ return { failed: ["that is not a server address — try 192.168.1.40, ollama.local, or a full URL"] };
813
+ }
814
+ let last = null;
815
+ for (const normalized of candidates) {
816
+ try {
817
+ const models = await fetchTags(tagsUrlFor(normalized));
818
+ // Say WHICH reading answered, but only when we had to guess — otherwise it is noise
819
+ // repeating what the user just typed.
820
+ if (candidates.length > 1) log(` using ${displayUrl(normalized.url)}`);
821
+ log(` found ${models.length} model${models.length === 1 ? "" : "s"} on ${normalized.host}`);
822
+ return { normalized, models };
823
+ } catch (e) {
824
+ last = e;
825
+ }
826
+ }
827
+ // NOT PRINTED HERE ANY MORE. "check that Ollama is running there" is exactly the wrong thing
828
+ // to say about a correct address for a hosted provider, and the caller is the only one that
829
+ // knows whether it found a better explanation. It prints one message or the other, never both.
830
+ return { failed: [`could not reach ${candidates[0].host}: ${networkReason(last)}`,
831
+ "check the address, or that Ollama is running there and listening on the network."] };
832
+ }