@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,736 @@
1
+ /**
2
+ * The back half of Client Expert (#154): make a database, start the local API, and point this
3
+ * terminal at it. Everything up to here only CHECKED things; this is the part that acts.
4
+ *
5
+ * The decisions worth knowing:
6
+ *
7
+ * · The API's own MongoDB driver is reused rather than adding one to the CLI. The package
8
+ * installs `mongodb` next to itself, and `mongosh` is NOT bundled with the MongoDB Server
9
+ * MSI on Windows — so shelling out to a shell that may not exist would fail on the exact
10
+ * platform this was written for.
11
+ *
12
+ * · DATA_BACKEND=mongo is mandatory and is verified after boot, not assumed. Without it the
13
+ * API starts happily, answers /api/health with 200, and writes everything to a DynamoDB
14
+ * that is not there. That is the worst kind of broken — it looks fine — and it is why
15
+ * apiIsUsable() checks the health BODY rather than the status code.
16
+ *
17
+ * The pure decisions (env, health verdict, readiness) are separated from the IO so they can be
18
+ * tested without a database, a network, or Windows.
19
+ */
20
+ import { createRequire } from "node:module";
21
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
22
+ import { spawnSurviving, pidOnPort } from "./platform-tools.mjs";
23
+ import { spawn } from "node:child_process";
24
+ import { writeFile, mkdir, readFile, access } from "node:fs/promises";
25
+ import { homedir } from "node:os";
26
+ import { join } from "node:path";
27
+
28
+ /** Where the local API listens. 4100 keeps clear of the hosted API and of Vite's 5173. */
29
+ export const LOCAL_API_PORT = Number(process.env.GONEXT_LOCAL_API_PORT ?? "4100") || 4100;
30
+ export const LOCAL_API_BASE = `http://127.0.0.1:${LOCAL_API_PORT}`;
31
+ export const LOCAL_MONGO_URI = process.env.GONEXT_LOCAL_MONGO_URI || "mongodb://127.0.0.1:27017";
32
+ export const LOCAL_DB_NAME = process.env.GONEXT_LOCAL_DB_NAME || "gu";
33
+
34
+ const GONEXT_DIR = join(homedir(), ".gonext");
35
+ export const API_PID_FILE = join(GONEXT_DIR, "local-api.pid");
36
+ export const API_LOG_FILE = join(GONEXT_DIR, "local-api.log");
37
+
38
+ /** The API authenticates a worker by sha256 of the key — same function it uses server-side. */
39
+ export function hashWorkerKey(secret) {
40
+ return createHash("sha256").update(String(secret), "utf8").digest("hex");
41
+ }
42
+
43
+ /** Same shape the hosted API issues, so nothing downstream can tell the difference. */
44
+ export function generateWorkerKey() {
45
+ return `wk_${randomBytes(24).toString("base64url")}`;
46
+ }
47
+
48
+ /**
49
+ * A local identity. There is no Firebase here, and every row is keyed by userId, so one is
50
+ * minted once and kept — changing it later would orphan every conversation already stored.
51
+ */
52
+ export function generateLocalUserId() {
53
+ return `local-${randomUUID()}`;
54
+ }
55
+
56
+ /**
57
+ * Environment for the local API.
58
+ *
59
+ * DATA_BACKEND is the whole point: it defaults to "dynamo" inside the API, so omitting it
60
+ * yields a server that runs and persists nothing.
61
+ */
62
+ export function apiEnv({
63
+ port = LOCAL_API_PORT,
64
+ mongoUri = LOCAL_MONGO_URI,
65
+ dbName = LOCAL_DB_NAME,
66
+ base = {},
67
+ } = {}) {
68
+ return {
69
+ ...base,
70
+ PORT: String(port),
71
+ DATA_BACKEND: "mongo",
72
+ MONGODB_URI: mongoUri,
73
+ MONGODB_DB: dbName,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Is this health body a usable API?
79
+ *
80
+ * A 200 is not the question. The API answers 200 while writing to a DynamoDB that does not
81
+ * exist, so the body is what decides: the backend must be mongo AND persistence must be on.
82
+ */
83
+ export function apiIsUsable(body) {
84
+ if (!body || typeof body !== "object") return { ok: false, why: "no health response" };
85
+ if (body.ok !== true) return { ok: false, why: "API reports not ok" };
86
+ if (body.dataBackend !== "mongo") {
87
+ return {
88
+ ok: false,
89
+ why: `API is using "${body.dataBackend}" instead of mongo — it would persist nothing locally`,
90
+ };
91
+ }
92
+ if (body.persistence !== true) {
93
+ return { ok: false, why: "API reached mongo but reports persistence off" };
94
+ }
95
+ return { ok: true, why: "" };
96
+ }
97
+
98
+ /** Load the API's own `mongodb` package. Throws a legible error when deps are not installed. */
99
+ export function loadMongoDriver(apiDir) {
100
+ try {
101
+ return createRequire(join(apiDir, "package.json"))("mongodb");
102
+ } catch (e) {
103
+ throw new Error(
104
+ `the API's dependencies are not installed in ${apiDir} (${e.code || e.message})`
105
+ );
106
+ }
107
+ }
108
+
109
+ /** Have the API's dependencies been installed? */
110
+ export async function apiDepsInstalled(apiDir) {
111
+ try {
112
+ await access(join(apiDir, "node_modules", "mongodb", "package.json"));
113
+ return true;
114
+ } catch {
115
+ return false;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * `npm install` inside the downloaded API. ~360 packages, so it is reported, not silent.
121
+ *
122
+ * WINDOWS NEEDS A SHELL, and not for the usual reason. npm there is `npm.cmd`, and since the
123
+ * fix for CVE-2024-27980 (Node 18.20 / 20.12) spawning a .cmd or .bat WITHOUT a shell throws
124
+ * `spawn EINVAL` outright — which is exactly what a Windows 11 machine reported here. The
125
+ * shell is the supported way to run a batch script, not a workaround.
126
+ *
127
+ * Safe because every argument is a literal flag: nothing user-supplied reaches the command
128
+ * line. The one value that could contain spaces — the install directory — is passed as `cwd`,
129
+ * which never goes through the shell.
130
+ */
131
+ export function npmInstallCommand(platform = process.platform) {
132
+ const windows = platform === "win32";
133
+ const cmd = windows ? "npm.cmd" : "npm";
134
+ const args = ["install", "--omit=dev", "--no-audit", "--no-fund", "--loglevel=error"];
135
+ return {
136
+ cmd,
137
+ args,
138
+ // Only where it is required. A shell on POSIX would add quoting rules for no benefit.
139
+ shell: windows,
140
+ /**
141
+ * The shell form: ONE string, no args array.
142
+ *
143
+ * Node 22 prints DEP0190 — "Passing args to a child process with shell option true can
144
+ * lead to security vulnerabilities, as the arguments are not escaped, only concatenated" —
145
+ * and printed it in the middle of the Windows setup output, which reads like something
146
+ * went wrong. Node is right: with a shell, the array is concatenated anyway, so building
147
+ * the line explicitly is both quieter AND more honest about what runs. Safe because every
148
+ * argument here is a literal flag (asserted in the tests).
149
+ */
150
+ line: [cmd, ...args].join(" "),
151
+ };
152
+ }
153
+
154
+ export function installApiDeps(apiDir, { onLine } = {}) {
155
+ const { cmd, args, shell, line } = npmInstallCommand();
156
+ return new Promise((resolve, reject) => {
157
+ let child;
158
+ try {
159
+ // With a shell, pass the whole line and NO args array — see `line` above (DEP0190).
160
+ child = shell
161
+ ? spawn(line, { cwd: apiDir, stdio: ["ignore", "pipe", "pipe"], shell: true })
162
+ : spawn(cmd, args, { cwd: apiDir, stdio: ["ignore", "pipe", "pipe"], shell: false });
163
+ } catch (e) {
164
+ // spawn() can throw SYNCHRONOUSLY (EINVAL does). Without this the raw "spawn EINVAL"
165
+ // surfaced with no clue as to which command failed — which is how it was first reported.
166
+ reject(new Error(`could not run ${cmd} in ${apiDir}: ${e.message}`));
167
+ return;
168
+ }
169
+ let err = "";
170
+ child.stdout.on("data", (b) => onLine?.(String(b).trim()));
171
+ child.stderr.on("data", (b) => {
172
+ err += String(b);
173
+ onLine?.(String(b).trim());
174
+ });
175
+ child.on("error", (e) => reject(new Error(`could not run ${cmd} in ${apiDir}: ${e.message}`)));
176
+ child.on("exit", (code) =>
177
+ code === 0 ? resolve() : reject(new Error(`npm install failed (exit ${code}) ${err.slice(-400)}`))
178
+ );
179
+ });
180
+ }
181
+
182
+ /**
183
+ * Create the database and the worker key.
184
+ *
185
+ * The API builds its own indexes lazily on first use, so only the ones setup depends on are
186
+ * made here — the unique index on keyHash, which is what makes seeding idempotent.
187
+ */
188
+ export async function seedDatabase({
189
+ apiDir,
190
+ mongoUri = LOCAL_MONGO_URI,
191
+ dbName = LOCAL_DB_NAME,
192
+ userId,
193
+ workerKey,
194
+ } = {}) {
195
+ const { MongoClient } = loadMongoDriver(apiDir);
196
+ const client = new MongoClient(mongoUri, { serverSelectionTimeoutMS: 8000 });
197
+ await client.connect();
198
+ try {
199
+ const db = client.db(dbName);
200
+ const keys = db.collection("worker_keys");
201
+ await keys.createIndex({ keyHash: 1 }, { unique: true });
202
+ const key = workerKey || generateWorkerKey();
203
+ const uid = userId || generateLocalUserId();
204
+ await keys.updateOne(
205
+ { keyHash: hashWorkerKey(key) },
206
+ {
207
+ $set: {
208
+ keyHash: hashWorkerKey(key),
209
+ userId: uid,
210
+ workerHostId: "",
211
+ updatedAt: new Date().toISOString(),
212
+ },
213
+ },
214
+ { upsert: true }
215
+ );
216
+ return { workerKey: key, userId: uid, dbName };
217
+ } finally {
218
+ await client.close().catch(() => {});
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Start the API so it OUTLIVES this terminal — including an SSH session.
224
+ *
225
+ * `detached: true` is not enough on Windows: an SSH session runs inside a JOB OBJECT, and
226
+ * Windows kills every process in the job when the session ends. Proved on a real box — the
227
+ * API died, and so did the worker daemon, which uses the same detached spawn that survives
228
+ * perfectly from an ordinary console window. spawnSurviving() goes through the task scheduler
229
+ * there, whose children are not in our job.
230
+ */
231
+ export async function startLocalApi({
232
+ apiDir,
233
+ port = LOCAL_API_PORT,
234
+ mongoUri = LOCAL_MONGO_URI,
235
+ dbName = LOCAL_DB_NAME,
236
+ } = {}) {
237
+ await mkdir(GONEXT_DIR, { recursive: true });
238
+ const res = await spawnSurviving({
239
+ cmd: process.execPath,
240
+ args: ["index.js"],
241
+ cwd: apiDir,
242
+ env: apiEnv({ port, mongoUri, dbName }),
243
+ logFile: API_LOG_FILE,
244
+ taskName: "gu-local-api",
245
+ });
246
+ // A task-launched process is not our child, so its pid is discovered from the port it
247
+ // serves — which is the better question anyway: what matters is who is answering, not who
248
+ // we spawned. Recorded after the server is up, by the caller's waitForApi.
249
+ const pid = res.pid ?? null;
250
+ if (pid) await writeFile(API_PID_FILE, String(pid) + "\n").catch(() => {});
251
+ return { pid, how: res.how };
252
+ }
253
+
254
+ /** Record whoever is serving the port, so `api stop` has something to stop. */
255
+ export async function recordApiPid(port = LOCAL_API_PORT) {
256
+ const pid = await pidOnPort(port);
257
+ if (pid) await writeFile(API_PID_FILE, String(pid) + "\n").catch(() => {});
258
+ return pid;
259
+ }
260
+
261
+ /** Poll /api/health until it is USABLE (see apiIsUsable) or the budget runs out. */
262
+ export async function waitForApi({ port = LOCAL_API_PORT, timeoutMs = 45000, now = Date.now } = {}) {
263
+ const deadline = now() + timeoutMs;
264
+ let last = { ok: false, why: "never answered" };
265
+ while (now() < deadline) {
266
+ try {
267
+ const res = await fetch(`http://127.0.0.1:${port}/api/health`, {
268
+ signal: AbortSignal.timeout(4000),
269
+ });
270
+ const body = await res.json();
271
+ last = apiIsUsable(body);
272
+ if (last.ok) {
273
+ // The pid is only knowable once something is LISTENING, and on Windows the process is
274
+ // not our child at all — so it is learned from the port rather than from the spawn.
275
+ await recordApiPid(port).catch(() => {});
276
+ return { ...last, body };
277
+ }
278
+ // A wrong-backend API will never become right by waiting — fail fast instead of
279
+ // spending the whole budget on something a restart cannot fix.
280
+ if (body && body.ok === true) return { ...last, body };
281
+ } catch (e) {
282
+ last = { ok: false, why: e.name === "TimeoutError" ? "health timed out" : "not up yet" };
283
+ }
284
+ await new Promise((r) => setTimeout(r, 1000));
285
+ }
286
+ return { ...last, body: null };
287
+ }
288
+
289
+ /**
290
+ * Wait until nothing is answering on the port — the opposite of waitForApi (task #170 P4).
291
+ *
292
+ * WHY THIS IS NOT `await sleep(1500)`. Killing a server and sleeping is a guess about how long
293
+ * the OS takes to release a listening socket, and it is wrong in both directions: too short on a
294
+ * loaded CI runner, where the next start then fails with EADDRINUSE and looks like a product bug,
295
+ * and pure waste on a fast machine. The condition is observable, so observe it.
296
+ *
297
+ * Polls faster than waitForApi because going down is quick and this sits on the critical path of
298
+ * every restart.
299
+ */
300
+ export async function waitForApiDown({ port = LOCAL_API_PORT, timeoutMs = 20000, now = Date.now } = {}) {
301
+ const deadline = now() + timeoutMs;
302
+ while (now() < deadline) {
303
+ if (!(await probeLocalApi(port)).reachable) return { down: true, why: "" };
304
+ await new Promise((r) => setTimeout(r, 200));
305
+ }
306
+ // Reported rather than thrown: a caller that is about to start a fresh server may still want to
307
+ // try, and the message is more useful attached to THAT failure than raised here.
308
+ return { down: false, why: `something is still answering on ${port} after ${timeoutMs}ms` };
309
+ }
310
+
311
+ /** Is a previously started API still answering? Used by status/doctor and by resume. */
312
+ export async function probeLocalApi(port = LOCAL_API_PORT) {
313
+ try {
314
+ const res = await fetch(`http://127.0.0.1:${port}/api/health`, {
315
+ signal: AbortSignal.timeout(3000),
316
+ });
317
+ const body = await res.json();
318
+ return { reachable: true, ...apiIsUsable(body), body };
319
+ } catch {
320
+ return { reachable: false, ok: false, why: "no API on the port", body: null };
321
+ }
322
+ }
323
+
324
+ /** KEY=VALUE lines → object. Comments and blanks ignored; the first `=` splits. */
325
+ export function parseEnvFile(text) {
326
+ const out = {};
327
+ for (const line of String(text ?? "").split(/\r?\n/)) {
328
+ const t = line.trim();
329
+ if (!t || t.startsWith("#")) continue;
330
+ const eq = t.indexOf("=");
331
+ if (eq <= 0) continue;
332
+ out[t.slice(0, eq).trim()] = t.slice(eq + 1).trim();
333
+ }
334
+ return out;
335
+ }
336
+
337
+ /**
338
+ * The worker.env this terminal reads, MERGED over whatever is already there.
339
+ *
340
+ * Both existing writers rewrite the file from three fixed keys, which silently drops anything
341
+ * else a user has set (GONEXT_SEARXNG_URL and GONEXT_PROBE_PYTHON are both real examples). A
342
+ * setup step is the worst place to lose someone's configuration, so this one only overrides
343
+ * the two values it actually owns.
344
+ */
345
+ export function workerEnvContents({ apiBase, workerKey, existing = "" }) {
346
+ const merged = {
347
+ GONEXT_POLL_MS: "500",
348
+ ...parseEnvFile(existing),
349
+ GONEXT_API_BASE: String(apiBase).replace(/\/+$/, ""),
350
+ GONEXT_WORKER_KEY: workerKey,
351
+ };
352
+ return (
353
+ "# gu — Local mode: the API and database are on THIS machine.\n" +
354
+ "# Written by `gu` setup; `gu clear` removes it.\n" +
355
+ Object.entries(merged)
356
+ .map(([k, v]) => `${k}=${v}`)
357
+ .join("\n") +
358
+ "\n"
359
+ );
360
+ }
361
+
362
+ export async function writeWorkerEnv({ apiBase, workerKey }) {
363
+ await mkdir(GONEXT_DIR, { recursive: true });
364
+ const path = join(GONEXT_DIR, "worker.env");
365
+ const existing = await readFile(path, "utf8").catch(() => "");
366
+ await writeFile(path, workerEnvContents({ apiBase, workerKey, existing }));
367
+ return path;
368
+ }
369
+
370
+ /** Does worker.env already point at the local API? Answers the "worker-env" step's probe. */
371
+ export async function workerEnvPointsLocal(port = LOCAL_API_PORT) {
372
+ try {
373
+ const txt = await readFile(join(GONEXT_DIR, "worker.env"), "utf8");
374
+ return txt.includes(`:${port}`) && /GONEXT_API_BASE=https?:\/\/(127\.0\.0\.1|localhost)/.test(txt);
375
+ } catch {
376
+ return false;
377
+ }
378
+ }
379
+
380
+ /* ------------------------------------------------------------------ settings ----------- */
381
+
382
+ /**
383
+ * Defaults for a brand-new Client Expert machine.
384
+ *
385
+ * These MIRROR what the hosted API seeds when a normal account pairs (routes/workerHosts.ts,
386
+ * task #127). Client Expert never pairs with that API — it makes its own database — so nothing
387
+ * was seeding them here, and the terminal stopped dead on "no agent model is configured for
388
+ * your account yet" with advice (`gu-cli login`) that points at a server this machine
389
+ * deliberately does not use.
390
+ */
391
+ export const DEFAULT_CODER_URL = "https://api.moonshot.ai/v1";
392
+ export const DEFAULT_CODER_MODEL = "kimi-k3";
393
+ export const DEFAULT_MLX_AGENT_URL = "http://127.0.0.1:8082";
394
+ export const DEFAULT_OLLAMA_AGENT_URL = "http://127.0.0.1:11434/v1";
395
+ export const DEFAULT_EMBED_URL = "http://127.0.0.1:8085";
396
+ export const DEFAULT_EMBED_MODEL = "mlx-community/Qwen3-Embedding-8B-4bit-DWQ";
397
+
398
+ /** Is a local Ollama serving models, and what does it have? */
399
+ export async function probeLocalOllama() {
400
+ try {
401
+ const res = await fetch("http://127.0.0.1:11434/api/tags", {
402
+ signal: AbortSignal.timeout(1500),
403
+ });
404
+ if (!res.ok) return { ok: false, models: [] };
405
+ const body = await res.json();
406
+ const models = (body?.models ?? []).map((m) => String(m?.name ?? "")).filter(Boolean);
407
+ return { ok: true, models };
408
+ } catch {
409
+ return { ok: false, models: [] };
410
+ }
411
+ }
412
+
413
+ /**
414
+ * The model backends a Client Expert machine can be pointed at.
415
+ *
416
+ * A CHOICE, not a guess. The old code seeded whatever it could infer — a local Ollama URL on a
417
+ * machine with no Ollama, or the Apple-only MLX port — so setup finished "successfully" and the
418
+ * first question failed with a model that was never going to answer. Two options that both
419
+ * WORK are better than one that might.
420
+ */
421
+ /** The shared Ollama every client machine can already reach. */
422
+ export const SHARED_OLLAMA_URL =
423
+ process.env.GONEXT_CLIENT_AGENT_URL?.trim() || "https://ollama1.gomarsic.cc/v1";
424
+ /**
425
+ * Chat model — the one the agent routes and thinks with, on every step of every turn.
426
+ *
427
+ * Deliberately NOT the code model. Measured on the shared box: this one is 6GB resident and
428
+ * sits 100% in VRAM, while the 28GB coder does not fit on a 24GB card at all and spills to
429
+ * CPU. The chat model is called constantly, so its latency is the one a person feels; the
430
+ * coder is called for the code and is worth waiting for.
431
+ */
432
+ export const SHARED_CHAT_MODEL =
433
+ process.env.GONEXT_CLIENT_AGENT_MODEL?.trim() || "gemma4:e4b";
434
+ /** Code model: the bigger one, chosen deliberately for capability over latency. */
435
+ export const SHARED_CODE_MODEL =
436
+ process.env.GONEXT_CLIENT_CODE_MODEL?.trim() || "gemma4-quadro-49k:latest";
437
+ /**
438
+ * A SECOND coder on the same box, offered by `/model` (user request, 2026-08-24).
439
+ *
440
+ * Not a replacement for SHARED_CODE_MODEL and not the default — an alternative to switch to when
441
+ * the default one is struggling, which is the situation `/model` exists for. Same host, so it
442
+ * needs no URL, no key and no extra setup; it is purely a name added to the offered list.
443
+ */
444
+ export const SHARED_ALT_CODE_MODEL =
445
+ process.env.GONEXT_CLIENT_ALT_CODE_MODEL?.trim() || "qwen3.8:27b";
446
+ /**
447
+ * Embedder for RAG. Named here because the hosted default is an MLX server on :8085 — Apple
448
+ * Silicon only, and not running on a Client Expert box at all. Seeding that meant RAG could
449
+ * never work off a Mac; this is an embedder every client can actually reach.
450
+ */
451
+ export const SHARED_EMBED_MODEL =
452
+ process.env.GONEXT_CLIENT_EMBED_MODEL?.trim() || "nomic-embed-text:latest";
453
+
454
+ /**
455
+ * The three models a Client Expert machine starts with, all on the shared Ollama.
456
+ *
457
+ * NO QUESTION AT SETUP. There was one — Ollama or Kimi — and it earned its keep while the
458
+ * alternative was "a URL that answers nothing", but all three of these work out of the box and
459
+ * need no credential. The coder is changeable afterwards with `/model`, which is the right
460
+ * place for it: a decision you can revisit beats a question you must answer before you have
461
+ * seen the thing work.
462
+ */
463
+ export const CLIENT_EXPERT_MODELS = {
464
+ url: SHARED_OLLAMA_URL,
465
+ /** Chat: routing and plain replies, on EVERY turn — so this is the latency you feel. */
466
+ chat: SHARED_CHAT_MODEL,
467
+ /** Code: writes the code blocks that drive the tools. Worth waiting for. */
468
+ code: SHARED_CODE_MODEL,
469
+ /** Embeddings for RAG. */
470
+ embed: SHARED_EMBED_MODEL,
471
+ };
472
+
473
+ /**
474
+ * The coding backends to register.
475
+ *
476
+ * TWO of them, and the KIND is what makes it work. `codingNeedsKey` in the API is
477
+ * `kind === "openai"`, so an "ollama" backend needs no credential and is usable the moment it
478
+ * is written — while Kimi is registered as "openai" so `/model` lists it and asks for the key
479
+ * only if the user actually switches. One slot per kind, so these cannot collide.
480
+ */
481
+ export function codingBackendsForClientExpert(models = CLIENT_EXPERT_MODELS) {
482
+ const M = models ?? CLIENT_EXPERT_MODELS;
483
+ // The chat model is offered too, so `/model` can drop to the fast one for simple work, plus
484
+ // whatever else was chosen to switch UP to when the default coder is not coping.
485
+ //
486
+ // `altCoders` comes from the setup flow when the user picked their own box (task #220);
487
+ // SHARED_ALT_CODE_MODEL is the seeded default for the shared box. Deduped and stripped of the
488
+ // primary coder, because a `/model` list that offers the model you are already on — twice —
489
+ // is a list nobody trusts.
490
+ const offered = [M.chat, ...(M.altCoders ?? [SHARED_ALT_CODE_MODEL])].filter(
491
+ (name, i, all) => name && name !== M.code && all.indexOf(name) === i,
492
+ );
493
+ return [
494
+ {
495
+ kind: "ollama",
496
+ enabled: true,
497
+ url: M.url,
498
+ defaultModel: M.code,
499
+ models: offered,
500
+ },
501
+ {
502
+ kind: "openai",
503
+ enabled: true,
504
+ url: DEFAULT_CODER_URL,
505
+ defaultModel: DEFAULT_CODER_MODEL,
506
+ models: [],
507
+ },
508
+ ];
509
+ }
510
+
511
+ /**
512
+ * Which agent model to seed when nobody is asked (a non-interactive run).
513
+ *
514
+ * The hosted defaults assume MLX on :8082 — Apple-Silicon only. Client Expert runs on Linux
515
+ * and Windows too, where that port will never answer, so a local Ollama is preferred when one
516
+ * is actually serving. Pure, so the choice is testable without either being installed.
517
+ */
518
+ export function chooseAgentModel({ platform, ollama }) {
519
+ if (ollama?.ok) {
520
+ // A model that is already pulled beats a name that has to be downloaded first. Preference
521
+ // order is only a nicety — any served model is better than a URL that answers nothing.
522
+ const preferred = ["gemma4:12b", "qwen3:14b", "llama3.1:8b"];
523
+ const model =
524
+ preferred.find((p) => ollama.models.includes(p)) ?? ollama.models[0] ?? "";
525
+ return { url: DEFAULT_OLLAMA_AGENT_URL, model, why: "local Ollama is serving models" };
526
+ }
527
+ if (platform === "darwin") {
528
+ return { url: DEFAULT_MLX_AGENT_URL, model: "", why: "MLX default — `gu doctor` starts it" };
529
+ }
530
+ // No Ollama and not a Mac: seed the Ollama URL anyway. It is the only local server that runs
531
+ // here, so it is the right thing to point at once one exists — and a configured-but-silent
532
+ // URL still lets the terminal START, which a missing setting does not.
533
+ return {
534
+ url: DEFAULT_OLLAMA_AGENT_URL,
535
+ model: "",
536
+ why: "no local model server yet — install Ollama, or point the Agent model URL elsewhere",
537
+ };
538
+ }
539
+
540
+ /**
541
+ * Models this machine should be OFFERED that its stored settings do not list yet.
542
+ * → an updated agentCodingBackends array, or null when nothing is missing.
543
+ *
544
+ * WHY A BACKFILL AND NOT JUST A BIGGER SEED. The seed runs behind `if (!hasCoder)`, so it fires
545
+ * once, at first setup, and never again. Adding a model to codingBackendsForClientExpert()
546
+ * therefore reaches NEW installs only — every machine already running would keep the list it was
547
+ * seeded with and never see the new name in `/model`. That is the shape of "a published fix
548
+ * reached nobody" that `api upgrade` was created to solve (see its comment in gu-cli.mjs).
549
+ *
550
+ * ADDITIVE, AND ONLY ADDITIVE. It appends names to `models`; it never removes one, never touches
551
+ * defaultModel, and never touches agentCodingDefaultKind — so what this machine actually RUNS is
552
+ * unchanged, and the only effect is that `/model` offers one more entry. The known cost: a user
553
+ * who deliberately pruned a model from the list would get it back. There is no UI to prune one in
554
+ * Client Expert (the list is seeded), so that is theoretical today, and the alternative — never
555
+ * updating the list — is the failure being fixed.
556
+ *
557
+ * SCOPED TO THE SHARED OLLAMA BY URL. An "ollama" backend the user re-pointed at their own box
558
+ * must not be told it serves ollama1's models; the names would be offered, picked, and fail at a
559
+ * host that has never heard of them.
560
+ */
561
+ export function backfillCodingModels(existing = {}) {
562
+ const backends = existing?.agentCodingBackends;
563
+ if (!Array.isArray(backends) || backends.length === 0) return null;
564
+ const sameHost = (a, b) =>
565
+ String(a ?? "").trim().replace(/\/+$/, "") === String(b ?? "").trim().replace(/\/+$/, "");
566
+ const shared = codingBackendsForClientExpert().find((b) => b.kind === "ollama");
567
+ const known = [shared.defaultModel, ...shared.models].map((m) => String(m ?? "").trim()).filter(Boolean);
568
+ let changed = false;
569
+ const next = backends.map((b) => {
570
+ if ((b?.kind ?? "") !== "ollama" || !sameHost(b?.url, shared.url)) return b;
571
+ const listed = Array.isArray(b.models) ? b.models : [];
572
+ const have = new Set(
573
+ [b.defaultModel, ...listed].map((m) => String(m ?? "").trim()).filter(Boolean)
574
+ );
575
+ const missing = known.filter((m) => !have.has(m));
576
+ if (missing.length === 0) return b;
577
+ changed = true;
578
+ return { ...b, models: [...listed, ...missing] };
579
+ });
580
+ return changed ? next : null;
581
+ }
582
+
583
+ /**
584
+ * What to write, given what is already stored.
585
+ *
586
+ * ONLY fills what is missing, exactly like the hosted seeding: re-running setup must never
587
+ * overwrite a model the user has since chosen. Pure — the interesting behaviour is what it
588
+ * declines to touch. The one deliberate exception is backfillCodingModels, which ADDS offered
589
+ * model names to a list that already exists; see its docstring for why that is not "overwriting
590
+ * a model the user has since chosen".
591
+ */
592
+ export function settingsPatch({ existing = {}, models = CLIENT_EXPERT_MODELS } = {}) {
593
+ const patch = {};
594
+ // `models` is either the seeded shared-Ollama defaults (nobody was asked) or the box the user
595
+ // chose at setup (task #220). Everything below is written in terms of M, so the two paths
596
+ // cannot drift into configuring different fields.
597
+ const M = models ?? CLIENT_EXPERT_MODELS;
598
+
599
+ const hasAgent =
600
+ Boolean(existing.agentModelUrl?.trim()) ||
601
+ Boolean(existing.agentModel?.trim()) ||
602
+ Boolean(existing.agentModelName?.trim());
603
+ if (!hasAgent) {
604
+ patch.agentModelUrl = M.url;
605
+ const name = M.chat;
606
+ // agentModelNAME is the field that matters. With a direct agent URL the API reads
607
+ // `agentModelName` and falls back to the literal string "default_model" when it is empty —
608
+ // which is what the banner showed, and what the agent actually asked Ollama for.
609
+ // `agentModel` is the OTHER thing: the legacy mlx-host key.
610
+ patch.agentModelName = name;
611
+ patch.agentModel = name;
612
+ }
613
+
614
+ const hasRag = existing.ragEnabled === true || Boolean(existing.ragEmbedUrl?.trim());
615
+ // NO EMBEDDER, NO RAG. When the user points at their own box (task #220) it may have no
616
+ // embedding model on it at all, and `embed` is then ABSENT rather than empty. Enabling RAG
617
+ // anyway would leave it configured and pointing at nothing — the same failure the MLX-on-8085
618
+ // seed used to produce, which is why the URL below is M.url and not that port.
619
+ if (!hasRag && M.embed) {
620
+ patch.ragEnabled = true;
621
+ patch.ragEmbedUrl = M.url;
622
+ patch.ragEmbedModel = M.embed;
623
+ }
624
+
625
+ const hasCoder =
626
+ (Array.isArray(existing.agentCodingBackends) && existing.agentCodingBackends.length > 0) ||
627
+ Boolean(existing.agentCodingModelUrl?.trim()) ||
628
+ Boolean(existing.agentCodingModelName?.trim());
629
+ if (!hasCoder) {
630
+ patch.agentCodingBackends = codingBackendsForClientExpert(M);
631
+ patch.agentCodingDefaultKind = "ollama";
632
+ // Legacy mirror, so an older worker resolves the same coder.
633
+ patch.agentCodingModelKind = "ollama";
634
+ patch.agentCodingModelUrl = M.url;
635
+ patch.agentCodingModelName = M.code;
636
+ // NO api key is stored. The default coder is kind "ollama", which the API does not require
637
+ // one for — and a placeholder here would be SENT to Kimi the moment someone switched with
638
+ // `/model`, failing at the provider with an auth error nobody expected.
639
+ } else {
640
+ // Already configured — so the seed above will never run again on this machine. Add any
641
+ // newly-offered shared-Ollama models to the list it was seeded with, without changing which
642
+ // coder it uses. See backfillCodingModels.
643
+ const merged = backfillCodingModels(existing);
644
+ if (merged) patch.agentCodingBackends = merged;
645
+ }
646
+
647
+ // FULL CODE-MODEL RESPONSES ARE SAVED IN THIS MODE (user, 2026-08-10: "in Client Expert,
648
+ // lets make in On by default for now").
649
+ //
650
+ // Why it has to be seeded rather than left to the user: agentSaveFullResponse's only writer
651
+ // is PATCH /api/settings, which is mounted behind requireFirebaseAuth — and Client Expert
652
+ // deliberately has no Firebase, and no web app pointed at the local API. So a Client Expert
653
+ // machine cannot turn this on by ANY supported route; off was not a default anyone chose, it
654
+ // was the only reachable state. Seeding is what makes the setting exist at all here.
655
+ //
656
+ // Why ON is defensible in THIS mode specifically: the text never leaves the machine. It is
657
+ // written to a MongoDB on the user's own disk, which is the entire premise of Client Expert —
658
+ // whereas on the hosted deployment the same flag ships full model output to our servers, which
659
+ // is exactly why it is opt-in there. Same field, different blast radius.
660
+ //
661
+ // What it buys: the raw reply that failed to parse (task #161). Diagnosing a code-parsing
662
+ // error without it means inferring the model's output from the error message about it.
663
+ //
664
+ // Only when the row does not already say — an explicit false is the user's choice, and a
665
+ // re-run of setup must not silently switch logging back on for someone who turned it off.
666
+ if (typeof existing.agentSaveFullResponse !== "boolean") {
667
+ patch.agentSaveFullResponse = true;
668
+ }
669
+ return patch;
670
+ }
671
+
672
+ /** Write the settings row this machine's own API will read. Returns what was written. */
673
+ /**
674
+ * Is this machine's model configuration already set?
675
+ *
676
+ * Exists so the setup flow can decide whether to ASK (task #220). settingsPatch deliberately
677
+ * leaves a configured machine alone — that rule protects anyone who set their models up by hand
678
+ * — but combined with a question it produces the one behaviour this codebase keeps removing:
679
+ * asking something and then ignoring the answer. On a first run there is nothing here and the
680
+ * question is honoured; on a re-run of the models step there is, and the honest move is to not
681
+ * ask and to point at `/model`, which CAN change a configured machine.
682
+ *
683
+ * Best-effort: an unreachable database returns false, so the question is asked and seedSettings
684
+ * makes the real decision a moment later. Being wrong in that direction costs a question; being
685
+ * wrong the other way costs the answer.
686
+ */
687
+ export async function modelsAlreadyConfigured({
688
+ apiDir,
689
+ mongoUri = LOCAL_MONGO_URI,
690
+ dbName = LOCAL_DB_NAME,
691
+ userId,
692
+ } = {}) {
693
+ try {
694
+ const { MongoClient } = loadMongoDriver(apiDir);
695
+ const client = new MongoClient(mongoUri, { serverSelectionTimeoutMS: 4000 });
696
+ await client.connect();
697
+ try {
698
+ const existing = (await client.db(dbName).collection("settings").findOne({ userId })) ?? {};
699
+ return Object.keys(settingsPatch({ existing })).length === 0;
700
+ } finally {
701
+ await client.close().catch(() => {});
702
+ }
703
+ } catch {
704
+ return false;
705
+ }
706
+ }
707
+
708
+ export async function seedSettings({
709
+ apiDir,
710
+ mongoUri = LOCAL_MONGO_URI,
711
+ dbName = LOCAL_DB_NAME,
712
+ userId,
713
+ // The box the user chose at setup (task #220), or the shared-Ollama defaults when the
714
+ // question was skipped or declined. Passed straight to settingsPatch so there is exactly one
715
+ // place that decides what a Client Expert machine is configured with.
716
+ models = CLIENT_EXPERT_MODELS,
717
+ } = {}) {
718
+ const { MongoClient } = loadMongoDriver(apiDir);
719
+ const client = new MongoClient(mongoUri, { serverSelectionTimeoutMS: 8000 });
720
+ await client.connect();
721
+ try {
722
+ const col = client.db(dbName).collection("settings");
723
+ const existing = (await col.findOne({ userId })) ?? {};
724
+ const patch = settingsPatch({ existing, models });
725
+ if (Object.keys(patch).length > 0) {
726
+ await col.updateOne(
727
+ { userId },
728
+ { $set: { ...patch, userId, updatedAt: new Date().toISOString() } },
729
+ { upsert: true },
730
+ );
731
+ }
732
+ return patch;
733
+ } finally {
734
+ await client.close().catch(() => {});
735
+ }
736
+ }