@tiens.nguyen/gu-cli 1.0.721 → 1.0.723
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/add-server.mjs +213 -0
- package/agent-model-command.mjs +3 -1
- package/coding-provider-setup.mjs +35 -0
- package/gonext_agent_chat.py +15 -13
- package/gu-repl.mjs +749 -865
- package/ollama-setup.mjs +3 -0
- package/package.json +3 -2
- package/server-keys.mjs +97 -0
- package/settings-transfer.mjs +156 -13
- package/terminal-layout.mjs +40 -0
- package/ollama-host-snippets.mjs +0 -36
package/ollama-setup.mjs
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { isLikelyEmbedder } from "./agent-model-command.mjs";
|
|
22
22
|
import { EMBED_CHOICES } from "./embed-model-command.mjs";
|
|
23
|
+
import { isCancelWord } from "./coding-provider-setup.mjs";
|
|
23
24
|
|
|
24
25
|
/** Ollama's native port, used when someone types a bare host or an IP with no port. */
|
|
25
26
|
export const OLLAMA_DEFAULT_PORT = 11434;
|
|
@@ -190,6 +191,8 @@ export function describeNotReady(host, missing) {
|
|
|
190
191
|
export function normalizeOllamaUrl(raw) {
|
|
191
192
|
const text = String(raw ?? "").trim();
|
|
192
193
|
if (!text) return null;
|
|
194
|
+
// "exit", "quit", "cancel"… are commands, never hosts (they were once saved as http://exit).
|
|
195
|
+
if (isCancelWord(text)) return null;
|
|
193
196
|
// A path, a query, or whitespace inside means this is not a host we can reason about.
|
|
194
197
|
if (/\s/.test(text)) return null;
|
|
195
198
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gu-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.723",
|
|
4
4
|
"description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -73,7 +73,6 @@
|
|
|
73
73
|
"first-run-steps.mjs",
|
|
74
74
|
"proxy-config.mjs",
|
|
75
75
|
"agent-model-command.mjs",
|
|
76
|
-
"ollama-host-snippets.mjs",
|
|
77
76
|
"ollama-setup.mjs",
|
|
78
77
|
"s3-setup.mjs",
|
|
79
78
|
"doctor-check.mjs",
|
|
@@ -84,6 +83,8 @@
|
|
|
84
83
|
"proxy-dispatcher.mjs",
|
|
85
84
|
"model-doctor.mjs",
|
|
86
85
|
"coding-key-prompt.mjs",
|
|
86
|
+
"server-keys.mjs",
|
|
87
|
+
"add-server.mjs",
|
|
87
88
|
"powershell-alias.mjs",
|
|
88
89
|
"install-path.mjs",
|
|
89
90
|
"gonext_probe_agent.py",
|
package/server-keys.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A KEY BELONGS TO A SERVER URL, NOT TO A MODEL (task #245) — the terminal's decisions, pure.
|
|
3
|
+
*
|
|
4
|
+
* The rules the user set:
|
|
5
|
+
* · a server URL owns its keys (many, named) and its models (at most MAX_MODELS_PER_SERVER);
|
|
6
|
+
* · the first key chosen for a server becomes its DEFAULT for every later terminal / project;
|
|
7
|
+
* · picking another key in a folder applies to that folder only, PER ROLE (coding/agent/embed);
|
|
8
|
+
* · the switch picker loads the current server's keys only;
|
|
9
|
+
* · deleting the first key entered for a server warns — its model list was loaded with it.
|
|
10
|
+
*
|
|
11
|
+
* The API owns resolution (resolveKeyForRole in api/src/app.ts); this module only decides what
|
|
12
|
+
* the terminal shows and asks, so /model, /agent-model and /embed-model cannot drift apart.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The three roles, the folder-choice field each one's pick lives in, and how to say it. */
|
|
16
|
+
export const KEY_ROLES = {
|
|
17
|
+
coding: { field: "codingKeyId", label: "coding model" },
|
|
18
|
+
agent: { field: "agentKeyId", label: "agent model" },
|
|
19
|
+
embed: { field: "embedKeyId", label: "embedding model" },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function roleOf(role) {
|
|
23
|
+
return KEY_ROLES[role] ?? KEY_ROLES.coding;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The key a folder is actually on, as the API will resolve it — for the "(in use)" mark and the
|
|
28
|
+
* bar's tail. Mirrors resolveCodingApiKey: the folder's pick (one of THIS server's keys) → the
|
|
29
|
+
* server's default → its first key → any of its keys → a key tied to no server.
|
|
30
|
+
* `keys` is GET /coding-keys?backendId=… (this server's keys, then unbound ones).
|
|
31
|
+
*/
|
|
32
|
+
export function keyInUse(keys, backendId, folderKeyId = "") {
|
|
33
|
+
const list = Array.isArray(keys) ? keys : [];
|
|
34
|
+
const own = list.filter((k) => k && k.backendId === backendId);
|
|
35
|
+
const loose = list.filter((k) => k && !k.backendId);
|
|
36
|
+
return (
|
|
37
|
+
(folderKeyId && own.find((k) => k.id === folderKeyId)) ||
|
|
38
|
+
own.find((k) => k.default) ||
|
|
39
|
+
own.find((k) => k.first) ||
|
|
40
|
+
own[0] ||
|
|
41
|
+
loose[0] ||
|
|
42
|
+
null
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What a picker row says about a key besides its name and tail. Order: most specific first. */
|
|
47
|
+
export function keyTags(k, { folderKeyId = "", inUseId = "" } = {}) {
|
|
48
|
+
const tags = [];
|
|
49
|
+
if (k.id === folderKeyId) tags.push("this folder");
|
|
50
|
+
else if (k.id === inUseId) tags.push("in use");
|
|
51
|
+
if (k.default) tags.push("default");
|
|
52
|
+
if (k.first) tags.push("first key");
|
|
53
|
+
if (!k.backendId) tags.push("not tied to a server yet");
|
|
54
|
+
return tags;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* After a key is picked: WHERE does the choice apply? The first key chosen for a server already
|
|
59
|
+
* became its default (the API stamps it), so this is only asked when the pick differs from the
|
|
60
|
+
* server's default. Returns the rows to offer, in order; `scope` is what each one means.
|
|
61
|
+
*/
|
|
62
|
+
export function scopeChoices({ host = "the server", role = "coding" } = {}) {
|
|
63
|
+
return [
|
|
64
|
+
{ scope: "folder", label: `just this folder's ${roleOf(role).label}` },
|
|
65
|
+
{ scope: "default", label: `make it the default for ${host} — every folder that has not picked its own` },
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The warnings before a key is deleted, from DELETE /coding-keys/:id?dryRun=1. Irreversible, so
|
|
71
|
+
* everything that changes is said first. The FIRST key gets its own line, as asked: "if later
|
|
72
|
+
* user delete that key we need to warn the user that it was the first key that user input for
|
|
73
|
+
* that server url".
|
|
74
|
+
*/
|
|
75
|
+
export function deleteWarnings(preview, { host = "this server", isFolderPick = false, roleLabel = "", lastOfServer = false } = {}) {
|
|
76
|
+
const lines = [];
|
|
77
|
+
// No preview (an older API, or it could not be reached) still gets the warnings the terminal
|
|
78
|
+
// can work out itself — this folder's pick, the server's last key.
|
|
79
|
+
preview = preview && typeof preview === "object" ? preview : {};
|
|
80
|
+
if (preview.wasFirst) {
|
|
81
|
+
lines.push(`This was the FIRST key you entered for ${host} — its model list was loaded with it.`);
|
|
82
|
+
}
|
|
83
|
+
if (preview.wasDefault) {
|
|
84
|
+
lines.push(`It is ${host}'s default key — folders without their own pick fall back to another key of ${host}, or none.`);
|
|
85
|
+
}
|
|
86
|
+
if (isFolderPick) {
|
|
87
|
+
lines.push(`This folder uses it${roleLabel ? ` for the ${roleLabel}` : ""} — it will fall back to ${host}'s default.`);
|
|
88
|
+
}
|
|
89
|
+
if (lastOfServer) {
|
|
90
|
+
lines.push(`It is the only key stored for ${host} — every model there will have none until you add another.`);
|
|
91
|
+
}
|
|
92
|
+
const others = Number(preview.folders ?? 0) - (isFolderPick ? 1 : 0);
|
|
93
|
+
if (others > 0) {
|
|
94
|
+
lines.push(`${others} other folder${others === 1 ? "" : "s"} picked it${Array.isArray(preview.roles) && preview.roles.length ? ` (${preview.roles.join(", ")})` : ""}.`);
|
|
95
|
+
}
|
|
96
|
+
return lines;
|
|
97
|
+
}
|
package/settings-transfer.mjs
CHANGED
|
@@ -29,13 +29,100 @@
|
|
|
29
29
|
* your models should not also decide which one you are coding with.
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
import { basename, extname, isAbsolute, join, resolve } from "node:path";
|
|
33
|
+
import { ensureV1Root, isCancelWord, MAX_MODELS_PER_SERVER } from "./coding-provider-setup.mjs";
|
|
34
|
+
|
|
35
|
+
/** The file name used when none is given — in the default folder (the home directory). */
|
|
36
|
+
export const DEFAULT_SETTINGS_NAME = "gu-settings.json";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* What was typed → the file to use. The DEFAULT FOLDER is the home directory, so a bare NAME is
|
|
40
|
+
* all anyone has to type ("work" → ~/work.json); a real path still works as one.
|
|
41
|
+
* "" → ~/gu-settings.json
|
|
42
|
+
* "work" → ~/work.json (".json" added when there is no extension)
|
|
43
|
+
* "work.json" → ~/work.json
|
|
44
|
+
* "~/dir/x.json" → ~/dir/x.json
|
|
45
|
+
* "./x.json", "/abs" → that path exactly (relative ones from the current folder, as a shell would)
|
|
46
|
+
* Quotes around the whole thing are stripped — people paste paths with them. Returns "" for a
|
|
47
|
+
* command word ("exit", "cancel", …), which the caller treats as a cancel.
|
|
48
|
+
*/
|
|
49
|
+
export function settingsFilePath(typed, home) {
|
|
50
|
+
const t = String(typed ?? "").trim().replace(/^['"]|['"]$/g, "").trim();
|
|
51
|
+
if (!t) return join(home, DEFAULT_SETTINGS_NAME);
|
|
52
|
+
if (isCancelWord(t)) return "";
|
|
53
|
+
if (t === "~") return join(home, DEFAULT_SETTINGS_NAME);
|
|
54
|
+
if (t.startsWith("~/")) return withJson(join(home, t.slice(2)));
|
|
55
|
+
// An explicit path is taken exactly as typed — no ".json" guessed onto a name someone spelled out.
|
|
56
|
+
if (isAbsolute(t) || t.startsWith("./") || t.startsWith("../") || /[\\/]/.test(t)) return resolve(t);
|
|
57
|
+
return withJson(join(home, t));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const withJson = (p) => (extname(basename(p)) ? p : `${p}.json`);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The settings files in the default folder, newest first — what /load-settings offers when it is
|
|
64
|
+
* given no name. Only files that ARE gu settings files (the "gu": "settings" marker), so a
|
|
65
|
+
* package.json in the home directory is never offered. `entries` = [{ name, text, mtimeMs }].
|
|
66
|
+
*/
|
|
67
|
+
export function settingsFilesIn(entries) {
|
|
68
|
+
return (Array.isArray(entries) ? entries : [])
|
|
69
|
+
.filter((e) => e && /\.json$/i.test(e.name) && /"gu"\s*:\s*"settings"/.test(String(e.text ?? "")))
|
|
70
|
+
.sort((a, b) => (b.mtimeMs ?? 0) - (a.mtimeMs ?? 0))
|
|
71
|
+
.map((e) => e.name);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Bumped only when an older gu could not read a newer file correctly. Task #245 ADDED optional
|
|
76
|
+
* fields (embedModel, keys[].default) — an older gu ignores them and still loads the rest, so the
|
|
77
|
+
* version stays 1.
|
|
78
|
+
*/
|
|
33
79
|
export const FILE_VERSION = 1;
|
|
34
80
|
|
|
35
81
|
/** The kinds the API's own type allows (CodingBackendKind). */
|
|
36
82
|
const KINDS = ["", "ollama", "openai", "local"];
|
|
37
83
|
|
|
38
84
|
const isHttpUrl = (v) => typeof v === "string" && /^https?:\/\/\S+$/i.test(v.trim());
|
|
85
|
+
|
|
86
|
+
/** Two spellings of one server are one server — the rule the API and providerRowForUrl use. */
|
|
87
|
+
const sameServerUrl = (a, b) =>
|
|
88
|
+
Boolean(clean(a) && clean(b)) && ensureV1Root(clean(a)).toLowerCase() === ensureV1Root(clean(b)).toLowerCase();
|
|
89
|
+
|
|
90
|
+
/** A server's models, the default counted once: what "N models" means everywhere a person reads it. */
|
|
91
|
+
export function modelsOf(b) {
|
|
92
|
+
return [...new Set([clean(b?.defaultModel), ...(Array.isArray(b?.models) ? b.models.map(clean) : [])].filter(Boolean))];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The line /save-settings prints. REPORTED: "4 providers, 0 models" for four providers that each
|
|
97
|
+
* had a model — the count read only the EXTRA models and never the default one.
|
|
98
|
+
*/
|
|
99
|
+
export function transferSummary(doc) {
|
|
100
|
+
const n = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
101
|
+
const backends = Array.isArray(doc?.codingBackends) ? doc.codingBackends : [];
|
|
102
|
+
const models = backends.reduce((sum, b) => sum + modelsOf(b).length, 0);
|
|
103
|
+
const parts = [n(backends.length, "provider"), n(models, "model"), n(Array.isArray(doc?.keys) ? doc.keys.length : 0, "key name")];
|
|
104
|
+
if (doc?.agentModel?.name || doc?.agentModel?.url) parts.push("the agent model");
|
|
105
|
+
if (doc?.embedModel?.name || doc?.embedModel?.url) parts.push("the embedding model");
|
|
106
|
+
return `${parts.join(", ")} — no secrets.`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A server's models within MAX_MODELS_PER_SERVER (task #245) — the API refuses a row that grows
|
|
111
|
+
* past it, and a file saved before the cap may hold more. The default stays; extras are kept in
|
|
112
|
+
* order up to the cap, or up to what this account's row ALREADY holds (a row stored before the cap
|
|
113
|
+
* keeps what it has). Returns { defaultModel, models, dropped }.
|
|
114
|
+
*/
|
|
115
|
+
export function capServerModels(b, keepAtLeast = 0) {
|
|
116
|
+
const all = modelsOf(b);
|
|
117
|
+
const defaultModel = clean(b?.defaultModel) || "";
|
|
118
|
+
const limit = Math.max(MAX_MODELS_PER_SERVER, keepAtLeast);
|
|
119
|
+
const kept = all.slice(0, limit);
|
|
120
|
+
return {
|
|
121
|
+
defaultModel,
|
|
122
|
+
models: kept.filter((m) => m !== defaultModel),
|
|
123
|
+
dropped: all.slice(limit),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
39
126
|
const isStr = (v) => typeof v === "string";
|
|
40
127
|
const clean = (v) => (typeof v === "string" ? v.trim() : "");
|
|
41
128
|
|
|
@@ -64,6 +151,9 @@ export function buildTransfer({ settings = {}, keys = [], now = new Date(), host
|
|
|
64
151
|
name: clean(k.name),
|
|
65
152
|
tail: clean(k.tail),
|
|
66
153
|
...(urlForRowId.get(k.backendId) ? { backendUrl: urlForRowId.get(k.backendId) } : {}),
|
|
154
|
+
// Task #245: which key is its server's DEFAULT, so loading makes the same one the default
|
|
155
|
+
// (otherwise it would be whichever key happened to be added first).
|
|
156
|
+
...(k.default === true ? { default: true } : {}),
|
|
67
157
|
}));
|
|
68
158
|
return {
|
|
69
159
|
gu: "settings",
|
|
@@ -74,6 +164,8 @@ export function buildTransfer({ settings = {}, keys = [], now = new Date(), host
|
|
|
74
164
|
...(host ? { savedOn: host } : {}),
|
|
75
165
|
codingBackends: backends,
|
|
76
166
|
agentModel: { url: clean(settings.agentModelUrl), name: clean(settings.agentModelName) },
|
|
167
|
+
// Task #245: the third role. Its KEY travels like the others — by name, bound to its server.
|
|
168
|
+
embedModel: { url: clean(settings.ragEmbedUrl), name: clean(settings.ragEmbedModel) },
|
|
77
169
|
keys: keyList,
|
|
78
170
|
};
|
|
79
171
|
}
|
|
@@ -150,6 +242,16 @@ export function validateTransfer(text) {
|
|
|
150
242
|
if (am?.name !== undefined && !isStr(am.name)) {
|
|
151
243
|
return { ok: false, problem: "agentModel.name must be text." };
|
|
152
244
|
}
|
|
245
|
+
const em = doc.embedModel;
|
|
246
|
+
if (em !== undefined && (!em || typeof em !== "object" || Array.isArray(em))) {
|
|
247
|
+
return { ok: false, problem: "embedModel must be an object with a url and a name." };
|
|
248
|
+
}
|
|
249
|
+
if (em?.url !== undefined && em.url !== "" && !isHttpUrl(em.url)) {
|
|
250
|
+
return { ok: false, problem: `embedModel.url is not http(s): ${JSON.stringify(em.url)}.` };
|
|
251
|
+
}
|
|
252
|
+
if (em?.name !== undefined && !isStr(em.name)) {
|
|
253
|
+
return { ok: false, problem: "embedModel.name must be text." };
|
|
254
|
+
}
|
|
153
255
|
if (doc.keys !== undefined && !Array.isArray(doc.keys)) {
|
|
154
256
|
return { ok: false, problem: "keys must be a list." };
|
|
155
257
|
}
|
|
@@ -159,6 +261,9 @@ export function validateTransfer(text) {
|
|
|
159
261
|
if (!k || typeof k !== "object" || Array.isArray(k)) return { ok: false, problem: `${at} is not an object.` };
|
|
160
262
|
if (!clean(k.name)) return { ok: false, problem: `${at} has no name — a key is identified by its name.` };
|
|
161
263
|
if (k.key !== undefined && !isStr(k.key)) return { ok: false, problem: `${at}.key must be text.` };
|
|
264
|
+
if (k.default !== undefined && typeof k.default !== "boolean") {
|
|
265
|
+
return { ok: false, problem: `${at}.default must be true or false.` };
|
|
266
|
+
}
|
|
162
267
|
if (k.backendUrl !== undefined && k.backendUrl !== "" && !isHttpUrl(k.backendUrl)) {
|
|
163
268
|
return { ok: false, problem: `${at}.backendUrl is not http(s): ${JSON.stringify(k.backendUrl)}.` };
|
|
164
269
|
}
|
|
@@ -166,13 +271,21 @@ export function validateTransfer(text) {
|
|
|
166
271
|
name: clean(k.name),
|
|
167
272
|
tail: clean(k.tail),
|
|
168
273
|
...(clean(k.backendUrl) ? { backendUrl: clean(k.backendUrl) } : {}),
|
|
274
|
+
...(k.default === true ? { default: true } : {}),
|
|
169
275
|
// Present only when a person put it there by hand; gu never writes one.
|
|
170
276
|
...(clean(k.key) ? { key: clean(k.key) } : {}),
|
|
171
277
|
});
|
|
172
278
|
}
|
|
173
279
|
return {
|
|
174
280
|
ok: true,
|
|
175
|
-
value: {
|
|
281
|
+
value: {
|
|
282
|
+
backends,
|
|
283
|
+
agentModel: { url: clean(am?.url), name: clean(am?.name) },
|
|
284
|
+
embedModel: { url: clean(em?.url), name: clean(em?.name) },
|
|
285
|
+
keys,
|
|
286
|
+
savedAt: clean(doc.savedAt),
|
|
287
|
+
savedOn: clean(doc.savedOn),
|
|
288
|
+
},
|
|
176
289
|
// Loading a file someone hand-edited to carry live credentials is allowed, but it is not
|
|
177
290
|
// allowed to be quiet: that file is now a secret on disk.
|
|
178
291
|
carriesSecrets: keys.some((k) => k.key),
|
|
@@ -190,8 +303,19 @@ export function validateTransfer(text) {
|
|
|
190
303
|
export function planLoad(file, current = {}) {
|
|
191
304
|
const currentRows = Array.isArray(current.agentCodingBackends) ? current.agentCodingBackends : [];
|
|
192
305
|
const currentKeys = Array.isArray(current.keys) ? current.keys : [];
|
|
193
|
-
|
|
194
|
-
|
|
306
|
+
// A KEY BELONGS TO ITS SERVER (task #245): "work" on api.openai.com says nothing about a "work"
|
|
307
|
+
// key for ollama.com. So a same-named key counts as present when it is ON THE SAME SERVER, or
|
|
308
|
+
// tied to NO server (a pre-#245 key — the resolver still falls back to it, so asking for it
|
|
309
|
+
// again would be asking for a key the account already uses). The same name on ANOTHER server
|
|
310
|
+
// is a different key.
|
|
311
|
+
const urlOfRow = (id) => clean(currentRows.find((r) => r?.id === id)?.url);
|
|
312
|
+
const present = (k) =>
|
|
313
|
+
currentKeys.some((c) => {
|
|
314
|
+
if (clean(c.name).toLowerCase() !== k.name.toLowerCase()) return false;
|
|
315
|
+
if (!c.backendId) return true;
|
|
316
|
+
return Boolean(k.backendUrl) && sameServerUrl(urlOfRow(c.backendId), k.backendUrl);
|
|
317
|
+
});
|
|
318
|
+
const missingKeys = file.keys.filter((k) => !k.key && !present(k));
|
|
195
319
|
const lines = [];
|
|
196
320
|
lines.push(
|
|
197
321
|
file.backends.length
|
|
@@ -199,12 +323,25 @@ export function planLoad(file, current = {}) {
|
|
|
199
323
|
: "no coding providers in the file — the ones on this account stay as they are.",
|
|
200
324
|
);
|
|
201
325
|
for (const b of file.backends) {
|
|
202
|
-
const
|
|
326
|
+
const n = modelsOf(b).length;
|
|
327
|
+
const models = n ? `${n} model${n === 1 ? "" : "s"}` : "no models";
|
|
203
328
|
lines.push(` ${b.kind || "?"} ${b.url || "(no url)"} ${models}${b.defaultModel ? `, default ${b.defaultModel}` : ""}${b.enabled ? "" : " (disabled)"}`);
|
|
329
|
+
const mine = currentRows.find((r) => sameServerUrl(r?.url, b.url));
|
|
330
|
+
const capped = capServerModels(b, mine ? modelsOf(mine).length : 0);
|
|
331
|
+
if (capped.dropped.length) {
|
|
332
|
+
lines.push(` a server offers at most ${MAX_MODELS_PER_SERVER} models — not loading ${capped.dropped.join(", ")}`);
|
|
333
|
+
}
|
|
204
334
|
}
|
|
205
335
|
if (file.agentModel.url || file.agentModel.name) {
|
|
206
336
|
lines.push(`agent model: ${file.agentModel.name || "(server default)"}${file.agentModel.url ? ` at ${file.agentModel.url}` : ""}`);
|
|
207
337
|
}
|
|
338
|
+
if (file.embedModel?.url || file.embedModel?.name) {
|
|
339
|
+
const same = sameServerUrl(file.embedModel.url, current.ragEmbedUrl) && file.embedModel.name === clean(current.ragEmbedModel);
|
|
340
|
+
lines.push(`embedding model: ${file.embedModel.name || "(unchanged)"}${file.embedModel.url ? ` at ${file.embedModel.url}` : ""}${same ? " (already set)" : ""}`);
|
|
341
|
+
if (!same && file.embedModel.name && file.embedModel.name !== clean(current.ragEmbedModel)) {
|
|
342
|
+
lines.push(" a different embedder: every knowledge base rebuilds on its next search.");
|
|
343
|
+
}
|
|
344
|
+
}
|
|
208
345
|
if (missingKeys.length) {
|
|
209
346
|
lines.push(`${missingKeys.length} API key${missingKeys.length === 1 ? "" : "s"} this account does not have — gu will ask for ${missingKeys.length === 1 ? "it" : "them"} after:`);
|
|
210
347
|
for (const k of missingKeys) lines.push(` ${k.name}${k.tail ? ` (…${k.tail})` : ""}${k.backendUrl ? ` for ${k.backendUrl}` : ""}`);
|
|
@@ -230,24 +367,30 @@ export function planLoad(file, current = {}) {
|
|
|
230
367
|
export function toSettingsPatch(file, current = {}) {
|
|
231
368
|
const patch = {};
|
|
232
369
|
const existing = Array.isArray(current.agentCodingBackends) ? current.agentCodingBackends : [];
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
370
|
+
// Matched as ONE SERVER however it is spelled (task #245) — "https://ollama.com" and
|
|
371
|
+
// "https://ollama.com/v1" are the same row, and a raw compare would mint a twin and strand the
|
|
372
|
+
// keys bound to the original.
|
|
373
|
+
const mineFor = (kind, url) => existing.find((r) => r?.id && r.kind === kind && sameServerUrl(r.url, url));
|
|
237
374
|
if (file.backends.length) {
|
|
238
375
|
patch.agentCodingBackends = file.backends.map((b) => {
|
|
239
|
-
const
|
|
376
|
+
const mine = mineFor(b.kind, b.url);
|
|
377
|
+
const capped = capServerModels(b, mine ? modelsOf(mine).length : 0);
|
|
240
378
|
return {
|
|
241
|
-
...(id ? { id } : {}),
|
|
379
|
+
...(mine?.id ? { id: mine.id } : {}),
|
|
242
380
|
kind: b.kind,
|
|
243
381
|
url: b.url,
|
|
244
|
-
defaultModel:
|
|
245
|
-
models:
|
|
382
|
+
defaultModel: capped.defaultModel,
|
|
383
|
+
models: capped.models,
|
|
246
384
|
enabled: b.enabled,
|
|
247
385
|
};
|
|
248
386
|
});
|
|
249
387
|
}
|
|
250
388
|
if (file.agentModel.url) patch.agentModelUrl = file.agentModel.url;
|
|
251
389
|
if (file.agentModel.name) patch.agentModelName = file.agentModel.name;
|
|
390
|
+
if (file.embedModel?.url) patch.ragEmbedUrl = file.embedModel.url;
|
|
391
|
+
if (file.embedModel?.name) patch.ragEmbedModel = file.embedModel.name;
|
|
392
|
+
// The embedder's key is resolved through its server's keys (task #245); the pre-#245
|
|
393
|
+
// account-wide pick would only shadow them.
|
|
394
|
+
if (file.embedModel?.url || file.embedModel?.name) patch.ragEmbedKeyId = "";
|
|
252
395
|
return patch;
|
|
253
396
|
}
|
package/terminal-layout.mjs
CHANGED
|
@@ -1715,6 +1715,46 @@ export function describeInstallFailure({ status = 0, stderr = "", error = null }
|
|
|
1715
1715
|
return `npm exited ${status}${why ? ` — ${_clipReason(why)}` : ""}`;
|
|
1716
1716
|
}
|
|
1717
1717
|
|
|
1718
|
+
/**
|
|
1719
|
+
* The command the in-app update RUNS — one string, run through a shell.
|
|
1720
|
+
*
|
|
1721
|
+
* ONE STRING, NOT AN ARGS ARRAY. A shell is needed (Windows finds npm as npm.cmd only through
|
|
1722
|
+
* one), and Node 24 warns DEP0190 whenever args are passed WITH `shell: true` — they are joined,
|
|
1723
|
+
* not escaped — and that warning landed in the middle of the transcript on every update.
|
|
1724
|
+
*
|
|
1725
|
+
* --prefer-online: npm REVALIDATES its cached package document instead of trusting it. The update
|
|
1726
|
+
* check reads registry.npmjs.org/<pkg>/latest, which knows a new version the moment it is
|
|
1727
|
+
* published; `npm install` reads the full document from npm's own cache and CDN, which catch up
|
|
1728
|
+
* minutes later. Without this, a version announced seconds after publishing failed with ETARGET.
|
|
1729
|
+
*/
|
|
1730
|
+
export const CLI_UPDATE_SHELL_COMMAND = `${CLI_INSTALL_COMMAND} --prefer-online`;
|
|
1731
|
+
|
|
1732
|
+
/**
|
|
1733
|
+
* Did the install fail only because npm has not caught up with a JUST-published version?
|
|
1734
|
+
* (ETARGET / notarget — "No matching version found".) Worth one retry after a short wait, and
|
|
1735
|
+
* worth saying plainly rather than as a raw npm error: nothing is wrong with the release.
|
|
1736
|
+
*/
|
|
1737
|
+
export function isFreshPublishMiss({ status = 0, stderr = "" } = {}) {
|
|
1738
|
+
if (status === 0) return false;
|
|
1739
|
+
return /\b(ETARGET|notarget)\b|No matching version found/i.test(String(stderr ?? ""));
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
/** What to say when npm still has not caught up after the retry. */
|
|
1743
|
+
export function freshPublishNote(version = "") {
|
|
1744
|
+
return `gu ${version || "the new version"} was published moments ago and npm's mirrors are still catching up — ` +
|
|
1745
|
+
"try the update again in a few minutes.";
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
/**
|
|
1749
|
+
* The local modules a source text imports LAZILY (a dynamic import of "./name.mjs"), minus the CLI entry
|
|
1750
|
+
* (importing gu-cli.mjs RUNS the CLI). gu-repl preloads these at startup so an in-app update that
|
|
1751
|
+
* replaces the package on disk can never be read into the still-running old process.
|
|
1752
|
+
*/
|
|
1753
|
+
export function lazyModuleNames(text) {
|
|
1754
|
+
return [...new Set([...String(text ?? "").matchAll(/import\("\.\/([\w.-]+\.mjs)"\)/g)].map((m) => m[1]))]
|
|
1755
|
+
.filter((m) => m !== "gu-cli.mjs");
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1718
1758
|
/** One line, bounded — a reason is for reading, and npm's can carry a whole stack frame. */
|
|
1719
1759
|
function _clipReason(s, max = 160) {
|
|
1720
1760
|
const one = String(s).replace(/\s+/g, " ").trim();
|
package/ollama-host-snippets.mjs
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* "Is that an Ollama SERVER?" — the input check behind /embed-model's server prompt (task #169).
|
|
3
|
-
*
|
|
4
|
-
* WHY A SEPARATE FILE OF ONE FUNCTION. The embed picker asks for a URL and its retry line is
|
|
5
|
-
* `"<typed>" is <why> — try host:port, e.g. 127.0.0.1:11434`, so the reason HAS to be a phrase
|
|
6
|
-
* that finishes that sentence. Keeping the check next to its words is what stops a future edit
|
|
7
|
-
* from returning a full sentence and printing "is The URL must not...".
|
|
8
|
-
*
|
|
9
|
-
* WHAT "OLLAMA-SHAPED" MEANS HERE. The python worker takes the saved root and appends
|
|
10
|
-
* /v1/embeddings, then /api/embed, then /api/embeddings itself (gonext_agent_chat._embed).
|
|
11
|
-
* Anything already carrying a path (/v1, /api/tags, someone pasting a dashboard link) would be
|
|
12
|
-
* saved as-is and every suffix would 404 — so a path is a reason to ask AGAIN, not to strip
|
|
13
|
-
* silently. Stripping hides the mistake; the retry teaches the shape.
|
|
14
|
-
*
|
|
15
|
-
* The vocabulary mirrors agent-model-command.ollamaAgentUrl ("not a URL", "not http(s)",
|
|
16
|
-
* "no host") so the agent and embed prompts read the same when they fail the same way.
|
|
17
|
-
*
|
|
18
|
-
* Returns { ok: true } or { ok: false, why }.
|
|
19
|
-
*/
|
|
20
|
-
export function checkOllamaHostInput(url) {
|
|
21
|
-
const v = String(url ?? "").trim();
|
|
22
|
-
if (!v) return { ok: false, why: "empty" };
|
|
23
|
-
let u;
|
|
24
|
-
try {
|
|
25
|
-
u = new URL(v);
|
|
26
|
-
} catch {
|
|
27
|
-
return { ok: false, why: "not a URL" };
|
|
28
|
-
}
|
|
29
|
-
if (u.protocol !== "http:" && u.protocol !== "https:") return { ok: false, why: "not http(s)" };
|
|
30
|
-
if (!u.hostname) return { ok: false, why: "no host" };
|
|
31
|
-
if (u.username || u.password) return { ok: false, why: "carrying credentials — keys go in the key prompt" };
|
|
32
|
-
const path = u.pathname.replace(/\/+$/, "");
|
|
33
|
-
if (path) return { ok: false, why: `carrying "${path}" — I need just the server root` };
|
|
34
|
-
if (u.search || u.hash) return { ok: false, why: "carrying a query — I need just the server root" };
|
|
35
|
-
return { ok: true };
|
|
36
|
-
}
|