@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.
- package/README.md +52 -0
- package/agent-model-command.mjs +259 -0
- package/agent-model-label.mjs +159 -0
- package/clear-state.mjs +149 -0
- package/client-expert-api.mjs +736 -0
- package/client-expert-run.mjs +892 -0
- package/client-expert-setup.mjs +616 -0
- package/coding-choice-tags.mjs +69 -0
- package/coding-key-prompt.mjs +229 -0
- package/coding-provider-setup.mjs +808 -0
- package/completed-flush.mjs +105 -0
- package/daemon-control.mjs +462 -0
- package/device-login.mjs +212 -0
- package/doctor-check.mjs +239 -0
- package/embed-model-command.mjs +157 -0
- package/first-run-steps.mjs +171 -0
- package/gonext_agent_chat.py +12299 -0
- package/gonext_mlx_embed.py +155 -0
- package/gonext_probe_agent.py +93 -0
- package/gonext_transcribe.py +130 -0
- package/gu-cli.mjs +4930 -0
- package/gu-repl.mjs +10326 -0
- package/job-pools.mjs +89 -0
- package/model-doctor.mjs +1494 -0
- package/node-version.mjs +40 -0
- package/ollama-setup.mjs +832 -0
- package/package.json +100 -0
- package/platform-tools.mjs +520 -0
- package/poll-errors.mjs +141 -0
- package/proxy-command.mjs +165 -0
- package/proxy-config.mjs +255 -0
- package/proxy-dispatcher.mjs +132 -0
- package/proxy-selftest.mjs +234 -0
- package/proxy-store.mjs +69 -0
- package/rag-job-config.mjs +59 -0
- package/rag-selftest.mjs +215 -0
- package/s3-setup.mjs +85 -0
- package/terminal-copy.mjs +248 -0
- package/terminal-hover.mjs +153 -0
- package/terminal-layout.mjs +2507 -0
- package/terminal-viewport.mjs +602 -0
- package/thinking_words.txt +1003 -0
- 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
|
+
}
|
package/clear-state.mjs
ADDED
|
@@ -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
|
+
}
|