@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 +7 -1
- package/client-expert-api.mjs +14 -0
- package/coding-provider-setup.mjs +12 -0
- package/gonext_agent_chat.py +87 -83
- package/gu-repl.mjs +142 -35
- package/ollama-setup.mjs +3 -0
- package/package.json +1 -1
- package/settings-transfer.mjs +156 -13
- package/terminal-layout.mjs +40 -0
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
|
}
|
package/client-expert-api.mjs
CHANGED
|
@@ -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":
|
package/gonext_agent_chat.py
CHANGED
|
@@ -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 (
|
|
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
|
-
#
|
|
1726
|
-
#
|
|
1727
|
-
#
|
|
1728
|
-
#
|
|
1729
|
-
# the
|
|
1730
|
-
#
|
|
1731
|
-
#
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
"
|
|
1736
|
-
"
|
|
1737
|
-
"
|
|
1738
|
-
"{{
|
|
1739
|
-
"
|
|
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
|
-
#
|
|
12115
|
-
#
|
|
12116
|
-
#
|
|
12117
|
-
#
|
|
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
|
-
|
|
12123
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
2017
|
-
|
|
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 ||
|
|
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
|
-
|
|
6137
|
-
|
|
6138
|
-
|
|
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
|
|
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
|
|
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
|
|
6162
|
-
// save would otherwise eat the first machine's file
|
|
6163
|
-
|
|
6164
|
-
|
|
6165
|
-
|
|
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
|
-
|
|
6180
|
-
|
|
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
|
|
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
|
|
6240
|
-
const
|
|
6241
|
-
|
|
6242
|
-
|
|
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
|
|
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.
|
|
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",
|
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();
|