@tiens.nguyen/gu-cli 1.0.722 → 1.0.724

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 CHANGED
@@ -14,7 +14,10 @@
14
14
  * server kind ("dialect": an Ollama's /api/tags vs an OpenAI-compatible /v1/models) lives in
15
15
  * serverTarget() and listRequest().
16
16
  */
17
- import { defaultProviderUrl, ensureV1Root, hostLocality, normalizeProviderUrl, parseProviderModels, MAX_MODELS_PER_SERVER } from "./coding-provider-setup.mjs";
17
+ import { defaultProviderUrl, ensureV1Root, hostLocality, isCancelWord, normalizeProviderUrl, parseProviderModels, MAX_MODELS_PER_SERVER } from "./coding-provider-setup.mjs";
18
+
19
+ // Re-exported: the add-server screen's own name for the rule every URL prompt shares.
20
+ export { isCancelWord };
18
21
  import { agentModelsFromProvider, isLikelyEmbedder, ollamaAgentDefault, ollamaAgentUrl, parseOllamaTags } from "./agent-model-command.mjs";
19
22
 
20
23
  /**
@@ -76,6 +79,9 @@ export function serverRole(role) {
76
79
  export function serverTarget(dialect, typed) {
77
80
  const raw = String(typed ?? "").trim();
78
81
  if (!raw) return { ok: false, why: "empty" };
82
+ // A command word is never a server. Reported: /embed-model showed "on exit" — "exit" typed at
83
+ // the URL prompt had been saved as http://exit, a perfectly valid-looking single-label host.
84
+ if (isCancelWord(raw)) return { ok: false, why: "a command, not a server" };
79
85
  if (/^[a-z][a-z0-9+.-]*:\/\/[^/]*@/i.test(raw)) {
80
86
  return { ok: false, why: "carrying credentials — keys go in the key prompt" };
81
87
  }
@@ -20,6 +20,7 @@
20
20
  import { createRequire } from "node:module";
21
21
  import { createHash, randomBytes, randomUUID } from "node:crypto";
22
22
  import { spawnSurviving, pidOnPort } from "./platform-tools.mjs";
23
+ import { DEFAULT_API_BASE as HOSTED_API_BASE } from "./device-login.mjs";
23
24
  import { spawn } from "node:child_process";
24
25
  import { writeFile, mkdir, readFile, access } from "node:fs/promises";
25
26
  import { homedir } from "node:os";
@@ -64,6 +65,8 @@ export function apiEnv({
64
65
  mongoUri = LOCAL_MONGO_URI,
65
66
  dbName = LOCAL_DB_NAME,
66
67
  base = {},
68
+ hostedBase = hostedApiBase(),
69
+ home = homedir(),
67
70
  } = {}) {
68
71
  return {
69
72
  ...base,
@@ -71,9 +74,20 @@ export function apiEnv({
71
74
  DATA_BACKEND: "mongo",
72
75
  MONGODB_URI: mongoUri,
73
76
  MONGODB_DB: dbName,
77
+ // THE AGENT'S PROMPT SCAFFOLD IS NOT ON THIS MACHINE (task #246). The local API forwards the
78
+ // worker's request to the hosted API, signed with this machine's gu CLI token, and keeps the
79
+ // answer in memory only — so private mode needs internet for a turn, and says so when it has
80
+ // none. No local copy exists to fall back to, by design.
81
+ PROMPT_SCAFFOLD_UPSTREAM: hostedBase,
82
+ GU_CLI_TOKEN_FILE: join(home, ".gonext", "cli-token.json"),
74
83
  };
75
84
  }
76
85
 
86
+ /** The hosted gu API — where a Client Expert machine's token is renewed and its scaffold fetched. */
87
+ export function hostedApiBase(env = process.env) {
88
+ return String(env.GU_TOKEN_API_BASE ?? "").trim() || HOSTED_API_BASE;
89
+ }
90
+
77
91
  /**
78
92
  * Is this health body a usable API?
79
93
  *
@@ -84,9 +84,19 @@ export function hostLocality(hostname) {
84
84
  * addresses" wholesale is meaningless — but 169.254.169.254 and metadata.google.internal are
85
85
  * never a model server, and they are what an SSRF probe asks for first.
86
86
  */
87
+ /**
88
+ * Words that mean "get me out of here" at a prompt, not a hostname. Every URL prompt refuses them
89
+ * as a server. Reported: /embed-model showed "on exit" — "exit" typed at a URL prompt had been
90
+ * saved as http://exit, a perfectly valid-looking single-label host.
91
+ */
92
+ export function isCancelWord(typed) {
93
+ return /^(no|n|skip|-|exit|quit|q|cancel|back|esc)$/i.test(String(typed ?? "").trim());
94
+ }
95
+
87
96
  export function normalizeProviderUrl(raw) {
88
97
  const text = String(raw ?? "").trim();
89
98
  if (!text) return { ok: false, why: "empty" };
99
+ if (isCancelWord(text)) return { ok: false, why: "command" };
90
100
  if (/\s/.test(text)) return { ok: false, why: "spaces" };
91
101
 
92
102
  const hadScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(text);
@@ -159,6 +169,8 @@ export function describeUrlProblem(why, typed = "") {
159
169
  return ["type the provider's base URL, e.g. https://api.openai.com/v1"];
160
170
  case "spaces":
161
171
  return ["that has a space in it — a URL cannot"];
172
+ case "command":
173
+ return ["that is a command, not a server — press Esc to cancel, or type the server's URL"];
162
174
  case "scheme":
163
175
  return ["only http:// and https:// addresses work here"];
164
176
  case "credentials":
@@ -1308,7 +1308,7 @@ def _render_numbered_tool_list(tools) -> str:
1308
1308
  when RAG or a workspace is enabled/disabled) and the numbering is always exactly
1309
1309
  correct, unlike the old manually-incremented counter it replaces. Kept intentionally
1310
1310
  terse — first docstring line only, bare arg names — since the system prompt's own
1311
- `tool.to_code_prompt()` rendering (see _COMPACT_CODE_SYSTEM_PROMPT) already shows the
1311
+ `tool.to_code_prompt()` rendering (in the API-served prompt scaffold) already shows the
1312
1312
  full typed signature on every model call; this is just a quick-reference index."""
1313
1313
  lines = []
1314
1314
  for i, t in enumerate(tools, 1):
@@ -1722,81 +1722,73 @@ def _looks_like_scratchpad(stripped: str) -> bool:
1722
1722
  return len(_DELIBERATION_RE.findall(stripped)) >= 3
1723
1723
 
1724
1724
 
1725
- # Compact replacement for smolagents' default CodeAgent system prompt. The stock template
1726
- # is ~9.9k chars, dominated by ~6k chars of GENERIC few-shot examples (image captions,
1727
- # Wikipedia, etc.) that are irrelevant to our HTTP/file tools and cost real prompt-eval
1728
- # time every step on a slow coding model. This keeps everything FUNCTIONALLY required —
1729
- # the Thought→Code→Observation loop, the code-blob format + closing tag, the {{tools}}
1730
- # rendering (so exact signatures survive), authorized imports, custom_instructions — plus
1731
- # ONE short worked example, and trims the rest. Jinja vars must match the stock template.
1732
- _COMPACT_CODE_SYSTEM_PROMPT = (
1733
- "You are an expert assistant who solves the task by writing Python code that calls "
1734
- "tools. You work in a loop of Thought → Code → Observation.\n\n"
1735
- "At EACH step:\n"
1736
- "- Write one 'Thought:' line: what you'll do and which tool.\n"
1737
- "- Then a code block that OPENS with {{code_block_opening_tag}} and CLOSES with "
1738
- "{{code_block_closing_tag}}, containing simple Python that calls the tools you need "
1739
- "and print()s what you want to see.\n"
1740
- "- BATCH INDEPENDENT CALLS INTO ONE BLOCK. If you already know the arguments for "
1741
- "several lookups, do them in ONE step and print each under a header — up to about 8 at "
1742
- "a time, then continue in the next step. Split into separate steps only when an "
1743
- "argument DEPENDS on a previous Observation.\n"
1744
- "- Batch LOOKUPS, not changes. If a line raises, the lines after it never run — so "
1745
- "keep edits, file writes and commands to one per block.\n"
1746
- "- You then receive the 'Observation:'. Use it to decide the next step. When you have "
1747
- "the answer, call final_answer(answer) inside a code block.\n\n"
1748
- "Example — arguments already known, so ONE step does all of it:\n"
1749
- "Thought: I need these three files before I can change anything.\n"
1750
- "{{code_block_opening_tag}}\n"
1751
- 'for p in ["src/main.js", "src/api.js", "test/main.test.js"]:\n'
1752
- ' print("=====", p)\n'
1753
- " print(read_file_lines(path=p))\n"
1754
- "{{code_block_closing_tag}}\n"
1755
- "Observation: \"===== src/main.js … ===== src/api.js … \"\n\n"
1756
- "Example — the next call DEPENDS on this result, so it is a separate step:\n"
1757
- "Thought: I'll read the page the user gave.\n"
1758
- "{{code_block_opening_tag}}\n"
1759
- 'text = fetch_url("https://example.com")\n'
1760
- "print(text)\n"
1761
- "{{code_block_closing_tag}}\n"
1762
- "Observation: \"Example Domain … \"\n"
1763
- "Thought: I have the content, so I'll answer.\n"
1764
- "{{code_block_opening_tag}}\n"
1765
- 'final_answer("The page is Example Domain, a placeholder site.")\n'
1766
- "{{code_block_closing_tag}}\n\n"
1767
- "You have access to these tools — call them as plain Python functions with the exact "
1768
- "signatures shown:\n"
1769
- "{{code_block_opening_tag}}\n"
1770
- "{%- for tool in tools.values() %}\n"
1771
- "{{ tool.to_code_prompt() }}\n"
1772
- "{% endfor %}\n"
1773
- "{{code_block_closing_tag}}\n"
1774
- "{%- if managed_agents and managed_agents.values() | list %}\n"
1775
- "You can also delegate to team members by calling them like a tool with a 'task' "
1776
- "string argument:\n"
1777
- "{{code_block_opening_tag}}\n"
1778
- "{%- for agent in managed_agents.values() %}\n"
1779
- "def {{ agent.name }}(task: str) -> str:\n"
1780
- ' """{{ agent.description }}"""\n'
1781
- "{% endfor %}\n"
1782
- "{{code_block_closing_tag}}\n"
1783
- "{%- endif %}\n\n"
1784
- "Rules:\n"
1785
- "1. ALWAYS write a 'Thought:' line, then a code block opening with "
1786
- "{{code_block_opening_tag}} and closing with {{code_block_closing_tag}} — or you fail.\n"
1787
- "2. Pass tool arguments directly: calculate(expression=\"2+2\"), NOT as a dict.\n"
1788
- "3. Use only variables you have defined; state persists between steps.\n"
1789
- "4. Never re-run a tool call with the exact same parameters.\n"
1790
- "5. Don't name a variable after a tool (e.g. 'final_answer').\n"
1791
- "6. Imports are allowed ONLY from: {{authorized_imports}}\n"
1792
- "7. Don't give up — you are in charge of solving the task.\n"
1793
- "{%- if custom_instructions %}\n"
1794
- "{{custom_instructions}}\n"
1795
- "{%- endif %}\n\n"
1796
- "Now Begin!"
1725
+ # THE CODE-AGENT SCAFFOLD IS NOT IN THIS FILE (task #246). The fixed system prompt — the
1726
+ # Thought → Code → Observation rules that replace smolagents' stock ~9.9k-char template — is served
1727
+ # by the API (GET /api/worker/prompt-scaffold) and never shipped in the gu package. The worker
1728
+ # fetches it once per job; if it cannot, the turn stops with the reason. There is deliberately NO
1729
+ # local copy to fall back to: the point is that the text is not on the user's machine.
1730
+ #
1731
+ # What a scaffold must keep, checked again here after the API checked it: without the code-block
1732
+ # tags the model's code is never parsed, without the tool loop no signature is shown, without
1733
+ # custom_instructions the per-turn hint is dropped — each fails silently, every turn.
1734
+ _SCAFFOLD_REQUIRED = (
1735
+ "{{code_block_opening_tag}}",
1736
+ "{{code_block_closing_tag}}",
1737
+ "tool.to_code_prompt()",
1738
+ "{{authorized_imports}}",
1739
+ "custom_instructions",
1797
1740
  )
1798
1741
 
1799
1742
 
1743
+ # The version of the scaffold this job is running — for the log and the prompt record, never the text.
1744
+ _PROMPT_SCAFFOLD_VERSION = {"v": None}
1745
+
1746
+
1747
+ def _fetch_prompt_scaffold(api_base: str, worker_key: str, name: str = "code-agent"):
1748
+ """The agent's prompt scaffold from the API → (template, version).
1749
+
1750
+ Raises _AgentConfigError with a message the USER reads (it becomes a TURN_FAILED line) when
1751
+ the API cannot be asked, refuses, or sends something unusable. Never returns a default. The
1752
+ message never contains the template itself."""
1753
+ base = (api_base or "").rstrip("/")
1754
+ if not base or not worker_key:
1755
+ raise _AgentConfigError(
1756
+ "The agent's prompt could not be loaded: this worker has no API address or key.")
1757
+ from urllib.parse import urlencode
1758
+ req = urllib.request.Request(
1759
+ f"{base}/api/worker/prompt-scaffold?{urlencode({'name': name})}",
1760
+ headers={"X-Worker-Key": worker_key}, method="GET")
1761
+ try:
1762
+ with urllib.request.urlopen(req, timeout=15, context=_ssl_context()) as resp:
1763
+ data = json.loads(resp.read().decode("utf-8", "replace") or "{}")
1764
+ except urllib.error.HTTPError as he:
1765
+ detail = ""
1766
+ try:
1767
+ detail = str(json.loads(he.read().decode("utf-8", "replace") or "{}").get("error") or "")
1768
+ except Exception: # noqa: BLE001
1769
+ pass
1770
+ if he.code == 404 and not detail:
1771
+ detail = ("this API is older than the prompt service — update it "
1772
+ "(`gu api upgrade` on a private-mode machine)")
1773
+ raise _AgentConfigError(
1774
+ f"The agent's prompt could not be loaded (HTTP {he.code})"
1775
+ + (f": {detail}" if detail else ".")) from None
1776
+ except Exception as e: # noqa: BLE001
1777
+ raise _AgentConfigError(
1778
+ f"The agent's prompt could not be loaded — the API at {base} did not answer "
1779
+ f"({type(e).__name__}). Check that it is running and reachable.") from None
1780
+ template = data.get("template") if isinstance(data, dict) else None
1781
+ version = data.get("version") if isinstance(data, dict) else None
1782
+ if not isinstance(template, str) or not template.strip():
1783
+ raise _AgentConfigError("The agent's prompt could not be loaded: the API sent an empty one.")
1784
+ missing = [p for p in _SCAFFOLD_REQUIRED if p not in template]
1785
+ if missing:
1786
+ raise _AgentConfigError(
1787
+ "The agent's prompt could not be used: it is missing " + ", ".join(missing) + ".")
1788
+ _PROMPT_SCAFFOLD_VERSION["v"] = version
1789
+ return template, version
1790
+
1791
+
1800
1792
  def _usage_field(obj, name):
1801
1793
  """One field off a usage object that may be an attribute bag OR a plain dict — both
1802
1794
  shapes come back from OpenAI-compatible clients depending on the transport."""
@@ -2300,6 +2292,14 @@ def _render_prompt(messages, max_chars: int = 400_000) -> str:
2300
2292
  out = []
2301
2293
  for m in messages or []:
2302
2294
  role, text = _message_role_text(m)
2295
+ if role == "system":
2296
+ # THE SCAFFOLD IS NEVER RECORDED (task #246). The system message IS the API-served
2297
+ # prompt scaffold (rendered with the tool signatures), and this record is stored by
2298
+ # the API and shown on the token-usage page — so it would hand the hidden text to
2299
+ # anyone who opens a step. Its size stays visible, the text does not.
2300
+ v = _PROMPT_SCAFFOLD_VERSION["v"]
2301
+ text = (f"[prompt scaffold{f' v{v}' if v is not None else ''} with the tool signatures — "
2302
+ f"{len(text)} characters, not shown]")
2303
2303
  out.append(f"───── {role or 'message'} ─────\n{text}")
2304
2304
  rendered = "\n\n".join(out)
2305
2305
  if len(rendered) > max_chars:
@@ -12111,21 +12111,25 @@ def run_agent_chat(cfg):
12111
12111
  agent_kwargs["additional_authorized_imports"] = [
12112
12112
  "json", "base64", "urllib", "urllib.request", "urllib.error"
12113
12113
  ]
12114
- # Trim smolagents' ~6k-char generic few-shot examples from the system prompt
12115
- # (they dominate prompt-eval on a slow coding model) while keeping the tool
12116
- # rendering + format rules intact. Load the stock templates and swap only the
12117
- # system_prompt; fall back to the default on any error.
12114
+ # THE SYSTEM PROMPT IS THE API'S SCAFFOLD (task #246): the stock templates are loaded
12115
+ # for everything else (planning, final-answer, managed agents) and ONLY system_prompt
12116
+ # is swapped. No fallback in either direction — a missing scaffold, or stock
12117
+ # templates that will not load, stop the turn with the reason (_AgentConfigError →
12118
+ # TURN_FAILED) instead of quietly running on a different prompt. The version is
12119
+ # logged; the text never is.
12120
+ _scaffold, _scaffold_version = _fetch_prompt_scaffold(worker_api_base, worker_api_key)
12118
12121
  try:
12119
12122
  import yaml as _yaml
12120
12123
  import os as _os
12121
12124
  import smolagents as _sm
12122
- _tpl = _yaml.safe_load(open(_os.path.join(
12123
- _os.path.dirname(_sm.__file__), "prompts", "code_agent.yaml")))
12124
- _tpl["system_prompt"] = _COMPACT_CODE_SYSTEM_PROMPT
12125
- agent_kwargs["prompt_templates"] = _tpl
12126
- _log("using compact CodeAgent system prompt (trimmed few-shot examples)")
12125
+ with open(_os.path.join(_os.path.dirname(_sm.__file__), "prompts", "code_agent.yaml")) as _f:
12126
+ _tpl = _yaml.safe_load(_f)
12127
12127
  except Exception as _e: # noqa: BLE001
12128
- _log(f"compact system prompt unavailable ({_e}); using smolagents default")
12128
+ raise _AgentConfigError(
12129
+ f"The agent's prompt templates could not be loaded ({type(_e).__name__}).") from None
12130
+ _tpl["system_prompt"] = _scaffold
12131
+ agent_kwargs["prompt_templates"] = _tpl
12132
+ _log(f"prompt scaffold code-agent v{_scaffold_version}")
12129
12133
  agent = _ToolAgent(**agent_kwargs)
12130
12134
  _agent_ref["a"] = agent # let step_callback trim this agent's step memory (#63)
12131
12135
  # At the step budget the run ends WITHOUT final_answer (_maxsteps_partial hit). Offer
package/gu-repl.mjs CHANGED
@@ -24,7 +24,7 @@
24
24
  * Commands: /exit /model /reset /help Ctrl-C stops following the current run.
25
25
  */
26
26
  import { readFile, mkdir, writeFile, unlink, rename } from "node:fs/promises";
27
- import { existsSync, statSync, readFileSync, writeFileSync } from "node:fs";
27
+ import { existsSync, statSync, readFileSync, writeFileSync, readdirSync, openSync, readSync, closeSync } from "node:fs";
28
28
  import { spawn } from "node:child_process";
29
29
  // FIRST, before anything that could itself fail on an old runtime: npm only WARNS about
30
30
  // `engines`, so an unsupported Node installs fine and then breaks somewhere unrelated.
@@ -73,6 +73,10 @@ import {
73
73
  answerBlock,
74
74
  updateRestartNote,
75
75
  describeInstallFailure,
76
+ CLI_UPDATE_SHELL_COMMAND,
77
+ isFreshPublishMiss,
78
+ freshPublishNote,
79
+ lazyModuleNames,
76
80
  inputZoneRows,
77
81
  INPUT_HEAD_ROWS,
78
82
  RAG_STORAGE,
@@ -795,8 +799,8 @@ const COMMANDS = [
795
799
  { name: "/proxy", usage: "/proxy [show|set|on|off|clear|test]", desc: "route MODEL calls through an HTTP proxy (local calls always go direct)" },
796
800
  { name: "/s3", usage: "/s3 [test]", desc: "set up the S3 bucket the RAG index lives in, and check the AWS keys work" },
797
801
  { name: "/context-window", usage: "/context-window [tokens|auto]", desc: "tell gu how big the coding model's context is — hosted models cannot be probed" },
798
- { name: "/save-settings", usage: "/save-settings [path]", desc: "write this account's coding providers, models and key NAMES to a file (no secrets)" },
799
- { name: "/load-settings", usage: "/load-settings [path]", desc: "read that file back on another machine — asks before it replaces anything" },
802
+ { name: "/save-settings", usage: "/save-settings [name|path]", desc: "write this account's providers, models and key NAMES to a file in your home folder (no secrets) — asks for a name, Enter keeps gu-settings.json" },
803
+ { name: "/load-settings", usage: "/load-settings [name|path]", desc: "read that file back on another machine — picks from the settings files in your home folder, asks before it replaces anything" },
800
804
  { name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
801
805
  { name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
802
806
  { name: "/help", desc: "list these commands" },
@@ -1962,6 +1966,9 @@ function announceCliUpdate() {
1962
1966
  * The picker names the consequence rather than the version, because the version is already on
1963
1967
  * the button and the consequence is what the answer turns on.
1964
1968
  */
1969
+ /** How long to wait before retrying an install that hit a just-published version (ETARGET). */
1970
+ const UPDATE_RETRY_WAIT_MS = 15_000;
1971
+
1965
1972
  async function runUpdate() {
1966
1973
  const { cli, api } = availableUpdates;
1967
1974
  const rows = [];
@@ -2013,8 +2020,17 @@ async function runUpdate() {
2013
2020
  // stderr is captured rather than discarded so the reason can be shown. npm puts the
2014
2021
  // useful line (EPERM, EACCES, ETARGET) there, and "it failed" without it leaves the
2015
2022
  // user with nothing to act on — sudo and "close gu first" are different fixes.
2016
- const r = spawnSync("npm", ["i", "-g", "@tiens.nguyen/gu-cli@latest"],
2017
- { stdio: ["ignore", "ignore", "pipe"], shell: true, encoding: "utf8" });
2023
+ const install = () => spawnSync(CLI_UPDATE_SHELL_COMMAND,
2024
+ { stdio: ["ignore", "ignore", "pipe"], shell: true, encoding: "utf8" });
2025
+ let r = install();
2026
+ // JUST PUBLISHED: the update check sees a new version the moment it lands, npm's cache and
2027
+ // CDN a few minutes later — ETARGET in between. Wait once and retry before calling it a
2028
+ // failure; if npm still has not caught up, say THAT, not a raw npm error.
2029
+ if (isFreshPublishMiss(r)) {
2030
+ await new Promise((res) => setTimeout(res, UPDATE_RETRY_WAIT_MS));
2031
+ r = install();
2032
+ if (isFreshPublishMiss(r)) throw new Error(freshPublishNote(cli));
2033
+ }
2018
2034
  const failed = describeInstallFailure(r);
2019
2035
  if (failed) throw new Error(failed);
2020
2036
  });
@@ -5645,7 +5661,7 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
5645
5661
  console.log(dim(dflt ? ` Enter uses ${dflt} · Esc cancels\n` : " Esc cancels.\n"));
5646
5662
  for (let attempt = 0; attempt < 3 && !target; attempt++) {
5647
5663
  const typed = await ask(" server URL: ");
5648
- if (typed === null || /^(no|n|skip|-)$/i.test(typed.trim())) break;
5664
+ if (typed === null || as.isCancelWord(typed)) break;
5649
5665
  const raw = typed.trim() || dflt;
5650
5666
  if (!raw) break;
5651
5667
  const t = as.serverTarget(dialect, raw);
@@ -6133,20 +6149,31 @@ async function chooseModel() {
6133
6149
  * NOT THE CURRENT CHOICE. Which model this account codes with stays as it is on the machine being
6134
6150
  * loaded into: a file that moves your models should not quietly move your coder too.
6135
6151
  */
6136
- const SETTINGS_FILE_DEFAULT = join(homedir(), "gu-settings.json");
6137
-
6138
- /** The path a command was given, or the default. Quotes stripped: people paste paths with them. */
6139
- function settingsPathFrom(line, word) {
6140
- const typed = line.slice(word.length).trim().replace(/^['"]|['"]$/g, "");
6141
- if (!typed) return SETTINGS_FILE_DEFAULT;
6142
- const expanded = typed.startsWith("~/") ? join(homedir(), typed.slice(2)) : typed;
6143
- return resolve(expanded);
6152
+ /** What followed the command word, or "" — the name/path rules are settingsFilePath's. */
6153
+ function settingsArg(line, word) {
6154
+ return line.slice(word.length).trim();
6144
6155
  }
6145
6156
 
6146
6157
  async function runSaveSettingsCommand(line) {
6147
6158
  const st = await import("./settings-transfer.mjs");
6148
- const file = settingsPathFrom(line, "/save-settings");
6159
+ const home = homedir();
6149
6160
  console.log("");
6161
+ // THE FILE NAME, IN THE DEFAULT FOLDER. The home directory is where it goes; all anyone should
6162
+ // have to type is a name — and Enter keeps gu-settings.json, as before.
6163
+ let typed = settingsArg(line, "/save-settings");
6164
+ if (!typed) {
6165
+ const answer = await ask(` file name in ${home} [${st.DEFAULT_SETTINGS_NAME}]: `);
6166
+ if (answer === null) {
6167
+ console.log(GUTTER + dim(" (cancelled — nothing was written)\n"));
6168
+ return;
6169
+ }
6170
+ typed = answer.trim();
6171
+ }
6172
+ let file = st.settingsFilePath(typed, home);
6173
+ if (!file) {
6174
+ console.log(GUTTER + dim(" (cancelled — nothing was written)\n"));
6175
+ return;
6176
+ }
6150
6177
  const settings = await withPendingStatus("reading your model settings", fetchWorkerSettings);
6151
6178
  if (!settings) {
6152
6179
  console.log(GUTTER + red(` couldn't reach the API at ${apiBase} — nothing was written.\n`));
@@ -6154,18 +6181,23 @@ async function runSaveSettingsCommand(line) {
6154
6181
  }
6155
6182
  const keys = (await fetchCodingKeys()) ?? [];
6156
6183
  const doc = st.buildTransfer({ settings, keys, host: hostname() });
6157
- if (!doc.codingBackends.length && !doc.agentModel.url && !doc.agentModel.name) {
6158
- console.log(GUTTER + dim(" this account has no coding providers and no agent model set — nothing to save.\n"));
6184
+ if (!doc.codingBackends.length && !doc.agentModel.url && !doc.agentModel.name && !doc.embedModel.url && !doc.embedModel.name) {
6185
+ console.log(GUTTER + dim(" this account has no coding providers, agent model or embedding model set — nothing to save.\n"));
6159
6186
  return;
6160
6187
  }
6161
- // ASK BEFORE OVERWRITING. The default path is one everybody shares, so the second machine to
6162
- // save would otherwise eat the first machine's file without a word.
6163
- if (existsSync(file)) {
6164
- const ok = (await ask(`${file} exists — overwrite it? [y/N] `)).trim().toLowerCase();
6165
- if (ok !== "y" && ok !== "yes") {
6188
+ // ASK BEFORE OVERWRITING — and let a NEW NAME be the answer. The default path is one everybody
6189
+ // shares, so the second machine to save would otherwise eat the first machine's file; "no" alone
6190
+ // left nowhere to go but typing the whole command again with a path.
6191
+ while (existsSync(file)) {
6192
+ const answer = await ask(` ${file} exists — overwrite it? [y/N], or type a new name: `);
6193
+ const a = String(answer ?? "").trim();
6194
+ if (/^(y|yes)$/i.test(a)) break;
6195
+ const next = a && !/^(n|no)$/i.test(a) ? st.settingsFilePath(a, home) : "";
6196
+ if (!next) {
6166
6197
  console.log(GUTTER + dim(" left it alone.\n"));
6167
6198
  return;
6168
6199
  }
6200
+ file = next;
6169
6201
  }
6170
6202
  try {
6171
6203
  // 0600: it names your providers and your key names. Not secret, but nobody else's business.
@@ -6174,19 +6206,36 @@ async function runSaveSettingsCommand(line) {
6174
6206
  console.log(GUTTER + red(` could not write ${file}: ${e instanceof Error ? e.message : e}\n`));
6175
6207
  return;
6176
6208
  }
6177
- const models = doc.codingBackends.reduce((n, b) => n + b.models.length, 0);
6178
6209
  console.log(GUTTER + accent(` saved to ${file}`));
6179
- console.log(
6180
- GUTTER +
6181
- dim(` ${doc.codingBackends.length} provider${doc.codingBackends.length === 1 ? "" : "s"}, ${models} model${models === 1 ? "" : "s"}, ${doc.keys.length} key name${doc.keys.length === 1 ? "" : "s"} — no secrets.`),
6182
- );
6210
+ // Counted by the tested rule — the default model included (it once read "0 models").
6211
+ console.log(GUTTER + dim(` ${st.transferSummary(doc)}`));
6183
6212
  console.log(GUTTER + dim(" load it on the other machine with /load-settings " + file + "\n"));
6184
6213
  }
6185
6214
 
6186
6215
  async function runLoadSettingsCommand(line) {
6187
6216
  const st = await import("./settings-transfer.mjs");
6188
- const file = settingsPathFrom(line, "/load-settings");
6217
+ const home = homedir();
6189
6218
  console.log("");
6219
+ // A NAME IN THE DEFAULT FOLDER, like /save-settings. With none given and more than one settings
6220
+ // file there, pick from them rather than silently taking gu-settings.json.
6221
+ let typed = settingsArg(line, "/load-settings");
6222
+ if (!typed) {
6223
+ const found = st.settingsFilesIn(listJsonFiles(home));
6224
+ if (found.length > 1) {
6225
+ console.log(dim(` Settings files in ${home} (${PICK_HINT}):`));
6226
+ const i = await pickFromList(found, 0);
6227
+ if (i < 0) {
6228
+ console.log(GUTTER + dim(" (cancelled — nothing changed)\n"));
6229
+ return;
6230
+ }
6231
+ typed = found[i];
6232
+ }
6233
+ }
6234
+ const file = st.settingsFilePath(typed, home);
6235
+ if (!file) {
6236
+ console.log(GUTTER + dim(" (cancelled — nothing changed)\n"));
6237
+ return;
6238
+ }
6190
6239
  let text;
6191
6240
  try {
6192
6241
  text = readFileSync(file, "utf8");
@@ -6231,16 +6280,16 @@ async function runLoadSettingsCommand(line) {
6231
6280
  console.log(GUTTER + red(" the API refused the settings — nothing was changed.\n"));
6232
6281
  return;
6233
6282
  }
6234
- console.log(GUTTER + accent(` applied: ${plan.adds} provider${plan.adds === 1 ? "" : "s"}.`));
6283
+ console.log(GUTTER + accent(` applied: ${plan.adds} provider${plan.adds === 1 ? "" : "s"}${patch.ragEmbedUrl || patch.ragEmbedModel ? ", the embedding model" : ""}.`));
6235
6284
 
6236
6285
  // THE KEYS, AFTER the settings — each needs the row it belongs to, and the rows only exist
6237
6286
  // once the API has minted their ids.
6238
6287
  const fresh = (await fetchWorkerSettings()) ?? {};
6239
- const rows = Array.isArray(fresh.agentCodingBackends) ? fresh.agentCodingBackends : [];
6240
- const rowIdForUrl = (url) => {
6241
- const hit = rows.find((r) => String(r?.url ?? "").trim().toLowerCase() === String(url).trim().toLowerCase());
6242
- return hit?.id ?? "";
6243
- };
6288
+ const ps = await import("./coding-provider-setup.mjs");
6289
+ const rows = ps.existingBackendRows(fresh);
6290
+ // One server however it is spelled (task #245) — a raw compare missed "…/v1" and saved the key
6291
+ // tied to NO server, where it can never be used.
6292
+ const rowIdForUrl = (url) => ps.providerRowForUrl(rows, url)?.id ?? "";
6244
6293
  for (const k of plan.missingKeys) {
6245
6294
  const where = k.backendUrl ? ` for ${k.backendUrl}` : "";
6246
6295
  const typed = k.key || (await ask(` key "${k.name}"${k.tail ? ` (…${k.tail})` : ""}${where} — paste it, or Enter to skip: `)).trim();
@@ -6248,12 +6297,42 @@ async function runLoadSettingsCommand(line) {
6248
6297
  console.log(GUTTER + dim(` skipped ${k.name} — add it later with ctrl+k.`));
6249
6298
  continue;
6250
6299
  }
6251
- const id = await addCodingKey(k.name, typed, k.backendUrl ? rowIdForUrl(k.backendUrl) : "");
6300
+ const rowId = k.backendUrl ? rowIdForUrl(k.backendUrl) : "";
6301
+ const id = await addCodingKey(k.name, typed, rowId);
6252
6302
  console.log(GUTTER + (id ? accent(` added ${k.name}.`) : red(` could not add ${k.name}.`)));
6303
+ // The key that was its server's DEFAULT on the saving machine is the default here too.
6304
+ if (id && rowId && k.default) await setServerDefaultKey(rowId, id);
6253
6305
  }
6254
6306
  console.log(GUTTER + dim(" pick a coding model with /model.\n"));
6255
6307
  }
6256
6308
 
6309
+ /** The .json files directly in a folder, with enough of each to recognise a settings file. */
6310
+ function listJsonFiles(dir) {
6311
+ try {
6312
+ return readdirSync(dir)
6313
+ .filter((n) => /\.json$/i.test(n))
6314
+ .slice(0, 200)
6315
+ .map((name) => {
6316
+ try {
6317
+ const p = join(dir, name);
6318
+ const st = statSync(p);
6319
+ // The marker is near the top; 4 KB is plenty and keeps a huge JSON from being read whole.
6320
+ if (!st.isFile() || st.size > 5_000_000) return null;
6321
+ const fd = openSync(p, "r");
6322
+ const buf = Buffer.alloc(Math.min(4096, st.size));
6323
+ readSync(fd, buf, 0, buf.length, 0);
6324
+ closeSync(fd);
6325
+ return { name, text: buf.toString("utf8"), mtimeMs: st.mtimeMs };
6326
+ } catch {
6327
+ return null;
6328
+ }
6329
+ })
6330
+ .filter(Boolean);
6331
+ } catch {
6332
+ return [];
6333
+ }
6334
+ }
6335
+
6257
6336
  async function fetchWorkerSettings() {
6258
6337
  try {
6259
6338
  const r = await fetch(`${apiBase}/api/worker/settings`, {
@@ -9925,7 +10004,35 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
9925
10004
  }
9926
10005
 
9927
10006
  // ---------- REPL ----------
10007
+ /**
10008
+ * LOAD EVERY LAZILY-IMPORTED MODULE NOW, so this process never reads one from disk later.
10009
+ *
10010
+ * REPORTED: "✓ gu 1.0.722 installed — Then restart gu", then /embed-model in the SAME terminal
10011
+ * crashed it with ERR_MODULE_NOT_FOUND …/ollama-host-snippets.mjs. The running process was still
10012
+ * 1.0.721; its /embed-model lazily imported ollama-host-snippets.mjs, and npm had just
10013
+ * replaced the package directory with 1.0.722, which no longer ships that file. A module that
10014
+ * still EXISTS is worse, not better: the old REPL silently runs against the new helper, two
10015
+ * versions mixed in one process.
10016
+ *
10017
+ * The ESM loader caches a module by its resolved URL, so once each one is loaded here every later
10018
+ * lazy import of that name gets THIS version back without touching the disk — whatever an in-app update
10019
+ * wrote there. The list is read from this file's own source, so a new lazy import is covered
10020
+ * without anyone remembering to add it. gu-cli.mjs is excluded: importing it RUNS the CLI.
10021
+ * Fire-and-forget: each import is ~5 ms, verified side-effect free (no output, no files).
10022
+ */
10023
+ function preloadLazyModules() {
10024
+ let text = "";
10025
+ try {
10026
+ text = readFileSync(fileURLToPath(import.meta.url), "utf8");
10027
+ } catch {
10028
+ return;
10029
+ }
10030
+ for (const name of lazyModuleNames(text)) import(`./${name}`).catch(() => {});
10031
+ }
10032
+
10033
+
9928
10034
  async function main() {
10035
+ preloadLazyModules();
9929
10036
  // The startup banner (logo + status facts) is printed AFTER the payload fetch below, once the
9930
10037
  // model/coder/RAG are known — see renderBanner (task #131). Nothing prints here so the banner
9931
10038
  // lands as one clean block.
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.722",
3
+ "version": "1.0.724",
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",
@@ -29,13 +29,100 @@
29
29
  * your models should not also decide which one you are coding with.
30
30
  */
31
31
 
32
- /** Bumped only when an older gu could not read a newer file correctly. */
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: { backends, agentModel: { url: clean(am?.url), name: clean(am?.name) }, keys, savedAt: clean(doc.savedAt), savedOn: clean(doc.savedOn) },
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
- const have = new Set(currentKeys.map((k) => clean(k.name).toLowerCase()).filter(Boolean));
194
- const missingKeys = file.keys.filter((k) => !have.has(k.name.toLowerCase()) && !k.key);
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 models = b.models.length ? `${b.models.length} model${b.models.length === 1 ? "" : "s"}` : "no models";
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
- const idFor = (kind, url) =>
234
- existing.find(
235
- (r) => r?.id && r.kind === kind && clean(r.url).toLowerCase() === clean(url).toLowerCase(),
236
- )?.id ?? "";
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 id = idFor(b.kind, b.url);
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: b.defaultModel,
245
- models: b.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
  }
@@ -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();