@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
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # @tiens.nguyen/gu-cli
2
+ Run:
3
+ GONEXT_API_BASE=... GONEXT_WORKER_KEY=... npx -y --package @tiens.nguyen/gu-cli gu-cli
4
+
5
+ ## Agent chat mode
6
+
7
+ Select **Agent** (instead of Chat) under the composer in the web app. Your
8
+ free-form prompt is handed to a [smolagents](https://github.com/huggingface/smolagents)
9
+ agent running on your local MLX/Ollama model. The agent can call tools (v1:
10
+ `http_request`) and streams its thinking steps + final answer directly into the
11
+ chat thread.
12
+
13
+ Requires smolagents in the worker's Python environment:
14
+
15
+ ```sh
16
+ pip install smolagents certifi
17
+ ```
18
+
19
+ - `certifi` supplies a trusted CA bundle so the agent's `http_request` tool can
20
+ verify HTTPS certificates on macOS (where Python's default bundle may be
21
+ missing). The worker falls back to the system bundle if certifi is absent.
22
+ - Agent mode is blocked for cloud models (the API returns 400). Select a local
23
+ MLX or Ollama model first.
24
+ - Tool steps appear in the collapsible reasoning (`<think>`) area; the final
25
+ answer is the message body — no new UI needed.
26
+
27
+ The agent script is `gonext_agent_chat.py` (reads `{messages, agentBaseURL,
28
+ agentApiKey, agentModelId, tools, maxSteps}` on stdin; emits NDJSON
29
+ `{"type":"step"/"final","text":"..."}` lines on stdout).
30
+
31
+ ## API Check / HTTP probe (Tools & Agents modes)
32
+
33
+ The worker can run Postman-style HTTP probes queued from the web app
34
+ ("Tools" = `tool_only`, "Agents" = `agentic`). The worker always performs the
35
+ actual HTTP request itself (Node `fetch`, so TLS works), measures the
36
+ status/latency/headers/body, and classifies it (2xx/3xx/4xx/5xx/timeout/
37
+ network_error).
38
+
39
+ - **Tools (`tool_only`)** — no extra setup. The selected local model writes a
40
+ one-line health summary of the measured result.
41
+ - **Agents (`agentic`)** — a smolagents agent (running on the selected local
42
+ model) produces the summary. Requires `pip install smolagents`.
43
+
44
+ The agent talks to your local MLX OpenAI-compatible server (no cloud calls).
45
+ The agent only summarizes; the worker's measurement stays the source of truth,
46
+ so if smolagents or the model is unavailable the probe still returns the
47
+ measured result with a note.
48
+
49
+ ### Env vars
50
+
51
+ GONEXT_PROBE_PYTHON Python executable for smolagents scripts
52
+ (default: GONEXT_MLX_LM_PYTHON or python3)
@@ -0,0 +1,259 @@
1
+ /**
2
+ * `/agent-model` — choosing the model that TALKS, from what the Ollama box actually has (#169).
3
+ *
4
+ * TERMINAL-ONLY BY NECESSITY, NOT BY PREFERENCE. On a Client Expert machine there is no Firebase
5
+ * and no web app pointed at the local API, so `PATCH /api/settings` — the route the web app uses
6
+ * for this — cannot be reached at all. The terminal is the only interface that exists, which is
7
+ * why the worker-key route had to learn to write agentModelName and why this command exists. The
8
+ * value lands in the MongoDB on the user's own disk; nothing leaves the machine.
9
+ *
10
+ * WHY THIS LIST IS LIVE AND /embed-model's IS HARDCODED. The two look symmetrical and are not.
11
+ * A chat model is interchangeable: pick a different one and the next answer is different, and
12
+ * that is the whole effect. An embedder decides the shape of every vector already on disk, so its
13
+ * list is two known-good options (see embed-model-command.mjs). Here, anything pulled onto the
14
+ * box should be selectable without waiting for a release — so the list is GET /api/tags, live.
15
+ *
16
+ * Which means it can fail, and failing well is most of the code below: a picker that renders
17
+ * empty reads as "there are no models", which is never what a network error means.
18
+ */
19
+
20
+ import { modelRetired, ensureV1Root } from "./coding-provider-setup.mjs";
21
+
22
+
23
+ /**
24
+ * Where to ask. Derived from the configured agent URL rather than hardcoded, so a machine
25
+ * pointed at its own Ollama lists its own models — for Client Expert this resolves to exactly
26
+ * the ollama1 box it was seeded with.
27
+ *
28
+ * `/v1` is stripped because that is the OpenAI-compatible face of Ollama; /api/tags lives on the
29
+ * native one, and the settings field may carry either.
30
+ */
31
+ export function agentTagsUrl(settings) {
32
+ const raw = String(settings?.agentModelUrl ?? "").trim();
33
+ if (!raw) return "";
34
+ const root = raw.replace(/\/+$/, "").replace(/\/v1$/i, "");
35
+ return `${root}/api/tags`;
36
+ }
37
+
38
+ /** What is set right now. agentModelName is the field the API reads when a direct URL is set. */
39
+ export function currentAgentModel(settings) {
40
+ return String(settings?.agentModelName ?? settings?.agentModel ?? "").trim();
41
+ }
42
+
43
+ /**
44
+ * An embedder in the chat list is a trap worth labelling.
45
+ *
46
+ * /api/tags returns everything pulled onto the box, embedding models included — and nomic-embed-
47
+ * text sitting between two chat models looks like a choice. Picking it does not fail at the
48
+ * point of choice: it fails as an agent that answers with garbage. We do not HIDE them (a model
49
+ * the user pulled and cannot find is its own bug report), we sink them and say what they are.
50
+ */
51
+ export function isLikelyEmbedder(entry) {
52
+ const family = String(entry?.family ?? "").toLowerCase();
53
+ const name = String(entry?.model ?? "").toLowerCase();
54
+ return family === "bert" || family === "nomic-bert" || /embed/.test(name);
55
+ }
56
+
57
+ /**
58
+ * Parse GET /api/tags into rows we can show.
59
+ *
60
+ * Tolerant on purpose: a proxy that returns HTML, an empty body, a shape from a future Ollama —
61
+ * all of them mean "no list", which the caller renders as a failure rather than as an empty box.
62
+ */
63
+ export function parseOllamaTags(json) {
64
+ const rows = Array.isArray(json?.models) ? json.models : [];
65
+ const out = [];
66
+ for (const m of rows) {
67
+ const model = String(m?.model ?? m?.name ?? "").trim();
68
+ if (!model) continue;
69
+ out.push({
70
+ model,
71
+ sizeBytes: Number(m?.size) || 0,
72
+ family: String(m?.details?.family ?? "").trim(),
73
+ params: String(m?.details?.parameter_size ?? "").trim(),
74
+ });
75
+ }
76
+ // Chat models first, then embedders, alphabetical within each — a stable order, so the same
77
+ // box always presents the same list and muscle memory keeps working.
78
+ return out.sort((a, b) => {
79
+ const ea = isLikelyEmbedder(a) ? 1 : 0;
80
+ const eb = isLikelyEmbedder(b) ? 1 : 0;
81
+ return ea !== eb ? ea - eb : a.model.localeCompare(b.model);
82
+ });
83
+ }
84
+
85
+ /**
86
+ * The picker's rows, with the current model marked (requirement 6).
87
+ *
88
+ * A configured model the box does not list still gets a row. It may have been deleted there, or
89
+ * the list may be from a different host — either way, dropping it would silently misreport what
90
+ * this machine is set to, and re-picking would look like a no-op when it is a change.
91
+ */
92
+ export function agentModelRows(models, current) {
93
+ const cur = String(current ?? "").trim();
94
+ const rows = (models ?? []).map((m) => ({
95
+ ...m,
96
+ current: m.model === cur,
97
+ embedder: isLikelyEmbedder(m),
98
+ }));
99
+ if (cur && !rows.some((r) => r.current)) {
100
+ rows.unshift({
101
+ model: cur,
102
+ sizeBytes: 0,
103
+ family: "",
104
+ params: "",
105
+ current: true,
106
+ embedder: false,
107
+ missing: true,
108
+ });
109
+ }
110
+ return rows;
111
+ }
112
+
113
+ /**
114
+ * WHAT TO SAY WHEN THE LIST CANNOT BE FETCHED (requirement 3).
115
+ *
116
+ * The failure mode this prevents is an empty picker, which reads as "no models exist" — a much
117
+ * more alarming and completely different claim than "I could not reach the box". The current
118
+ * model is always named, because that is the one fact still true when the network is not.
119
+ */
120
+ export function describeTagsFailure({ url, current, error, proxied = false } = {}) {
121
+ const lines = [
122
+ `couldn't fetch the model list from ${url || "(no agent model URL configured)"}`,
123
+ ];
124
+ if (error) lines.push(` ${error}`);
125
+ lines.push(
126
+ current
127
+ ? `still using ${current} — unchanged.`
128
+ : "no agent model is set on this machine, and the list is unavailable."
129
+ );
130
+ if (!proxied) {
131
+ // Task #160: ollama1 is one of the three allowlisted model origins. On a restricted network
132
+ // the direct route is exactly what fails, and the proxy is the thing that fixes it.
133
+ lines.push("if this machine needs a proxy to reach it, set one with /proxy.");
134
+ }
135
+ return lines;
136
+ }
137
+
138
+ /** Human size for the picker — a 40GB model on a small box is worth seeing before choosing. */
139
+ export function formatSize(bytes) {
140
+ const n = Number(bytes) || 0;
141
+ if (n <= 0) return "";
142
+ const gb = n / 1e9;
143
+ return gb >= 1 ? `${gb.toFixed(1)}GB` : `${Math.round(n / 1e6)}MB`;
144
+ }
145
+
146
+ /**
147
+ * WHAT KIND OF THING IS THIS MODEL ID, if it is not a chat model?
148
+ *
149
+ * Returns a short phrase to LABEL it with, or "" when it looks like something you can talk to.
150
+ * Deliberately pattern-based on the id: /v1/models reports an id, an object type, a creation
151
+ * date and an owner, and nothing that says "this one generates images". The vendor's own naming
152
+ * is the only signal there is, and it is a reliable one for exactly the families that matter.
153
+ *
154
+ * ERRING TOWARDS "" IS THE SAFE DIRECTION. An unrecognised id stays unlabelled and sorts with the
155
+ * chat models, which is right: a new chat model must not be buried on the day it ships, and the
156
+ * cost of a wrong guess in the other direction is a model that answers badly — visible in one
157
+ * turn — rather than a model the user cannot find at all.
158
+ */
159
+ export function nonChatKind(id) {
160
+ const s = String(id ?? "").toLowerCase();
161
+ if (!s) return "";
162
+ if (/embed/.test(s)) return "an embedding model";
163
+ if (/whisper|tts|audio|speech|transcribe|realtime/.test(s)) return "an audio/speech model";
164
+ if (/moderation/.test(s)) return "a moderation model";
165
+ if (/dall-e|^image|-image|imagen|stable-diffusion|sora|video/.test(s)) return "an image/video model";
166
+ if (/rerank/.test(s)) return "a reranking model";
167
+ return "";
168
+ }
169
+
170
+ /**
171
+ * The agent picker's rows, read from an OpenAI-compatible /v1/models body (task #225 phase 3).
172
+ *
173
+ * SHOW EVERYTHING, SINK AND LABEL. The user chose this explicitly, and it is deliberately the
174
+ * OPPOSITE of what the coding picker does — the same person asked that one to filter hard, and
175
+ * chose "show everything the provider lists" here. It also matches the rule this file already
176
+ * applied to embedders in an Ollama list: "We do not HIDE them … we sink them and say what they
177
+ * are." A model the user can see and was warned about beats one that silently is not there.
178
+ *
179
+ * THREE BANDS, in the order of how much you should hesitate:
180
+ * 0 chat models — unlabelled, alphabetical
181
+ * 1 live but not for chatting — labelled with what they are
182
+ * 2 the provider says SHUT DOWN — labelled, and last, because this is the only category that
183
+ * cannot work at all rather than merely working badly
184
+ *
185
+ * The row shape matches parseOllamaTags so agentModelRows renders both without a special case;
186
+ * `sizeBytes` is 0 because a hosted provider does not report one and inventing a number would be
187
+ * worse than showing none.
188
+ */
189
+ export function agentModelsFromProvider(body, now = Date.now()) {
190
+ const rows = Array.isArray(body?.data) ? body.data : Array.isArray(body) ? body : null;
191
+ if (!rows) return [];
192
+ const out = [];
193
+ const seen = new Set();
194
+ for (const r of rows) {
195
+ const id = typeof r === "string" ? r.trim() : typeof r?.id === "string" ? r.id.trim() : "";
196
+ if (!id || seen.has(id)) continue;
197
+ seen.add(id);
198
+ // The RETIREMENT rule is shared with the coding flow rather than re-decided here: what
199
+ // counts as "shut down" must not mean two different things in two pickers.
200
+ const dead = modelRetired(r, now);
201
+ const kind = dead ? "" : nonChatKind(id);
202
+ out.push({
203
+ model: id,
204
+ sizeBytes: 0,
205
+ family: "",
206
+ params: "",
207
+ note: dead ? "the provider says this model is shut down" : kind,
208
+ band: dead ? 2 : kind ? 1 : 0,
209
+ });
210
+ }
211
+ // Stable: band, then alphabetical. The same body always renders the same list, so a position
212
+ // learned once stays learned.
213
+ return out.sort((a, b) => (a.band !== b.band ? a.band - b.band : a.model.localeCompare(b.model)));
214
+ }
215
+
216
+ /**
217
+ * WHERE DO WE ASK FOR THE AGENT'S MODEL LIST, AND IN WHAT DIALECT? (task #225 phase 3)
218
+ *
219
+ * The picker has only ever spoken Ollama — agentTagsUrl builds `${root}/api/tags` and the reply
220
+ * goes to parseOllamaTags. A hosted provider answers GET /v1/models instead, with a different
221
+ * body. This is the one decision that tells the two apart, kept pure so both branches can be
222
+ * tested without a network.
223
+ *
224
+ * DECIDED BY THE PROVIDER ROW, not by sniffing the URL. The account already says what each
225
+ * endpoint IS — the user chose "ollama" or "openai" when they added it — and that stored answer
226
+ * beats any guess from a hostname. It is also the same rule the API uses to resolve the agent's
227
+ * key (app.ts), so the list and the credential cannot disagree about which provider is in play.
228
+ *
229
+ * NO MATCHING ROW → OLLAMA, which is exactly today's behaviour and therefore safe for every
230
+ * existing install: they all have a local agent URL and no row for it.
231
+ *
232
+ * Returns { url, dialect: "ollama" | "openai", row } — row is the matched provider or null.
233
+ */
234
+ export function agentListSource(settings) {
235
+ const direct = String(settings?.agentModelUrl ?? "").trim();
236
+ if (!direct) return { url: "", dialect: "ollama", row: null };
237
+ const rows = Array.isArray(settings?.agentCodingBackends) ? settings.agentCodingBackends : [];
238
+ // Case-folded for the same reason app.ts folds it: normalising does not lower-case the host,
239
+ // so a URL typed in capitals connects fine and would otherwise fail to match its own row.
240
+ const want = ensureV1Root(direct).toLowerCase();
241
+ const row = rows.find(
242
+ (b) => b && ensureV1Root(String(b.url ?? "")).toLowerCase() === want
243
+ );
244
+ if (row && String(row.kind ?? "").trim() === "openai") {
245
+ // The row's url already ends at the /v1 root, so the models endpoint hangs directly off it.
246
+ return { url: `${ensureV1Root(String(row.url ?? ""))}/models`, dialect: "openai", row };
247
+ }
248
+ return { url: agentTagsUrl(settings), dialect: "ollama", row: row ?? null };
249
+ }
250
+
251
+ /**
252
+ * Read a model list in whichever dialect agentListSource named.
253
+ *
254
+ * One entry point so the caller does not branch twice — once to choose the URL and again to
255
+ * choose the parser — which is how the two get out of step.
256
+ */
257
+ export function agentModelsFromBody(dialect, body, now = Date.now()) {
258
+ return dialect === "openai" ? agentModelsFromProvider(body, now) : parseOllamaTags(body);
259
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * What the banner should CALL the agent's chat model (#170).
3
+ *
4
+ * REPORTED: a Host-mode machine showed `model default_model` — the same on a fresh setup as on
5
+ * an old one. The account was not missing the name; it was structurally unable to reach it:
6
+ *
7
+ * agentModelUrl = 'http://127.0.0.1:8090' ← a direct URL is set
8
+ * agentModelName = '' ← so THIS is the field that names the model
9
+ * agentModel = 'Qwen3-14B-4bit@@port:8080' ← the name, in the branch nobody reads
10
+ *
11
+ * api/src/app.ts resolves `directAgentName || "default_model"` whenever a direct URL is set, and
12
+ * the legacy mlx-host key it would otherwise parse the name out of is only consulted when there
13
+ * is NO direct URL. Client Expert seeds agentModelName during setup, which is why its banner is
14
+ * right; Host mode never writes that field at all.
15
+ *
16
+ * WHY THIS IS A DISPLAY FIX AND NOT A SETTINGS FIX — the important part.
17
+ *
18
+ * The tempting repair is to have Host mode seed agentModelName the way Client Expert does. On an
19
+ * MLX host that BREAKS TURNS. mlx_lm/server.py maps exactly one name:
20
+ *
21
+ * self._model_map["default_model"] = self.cli_args.model # the ONLY mapping
22
+ * model_path = self._model_map.get(model_path, model_path) # anything else is a PATH
23
+ *
24
+ * so asking it for "Qwen3-14B-4bit" makes it treat that as a repo to load — evicting the model it
25
+ * has and attempting a download — until the agent's model-not-found retry falls back to the
26
+ * sentinel. Client Expert can seed a name because its backend is OLLAMA, where the name is what
27
+ * SELECTS the model from many on one port. MLX is the inverse: "default_model" is the correct
28
+ * thing to send, and only the wrong thing to show a person.
29
+ *
30
+ * So: the wire keeps the sentinel, and the human is told what is actually loaded. The authority
31
+ * for that is the process's own `--model` argument (see inspectModelServers — mlx_lm.server's
32
+ * /v1/models advertises the whole HF cache, not the loaded model, so it cannot be asked).
33
+ */
34
+
35
+ import { modelDisplayName, parsePort } from "./model-doctor.mjs";
36
+
37
+ /**
38
+ * Is this URL served by THIS process's machine — the only case where a local `ps` row is
39
+ * evidence about it?
40
+ *
41
+ * DELIBERATELY NOT model-doctor's isLocalModelUrl, which answers a different question: it counts
42
+ * LAN and tailnet addresses as local because it is deciding whether the doctor *could* start a
43
+ * server there. Here that would be a mislabel with a straight face — a client machine pointed at
44
+ * http://192.168.1.50:8090 would get whatever this laptop happens to be running. Loopback only.
45
+ */
46
+ export function servesFromThisMachine(baseUrl) {
47
+ let host = "";
48
+ try {
49
+ host = new URL(String(baseUrl || "").includes("://") ? baseUrl : `http://${baseUrl}`)
50
+ .hostname.toLowerCase()
51
+ .replace(/\.$/, "");
52
+ } catch {
53
+ return false;
54
+ }
55
+ if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
56
+ if (!host) return false;
57
+ if (["localhost", "0.0.0.0", "::", "::1"].includes(host)) return true;
58
+ return /^127(\.\d{1,3}){3}$/.test(host);
59
+ }
60
+
61
+ /**
62
+ * mlx_lm.server's "whatever I loaded" sentinel, and what the API returns when a direct agent URL
63
+ * is set with no name beside it. Not a model name, and must never be shown as one.
64
+ */
65
+ export const LOADED_MODEL_SENTINEL = "default_model";
66
+
67
+ /** Does this id name a model, or is it a stand-in for one? */
68
+ export function namesAModel(id) {
69
+ const s = String(id ?? "").trim();
70
+ return s !== "" && s !== LOADED_MODEL_SENTINEL;
71
+ }
72
+
73
+ /**
74
+ * Decide what to call the agent's chat model.
75
+ *
76
+ * `servers` is discoverModelServers() output — [{ pid, port, kind, modelPath }] parsed from `ps`.
77
+ *
78
+ * Returns { name, known, reason }:
79
+ * known:true → `name` is a real model name, safe to print as one
80
+ * known:false → `reason` says why we cannot name it, so the caller can be honest rather than
81
+ * printing a sentinel: "settings" is never a reason (a named model is known),
82
+ * leaving "remote", "no-server" and "ambiguous".
83
+ */
84
+ export function agentModelLabel({ configuredId = "", agentUrl = "", servers = [] } = {}) {
85
+ // A REAL name wins outright. This is the Ollama / Client Expert case, where the name is not
86
+ // decoration — it selects the model — and second-guessing it with a local process listing is
87
+ // how you end up labelling someone's remote model with whatever is running on their laptop.
88
+ if (namesAModel(configuredId)) {
89
+ return { name: String(configuredId).trim(), known: true, reason: "settings" };
90
+ }
91
+ // The sentinel means "ask the server". We can only do that for a server on THIS machine: a
92
+ // client machine points at another Mac, and a local `ps` row is then somebody else's process.
93
+ if (!servesFromThisMachine(agentUrl)) {
94
+ return { name: "", known: false, reason: "remote" };
95
+ }
96
+ // NEVER the embedder. It is a model server on this machine with a perfectly good name, and
97
+ // grabbing it here would put "Qwen3-Embedding-8B-4bit-DWQ" in the chat-model row — a mistake
98
+ // that looks like data rather than a bug.
99
+ const chat = (Array.isArray(servers) ? servers : []).filter(
100
+ (s) => s?.kind === "mlx-lm" && String(s?.modelPath || "").trim()
101
+ );
102
+ if (chat.length === 0) return { name: "", known: false, reason: "no-server" };
103
+ const port = parsePort(agentUrl);
104
+ const onPort = port ? chat.filter((s) => s.port === port) : [];
105
+ // The port the agent will actually talk to is the only match that proves anything. Falling
106
+ // back to "the only mlx server running" is safe precisely because there is nothing to confuse
107
+ // it with; with several and none on the right port, we genuinely do not know which answers.
108
+ const pick = onPort[0] ?? (chat.length === 1 ? chat[0] : null);
109
+ if (!pick) return { name: "", known: false, reason: "ambiguous" };
110
+ const name = modelDisplayName(pick.modelPath);
111
+ return name
112
+ ? { name, known: true, reason: "running" }
113
+ : { name: "", known: false, reason: "no-server" };
114
+ }
115
+
116
+ /**
117
+ * WHAT is actually being served on a port, from `ps` — { name, path } or null.
118
+ *
119
+ * `doctor` used to report the chat model as "✓ chat model is up on port 8090", which answers
120
+ * "is something listening" and not "is it the right thing, from the right folder" — reported by
121
+ * a user who could not tell whether the server that was already up was even serving the model
122
+ * they meant. The RAG section had always named its model ("…for Qwen3-Embedding-8B-4bit-DWQ");
123
+ * the chat section named a port number.
124
+ */
125
+ export function servedModelAt({ port, servers = [], kind = "mlx-lm" } = {}) {
126
+ const row = (Array.isArray(servers) ? servers : []).find(
127
+ (s) => s?.kind === kind && s?.port === port && String(s?.modelPath || "").trim()
128
+ );
129
+ if (!row) return null;
130
+ const name = modelDisplayName(row.modelPath);
131
+ return name ? { name, path: row.modelPath } : null;
132
+ }
133
+
134
+ /**
135
+ * Do these name the same model? Compares the LAST path segment, so a folder and a bare name
136
+ * agree ("/Users/j/mlx-models/Qwen3-14B-4bit" vs "Qwen3-14B-4bit"), and case-insensitively,
137
+ * because the account stores what a human typed.
138
+ *
139
+ * Used to warn when the server already running is not the model the account asks for — the
140
+ * mismatch doctor could not previously see, because it never looked at what was loaded.
141
+ */
142
+ export function sameModelName(a, b) {
143
+ const norm = (x) => modelDisplayName(String(x ?? "").trim()).toLowerCase();
144
+ const na = norm(a);
145
+ return na !== "" && na === norm(b);
146
+ }
147
+
148
+ /** Plain-text fallback for each reason we cannot name the model. Never a sentinel. */
149
+ export const UNKNOWN_MODEL_TEXT = Object.freeze({
150
+ remote: "(named by the remote server)",
151
+ "no-server": "(no model server running)",
152
+ ambiguous: "(the loaded model)",
153
+ });
154
+
155
+ /** The banner's text for a label, with no colour applied — `dim` is the caller's business. */
156
+ export function agentModelText(label) {
157
+ if (label?.known) return label.name;
158
+ return UNKNOWN_MODEL_TEXT[label?.reason] ?? UNKNOWN_MODEL_TEXT.ambiguous;
159
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * `gu clear` — put this machine back to how it was before gu ran.
3
+ *
4
+ * Written for testing the first-run flow, which is otherwise a one-shot: once ~/.gonext/
5
+ * worker.env exists the machine is paired, the mode question stops being asked, and Client
6
+ * Expert refuses outright — so there is no way to see the beginning again short of deleting
7
+ * files by hand and guessing which ones.
8
+ *
9
+ * IT ASKS FIRST, ALWAYS, and lists exactly what it will remove with sizes. Some of this is
10
+ * genuinely valuable — conversation history, RAG indexes that took minutes of GPU time, the
11
+ * backups the agent's own edits can be reverted from — and a reset command that quietly ate
12
+ * them would be the last thing anyone trusted with `--yes`.
13
+ */
14
+ import { readdir, rm, stat } from "node:fs/promises";
15
+ import { homedir } from "node:os";
16
+ import { join } from "node:path";
17
+
18
+ export const GONEXT_DIR = join(homedir(), ".gonext");
19
+
20
+ /**
21
+ * What lives in ~/.gonext, and whether a reset should remove it.
22
+ *
23
+ * Grouped by what LOSING it costs, because that is the only thing that helps someone decide.
24
+ * Anything not listed is left alone: a reset that deletes files it does not recognise is not
25
+ * a reset, it is an accident waiting for the next release to add a directory.
26
+ */
27
+ export const STATE_ITEMS = [
28
+ // --- identity and setup: the whole point of the command -----------------------------
29
+ { path: "worker.env", group: "identity",
30
+ what: "the account link (worker key + API base)", cost: "you sign in again" },
31
+ { path: "client-expert.json", group: "identity",
32
+ what: "Local-mode setup progress and the chosen mode", cost: "setup starts over" },
33
+ { path: "instance", group: "identity",
34
+ what: "this machine's id", cost: "it gets a new one" },
35
+ { path: "prefs.json", group: "identity", what: "CLI preferences", cost: "defaults return" },
36
+ { path: "wizard-state.json", group: "identity",
37
+ what: "installer progress", cost: "the wizard starts over" },
38
+
39
+ // --- work you may actually want -----------------------------------------------------
40
+ { path: "sessions", group: "work",
41
+ what: "per-folder conversation history and choices", cost: "every folder forgets its chat" },
42
+ { path: "workspaces.json", group: "work",
43
+ what: "registered workspaces", cost: "folders must be re-registered" },
44
+ { path: "active-workspace.json", group: "work", what: "the active folder", cost: "-" },
45
+ { path: "servers.json", group: "work", what: "deploy targets", cost: "re-enter host/user" },
46
+ { path: "workspace-backups", group: "work",
47
+ what: "backups of files the agent edited", cost: "past edits can no longer be reverted" },
48
+
49
+ // --- expensive to rebuild -----------------------------------------------------------
50
+ { path: "rag", group: "expensive",
51
+ what: "local RAG indexes", cost: "re-embedding costs real GPU minutes" },
52
+ { path: "api", group: "expensive",
53
+ what: "downloaded API packages", cost: "re-downloaded on next setup" },
54
+
55
+ // --- scratch: nobody will miss these ------------------------------------------------
56
+ { path: "worker.log", group: "scratch", what: "worker log", cost: "-" },
57
+ { path: "model.log", group: "scratch", what: "model log", cost: "-" },
58
+ { path: "embed.log", group: "scratch", what: "embedder log", cost: "-" },
59
+ { path: "run-logs", group: "scratch", what: "command output logs", cost: "-" },
60
+ { path: "agent-turns", group: "scratch", what: "turn checkpoints", cost: "-" },
61
+ { path: "rag-work", group: "scratch", what: "RAG scratch space", cost: "-" },
62
+ { path: "web-agent-work", group: "scratch", what: "web agent scratch space", cost: "-" },
63
+ { path: "local-api.log", group: "scratch", what: "local API log", cost: "-" },
64
+ { path: "local-api.pid", group: "scratch", what: "local API pidfile", cost: "-" },
65
+ { path: "worker.pid", group: "scratch", what: "worker pidfile", cost: "-" },
66
+ { path: "model.pid", group: "scratch", what: "model pidfile", cost: "-" },
67
+ { path: "embed.pid", group: "scratch", what: "embedder pidfile", cost: "-" },
68
+ { path: "logs", group: "scratch", what: "logs", cost: "-" },
69
+ ];
70
+
71
+ /** Bytes on disk for a file or directory, or 0 when it does not exist. */
72
+ export async function sizeOf(p) {
73
+ try {
74
+ const s = await stat(p);
75
+ if (!s.isDirectory()) return s.size;
76
+ let total = 0;
77
+ for (const entry of await readdir(p, { withFileTypes: true })) {
78
+ total += await sizeOf(join(p, entry.name));
79
+ }
80
+ return total;
81
+ } catch {
82
+ return 0;
83
+ }
84
+ }
85
+
86
+ export const human = (n) =>
87
+ n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)}M`
88
+ : n >= 1024 ? `${Math.round(n / 1024)}K`
89
+ : `${n}B`;
90
+
91
+ /**
92
+ * What is actually present, with sizes — so the confirmation describes THIS machine rather
93
+ * than a generic list, and an empty group can be left out entirely.
94
+ */
95
+ export async function surveyState({ groups } = {}) {
96
+ const wanted = groups ?? ["identity", "work", "expensive", "scratch"];
97
+ const found = [];
98
+ for (const item of STATE_ITEMS) {
99
+ if (!wanted.includes(item.group)) continue;
100
+ const full = join(GONEXT_DIR, item.path);
101
+ const bytes = await sizeOf(full);
102
+ try {
103
+ await stat(full);
104
+ found.push({ ...item, full, bytes });
105
+ } catch {
106
+ /* not on this machine — nothing to say about it */
107
+ }
108
+ }
109
+ return found;
110
+ }
111
+
112
+ /** Remove what the survey found. Returns the paths actually removed. */
113
+ export async function clearState(items) {
114
+ const removed = [];
115
+ for (const item of items) {
116
+ try {
117
+ await rm(item.full, { recursive: true, force: true });
118
+ removed.push(item.path);
119
+ } catch {
120
+ /* a file we cannot remove is reported by its absence from this list */
121
+ }
122
+ }
123
+ return removed;
124
+ }
125
+
126
+ /* ------------------------------------------------ the local database ------------------- */
127
+
128
+ /**
129
+ * On a Client Expert machine the database is state too — and `gu clear` did not touch it.
130
+ *
131
+ * The consequence is not obvious and bit a real machine: clear removes worker.env and the
132
+ * setup notes, so the next `gu` runs setup again — but seeding only fills what is MISSING,
133
+ * and the old settings row is still there. The machine silently inherits configuration from a
134
+ * previous version (an agent URL pointing at a local Ollama that was never installed, a coder
135
+ * nobody chose), while every file on disk says it is a fresh install.
136
+ *
137
+ * "Starts from the beginning" has to mean it, or it is worse than not offering the command.
138
+ */
139
+ export function localDatabaseItem({ apiBase }) {
140
+ const local = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/|$)/i.test(String(apiBase ?? ""));
141
+ if (!local) return null;
142
+ return {
143
+ group: "work",
144
+ what: "the local MongoDB database (conversations, settings, worker keys)",
145
+ // Named plainly: this is the one thing here that a person might genuinely want to keep,
146
+ // and it is not recoverable.
147
+ cost: "every conversation on this machine is deleted",
148
+ };
149
+ }