@tiens.nguyen/gu-cli 1.0.814 → 1.0.817
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/client-expert-run.mjs +73 -15
- package/coding-provider-setup.mjs +6 -0
- package/gonext_agent_chat.py +160 -2
- package/gu-cli.mjs +48 -12
- package/gu-repl.mjs +149 -32
- package/install-path.mjs +22 -1
- package/model-setup-ui.mjs +28 -0
- package/ollama-setup.mjs +16 -0
- package/package.json +1 -1
- package/proxy-dispatcher.mjs +10 -0
- package/terminal-layout.mjs +9 -0
package/README.md
CHANGED
|
@@ -10,7 +10,8 @@ gu # open the REPL in the current folder
|
|
|
10
10
|
|
|
11
11
|
The package installs three commands: `gu` (the terminal REPL), `gu-cli` and `gu-local-worker`
|
|
12
12
|
(the same worker CLI). Requires Node.js 18 or newer. The install runs `fix-path` for you; if
|
|
13
|
-
npm's global bin folder is not on your PATH afterwards, run `gu-cli fix-path
|
|
13
|
+
npm's global bin folder is not on your PATH afterwards, run `npx @tiens.nguyen/gu-cli fix-path`
|
|
14
|
+
(works even when `gu-cli` itself is not found, e.g. when npm skipped the install script).
|
|
14
15
|
On Windows, PowerShell's built-in `gu` alias (Get-Unique) shadows this CLI — run
|
|
15
16
|
`gu-cli fix-powershell` to drop it.
|
|
16
17
|
|
package/client-expert-run.mjs
CHANGED
|
@@ -40,7 +40,15 @@ import {
|
|
|
40
40
|
LOCAL_MONGO_URI,
|
|
41
41
|
} from "./client-expert-api.mjs";
|
|
42
42
|
import { askOllamaSetup, localOllamaDefault } from "./ollama-setup.mjs";
|
|
43
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
SKIP_ALL,
|
|
45
|
+
createHinter,
|
|
46
|
+
modelSetupMenu,
|
|
47
|
+
parseModelSetupChoice,
|
|
48
|
+
providerChoiceMenu,
|
|
49
|
+
parseProviderChoice,
|
|
50
|
+
} from "./model-setup-ui.mjs";
|
|
51
|
+
import { providerPresets } from "./coding-provider-setup.mjs";
|
|
44
52
|
import { presetFilesIn } from "./presets-transfer.mjs";
|
|
45
53
|
import { readdirSync, statSync, readFileSync } from "node:fs";
|
|
46
54
|
import { parseOllamaTags, formatSize } from "./agent-model-command.mjs";
|
|
@@ -312,8 +320,8 @@ export async function ensureNativeMongoMac(ask, mongo, { log = (l) => console.lo
|
|
|
312
320
|
log(dim(installing ? "\n It is not installed here. This installs it (Homebrew):\n" : "\n It is installed but not running. This starts it:\n"));
|
|
313
321
|
for (const s of steps) log(` brew ${s.join(" ")}`);
|
|
314
322
|
log("");
|
|
315
|
-
const yes = await ask
|
|
316
|
-
if (
|
|
323
|
+
const yes = await readYesNo(ask, installing ? " Install MongoDB now? [Y/n]: " : " Start MongoDB now? [Y/n]: ", true, log);
|
|
324
|
+
if (!yes) {
|
|
317
325
|
log(dim(`\n Not ${installing ? "installed" : "started"} — run the commands above yourself.`));
|
|
318
326
|
return { ok: false, explained: true };
|
|
319
327
|
}
|
|
@@ -440,8 +448,8 @@ export async function ensureNativeMongo(ask, mongo, {
|
|
|
440
448
|
const needs = platform === "win32"
|
|
441
449
|
? "Windows may ask for administrator permission"
|
|
442
450
|
: isRoot ? "" : "sudo will ask for your password";
|
|
443
|
-
const yes = await ask
|
|
444
|
-
if (
|
|
451
|
+
const yes = await readYesNo(ask, ` ${installing ? "Install" : "Start"} MongoDB now?${needs ? ` (${needs})` : ""} [Y/n]: `, true, log);
|
|
452
|
+
if (!yes) {
|
|
445
453
|
log(dim(`\n Not ${installing ? "installed" : "started"} — run the commands above yourself.`));
|
|
446
454
|
return { ok: false, explained: true };
|
|
447
455
|
}
|
|
@@ -608,8 +616,7 @@ export async function startMongoInDocker(ask, { platform = process.platform } =
|
|
|
608
616
|
console.log(dim(` Simplest path: ${plan.summary}.`));
|
|
609
617
|
console.log(dim(" Your conversation history lives in that volume and survives a restart."));
|
|
610
618
|
console.log(dim("\n " + plan.commands[0] + "\n"));
|
|
611
|
-
|
|
612
|
-
if (String(yes ?? "").trim().toLowerCase().startsWith("n")) return false;
|
|
619
|
+
if (!(await readYesNo(ask, " Start MongoDB in Docker now? [Y/n]: ", true))) return false;
|
|
613
620
|
|
|
614
621
|
for (const cmd of plan.commands) {
|
|
615
622
|
console.log(dim(` $ ${cmd}`));
|
|
@@ -672,13 +679,36 @@ export async function checkMongoConnection(uri) {
|
|
|
672
679
|
* Returns { uri, custom } to continue with, or { exit: true } when the user declines — the
|
|
673
680
|
* caller ends setup with the `gu clear` → `gu` hint.
|
|
674
681
|
*/
|
|
682
|
+
/**
|
|
683
|
+
* A [Y/n] / [y/N] answer (GitHub #128). Enter → the default; y/yes → yes; n/no → no. Anything else
|
|
684
|
+
* ("3", "maybe") is NOT taken as an answer: the question is asked again with what to type —
|
|
685
|
+
* "3" at "Install MongoDB now? [Y/n]" used to install MongoDB. A closed input (null) is "no", as
|
|
686
|
+
* it always was. After three unrecognised answers the default is used.
|
|
687
|
+
*/
|
|
688
|
+
export function parseYesNo(raw, defaultYes) {
|
|
689
|
+
if (raw === null || raw === undefined) return false;
|
|
690
|
+
const a = String(raw).trim().toLowerCase();
|
|
691
|
+
if (!a) return defaultYes;
|
|
692
|
+
if (a === "y" || a === "yes") return true;
|
|
693
|
+
if (a === "n" || a === "no") return false;
|
|
694
|
+
return null;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
export async function readYesNo(ask, prompt, defaultYes, say = (l) => console.log(l)) {
|
|
698
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
699
|
+
const verdict = parseYesNo(await ask(prompt), defaultYes);
|
|
700
|
+
if (verdict !== null) return verdict;
|
|
701
|
+
say(dim(` Type y or n (Enter = ${defaultYes ? "yes" : "no"}).`));
|
|
702
|
+
}
|
|
703
|
+
return defaultYes;
|
|
704
|
+
}
|
|
705
|
+
|
|
675
706
|
export async function askMongoCredentials(ask, askSecret = null) {
|
|
676
707
|
const { host, port } = mongoHostPort(LOCAL_MONGO_URI);
|
|
677
708
|
console.log(dim(` MongoDB is answering at ${host}:${port}.`));
|
|
678
709
|
console.log(dim(" If that is a MongoDB this machine already had, it may have its OWN"));
|
|
679
710
|
console.log(dim(" username and password — which setup does not know, and cannot change."));
|
|
680
|
-
|
|
681
|
-
if (!String(yes ?? "").trim().toLowerCase().startsWith("n")) {
|
|
711
|
+
if (await readYesNo(ask, " Use the default (empty username and password)? [Y/n]: ", true)) {
|
|
682
712
|
return { uri: LOCAL_MONGO_URI, custom: false };
|
|
683
713
|
}
|
|
684
714
|
// NO → the instance has its own credentials. Ask for them, and CHECK before any installing
|
|
@@ -700,15 +730,13 @@ export async function askMongoCredentials(ask, askSecret = null) {
|
|
|
700
730
|
}
|
|
701
731
|
if (verdict.unverifiable) {
|
|
702
732
|
console.log("\n " + yellow("⚠ could not verify: ") + dim(verdict.why));
|
|
703
|
-
|
|
704
|
-
if (!String(carryOn ?? "").trim().toLowerCase().startsWith("n")) {
|
|
733
|
+
if (await readYesNo(ask, " Continue with these credentials anyway? [Y/n]: ", true)) {
|
|
705
734
|
return { uri, custom: true, unverified: true };
|
|
706
735
|
}
|
|
707
736
|
} else {
|
|
708
737
|
console.log("\n " + red("✗ Check Connection failed") + dim(` — ${verdict.why}`));
|
|
709
738
|
}
|
|
710
|
-
|
|
711
|
-
if (!String(again ?? "").trim().toLowerCase().startsWith("y")) return { exit: true };
|
|
739
|
+
if (!(await readYesNo(ask, " Re-enter the username and password? [y/N]: ", false))) return { exit: true };
|
|
712
740
|
}
|
|
713
741
|
return { exit: true };
|
|
714
742
|
}
|
|
@@ -1304,6 +1332,33 @@ export async function runClientExpertSetup({
|
|
|
1304
1332
|
chosenModels = { skipped: true, keys: [], loadPreset: path === "preset" };
|
|
1305
1333
|
throw new SkipModelQuestion();
|
|
1306
1334
|
}
|
|
1335
|
+
// WHICH PROVIDER (#132 follow-up): Ollama is still the Enter default, but a hosted provider can be
|
|
1336
|
+
// picked from the preset list or typed. That pick is HANDED to gu's own add-provider flow, which
|
|
1337
|
+
// asks the key and then the model once gu starts — the terminal that flow needs does not exist yet.
|
|
1338
|
+
const provPresets = providerPresets("coding");
|
|
1339
|
+
console.log("");
|
|
1340
|
+
const [ptitle, ...prows] = providerChoiceMenu(provPresets);
|
|
1341
|
+
console.log(" " + bold(ptitle));
|
|
1342
|
+
for (const r of prows) console.log(dim(" " + r));
|
|
1343
|
+
let provider = null;
|
|
1344
|
+
for (let t = 0; t < 3 && !provider; t++) {
|
|
1345
|
+
provider = parseProviderChoice(await askStep(" choose [1]: "), provPresets);
|
|
1346
|
+
}
|
|
1347
|
+
provider = provider ?? { kind: "ollama" };
|
|
1348
|
+
if (provider.kind === "skip") {
|
|
1349
|
+
chosenModels = { skipped: true, keys: [], loadPreset: false };
|
|
1350
|
+
throw new SkipModelQuestion();
|
|
1351
|
+
}
|
|
1352
|
+
if (provider.kind === "preset" || provider.kind === "url") {
|
|
1353
|
+
chosenModels = {
|
|
1354
|
+
skipped: true,
|
|
1355
|
+
keys: [],
|
|
1356
|
+
addProvider: provider.kind === "preset"
|
|
1357
|
+
? { name: provider.preset.name, url: provider.preset.url, dialect: provider.preset.dialect ?? "openai" }
|
|
1358
|
+
: { name: "", url: "", dialect: "openai" },
|
|
1359
|
+
};
|
|
1360
|
+
throw new SkipModelQuestion();
|
|
1361
|
+
}
|
|
1307
1362
|
console.log("");
|
|
1308
1363
|
console.log(" " + bold("Which Ollama server should this machine use?"));
|
|
1309
1364
|
// THIS MACHINE FIRST, when it has one. Client Expert is the mode whose whole promise is
|
|
@@ -1351,6 +1406,7 @@ export async function runClientExpertSetup({
|
|
|
1351
1406
|
// #301: skipped on purpose (up front, "s" at both servers, or Esc) → no model field is written.
|
|
1352
1407
|
const skipModels = Boolean(chosenModels?.skipped);
|
|
1353
1408
|
const loadPreset = Boolean(chosenModels?.loadPreset);
|
|
1409
|
+
const addProvider = chosenModels?.addProvider ?? null;
|
|
1354
1410
|
|
|
1355
1411
|
try {
|
|
1356
1412
|
const written = await seedSettings({
|
|
@@ -1371,7 +1427,9 @@ export async function runClientExpertSetup({
|
|
|
1371
1427
|
}
|
|
1372
1428
|
if (skipModels) {
|
|
1373
1429
|
console.log("");
|
|
1374
|
-
console.log(" " + cyan("✓ Model setup skipped") + dim(
|
|
1430
|
+
console.log(" " + cyan("✓ Model setup skipped") + dim(addProvider
|
|
1431
|
+
? ` — ${addProvider.name || "the provider list"}: its key and model are asked as soon as gu starts`
|
|
1432
|
+
: loadPreset
|
|
1375
1433
|
? " — the preset picker opens as soon as gu starts"
|
|
1376
1434
|
: " — set them any time with /model (or /use-presets)"));
|
|
1377
1435
|
} else if (Object.keys(written).length === 0) {
|
|
@@ -1423,5 +1481,5 @@ export async function runClientExpertSetup({
|
|
|
1423
1481
|
);
|
|
1424
1482
|
// #301: the caller opens /use-presets once the terminal exists — the picker, the plan and the
|
|
1425
1483
|
// confirmation are that command's, not rebuilt here.
|
|
1426
|
-
return { complete: true, stoppedAt: null, ...(loadPreset ? { loadPreset: true } : {}) };
|
|
1484
|
+
return { complete: true, stoppedAt: null, ...(loadPreset ? { loadPreset: true } : {}), ...(addProvider ? { addProvider } : {}) };
|
|
1427
1485
|
}
|
|
@@ -260,6 +260,12 @@ export function providerPresets(role = "coding") {
|
|
|
260
260
|
{ name: "Claude API (Anthropic)", url: CLAUDE_API_URL, roles: ["coding", "agent"] },
|
|
261
261
|
{ name: "OpenAI", url: DEFAULT_PROVIDER_URL, roles: ["coding", "agent", "embed"] },
|
|
262
262
|
{ name: "Kimi (Moonshot)", url: "https://api.moonshot.ai/v1", roles: ["coding", "agent"] },
|
|
263
|
+
// #132. Ollama's cloud speaks Ollama's own API (/api/tags), so the preset names its dialect.
|
|
264
|
+
{ name: "Ollama cloud", url: "https://ollama.com", dialect: "ollama", roles: ["coding", "agent"] },
|
|
265
|
+
// Qwen's two plans take DIFFERENT keys (Token Plan sk-sp-..., pay-as-you-go sk-ws-...), so the
|
|
266
|
+
// plan is in the name. Both are OpenAI-compatible and already carry their /compatible-mode/v1 path.
|
|
267
|
+
{ name: "Qwen Token Plan (OpenAI-compatible)", url: "https://token-plan.maas.qwencloudapi.com/compatible-mode/v1", roles: ["coding", "agent"] },
|
|
268
|
+
{ name: "Qwen pay-as-you-go (OpenAI-compatible)", url: "https://maas.qwencloudapi.com/compatible-mode/v1", roles: ["coding", "agent"] },
|
|
263
269
|
];
|
|
264
270
|
return all.filter((p) => p.roles.includes(role));
|
|
265
271
|
}
|
package/gonext_agent_chat.py
CHANGED
|
@@ -4574,6 +4574,8 @@ def _strip_tool_tags(text: str) -> str:
|
|
|
4574
4574
|
"""Remove the internal hint tags we append to tool output (e.g. '[SUCCESS …]',
|
|
4575
4575
|
'[NOTE: …]', 'Note: This URL failed …') so they never leak into the user reply."""
|
|
4576
4576
|
out = []
|
|
4577
|
+
# DeepSeek tool_calls/invoke markup must never reach the user as the answer (#133).
|
|
4578
|
+
text = _strip_toolcall_markup(text)
|
|
4577
4579
|
for ln in (text or "").splitlines():
|
|
4578
4580
|
s = ln.strip()
|
|
4579
4581
|
if s.startswith("[SUCCESS") or s.startswith("[NOTE:") or s.startswith("Note: This URL failed"):
|
|
@@ -4582,6 +4584,99 @@ def _strip_tool_tags(text: str) -> str:
|
|
|
4582
4584
|
return "\n".join(out).strip()
|
|
4583
4585
|
|
|
4584
4586
|
|
|
4587
|
+
def _md_scalar(v) -> str:
|
|
4588
|
+
if v is None or v == "" or v == [] or v == {}:
|
|
4589
|
+
return "none"
|
|
4590
|
+
if isinstance(v, bool):
|
|
4591
|
+
return "yes" if v else "no"
|
|
4592
|
+
return str(v)
|
|
4593
|
+
|
|
4594
|
+
|
|
4595
|
+
def _md_lines(value, depth=0):
|
|
4596
|
+
"""Nested dict/list -> markdown bullet lines (no raw braces, quotes or brackets)."""
|
|
4597
|
+
pad = " " * depth
|
|
4598
|
+
lines = []
|
|
4599
|
+
if isinstance(value, dict):
|
|
4600
|
+
for k, v in value.items():
|
|
4601
|
+
label = str(k).replace("_", " ").strip()
|
|
4602
|
+
label = label[:1].upper() + label[1:]
|
|
4603
|
+
if isinstance(v, (dict, list, tuple)) and v:
|
|
4604
|
+
lines.append(f"{pad}- **{label}:**")
|
|
4605
|
+
lines.extend(_md_lines(v, depth + 1))
|
|
4606
|
+
else:
|
|
4607
|
+
lines.append(f"{pad}- **{label}:** {_md_scalar(v)}")
|
|
4608
|
+
else:
|
|
4609
|
+
for item in value:
|
|
4610
|
+
if isinstance(item, (dict, list, tuple)) and item:
|
|
4611
|
+
lines.extend(_md_lines(item, depth + 1))
|
|
4612
|
+
else:
|
|
4613
|
+
lines.append(f"{pad}- {_md_scalar(item)}")
|
|
4614
|
+
return lines
|
|
4615
|
+
|
|
4616
|
+
|
|
4617
|
+
def _fallback_recap(steps, last_text="") -> str:
|
|
4618
|
+
"""Task #131: the model ended its turn with no final text (e.g. only reasoning). Build a
|
|
4619
|
+
short readable recap from the steps that did run instead of showing '[No result]'."""
|
|
4620
|
+
tried = []
|
|
4621
|
+
for st in (steps or [])[-8:]:
|
|
4622
|
+
did = str(st.get("did") or "").strip().replace(chr(10), " ")
|
|
4623
|
+
m = re.search(r"([a-z_]+\([^\n]{0,70})", did)
|
|
4624
|
+
label = (m.group(1) if m else did)[:70].strip()
|
|
4625
|
+
if label:
|
|
4626
|
+
tried.append("- " + label)
|
|
4627
|
+
last = str(last_text or "").strip()
|
|
4628
|
+
if not tried and not last:
|
|
4629
|
+
return ""
|
|
4630
|
+
out = ["**Status:** the model finished without writing a summary."]
|
|
4631
|
+
if tried:
|
|
4632
|
+
out += ["", "**What was run**"] + tried
|
|
4633
|
+
if last:
|
|
4634
|
+
out += ["", "**Last result**", last[:600]]
|
|
4635
|
+
return chr(10).join(out)
|
|
4636
|
+
|
|
4637
|
+
|
|
4638
|
+
def _humanize_answer(result) -> str:
|
|
4639
|
+
"""Task #131: a final answer that is a dict/list (or a string that is just a JSON /
|
|
4640
|
+
Python-dict literal, as DeepSeek produces) is rendered as readable markdown instead of
|
|
4641
|
+
being shown as its raw repr. Plain prose is returned untouched."""
|
|
4642
|
+
if result is None:
|
|
4643
|
+
return ""
|
|
4644
|
+
data = result
|
|
4645
|
+
if isinstance(result, str):
|
|
4646
|
+
s = result.strip()
|
|
4647
|
+
if len(s) < 2 or s[0] not in "{[" or s[-1] not in "}]":
|
|
4648
|
+
return result
|
|
4649
|
+
data = None
|
|
4650
|
+
try:
|
|
4651
|
+
data = json.loads(s)
|
|
4652
|
+
except Exception: # noqa: BLE001
|
|
4653
|
+
try:
|
|
4654
|
+
import ast
|
|
4655
|
+
data = ast.literal_eval(s)
|
|
4656
|
+
except Exception: # noqa: BLE001
|
|
4657
|
+
return result
|
|
4658
|
+
if not isinstance(data, (dict, list)) or not data:
|
|
4659
|
+
return result
|
|
4660
|
+
elif isinstance(result, tuple):
|
|
4661
|
+
data = list(result)
|
|
4662
|
+
elif not isinstance(result, (dict, list)):
|
|
4663
|
+
return str(result)
|
|
4664
|
+
if isinstance(data, dict):
|
|
4665
|
+
out = []
|
|
4666
|
+
for k, v in data.items():
|
|
4667
|
+
label = str(k).replace("_", " ").strip()
|
|
4668
|
+
label = label[:1].upper() + label[1:]
|
|
4669
|
+
if isinstance(v, (dict, list, tuple)) and v:
|
|
4670
|
+
out.append("")
|
|
4671
|
+
out.append(f"**{label}**")
|
|
4672
|
+
out.extend(_md_lines(v, 0))
|
|
4673
|
+
else:
|
|
4674
|
+
out.append("")
|
|
4675
|
+
out.append(f"**{label}:** {_md_scalar(v)}")
|
|
4676
|
+
return chr(10).join(out).strip()
|
|
4677
|
+
return chr(10).join(_md_lines(data, 0)).strip()
|
|
4678
|
+
|
|
4679
|
+
|
|
4585
4680
|
_THINK_BLOCK = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
|
|
4586
4681
|
|
|
4587
4682
|
# Malformed variants of smolagents' code-block tags. Weak coding models routinely
|
|
@@ -4812,6 +4907,62 @@ def _dsml_to_code_block(text):
|
|
|
4812
4907
|
return (prose + "\n" if prose else "") + "<code>\n" + body + "\n</code>"
|
|
4813
4908
|
|
|
4814
4909
|
|
|
4910
|
+
# DeepSeek plain tool-call markup (issue #133). Besides the DSML form above, DeepSeek-family
|
|
4911
|
+
# models answer with <tool_calls><invoke name="X"><parameter name="k" string="true">v</parameter>
|
|
4912
|
+
# </invoke></tool_calls>. That matched nothing, so it reached the user as the FINAL answer (an
|
|
4913
|
+
# unexecuted run_command and no recap). Read it as a code block so the call runs, and strip any
|
|
4914
|
+
# leftover from the final text so it is never shown.
|
|
4915
|
+
_TC_INVOKE = re.compile(r"<invoke\s+name\s*=\s*[\x22\x27]([A-Za-z_]\w*)[\x22\x27]\s*>([\s\S]*?)(?:</invoke\s*>|(?=<invoke\b)|$)")
|
|
4916
|
+
_TC_PARAM = re.compile(r"<parameter\s+name\s*=\s*[\x22\x27]([A-Za-z_]\w*)[\x22\x27]"
|
|
4917
|
+
r"(?:\s+string\s*=\s*[\x22\x27](true|false)[\x22\x27])?\s*>([\s\S]*?)(?:</parameter\s*>|(?=<parameter\b)|$)")
|
|
4918
|
+
_TC_BLOCK = re.compile(r"<\s*tool_calls\s*>[\s\S]*?(?:<\s*/\s*tool_calls\s*>|$)", re.I)
|
|
4919
|
+
_TC_STRAY = re.compile(r"</?\s*tool_calls\s*>|</?\s*invoke\b[^<>]*>|</?\s*parameter\b[^<>]*>", re.I)
|
|
4920
|
+
_TC_JSON_WORDS = {"true": "True", "false": "False", "null": "None"}
|
|
4921
|
+
|
|
4922
|
+
|
|
4923
|
+
def _strip_toolcall_markup(text):
|
|
4924
|
+
"""Remove tool_calls/invoke/parameter markup from text that is shown to the user."""
|
|
4925
|
+
if not text or ("<tool_calls" not in text and "<invoke" not in text and "<parameter" not in text):
|
|
4926
|
+
return text
|
|
4927
|
+
return _TC_STRAY.sub("", _TC_BLOCK.sub("", text)).strip()
|
|
4928
|
+
|
|
4929
|
+
|
|
4930
|
+
def _toolcalls_to_code_block(text):
|
|
4931
|
+
"""Rewrite tool_calls/invoke/parameter markup into a code block that calls the tools, or
|
|
4932
|
+
return the text unchanged (only when there is no code tag already and the result COMPILES)."""
|
|
4933
|
+
if not text or "<invoke" not in text or re.search(r"<\s*/?\s*code\b", text, re.I):
|
|
4934
|
+
return text
|
|
4935
|
+
calls = []
|
|
4936
|
+
for m in _TC_INVOKE.finditer(text):
|
|
4937
|
+
args = []
|
|
4938
|
+
for pm in _TC_PARAM.finditer(m.group(2)):
|
|
4939
|
+
val = pm.group(3).strip("\n")
|
|
4940
|
+
lit = repr(val)
|
|
4941
|
+
if pm.group(2) == "false":
|
|
4942
|
+
if val in _TC_JSON_WORDS:
|
|
4943
|
+
lit = _TC_JSON_WORDS[val]
|
|
4944
|
+
else:
|
|
4945
|
+
try:
|
|
4946
|
+
json.loads(val)
|
|
4947
|
+
lit = val
|
|
4948
|
+
except Exception: # noqa: BLE001
|
|
4949
|
+
lit = repr(val)
|
|
4950
|
+
args.append(pm.group(1) + "=" + lit)
|
|
4951
|
+
calls.append("print(" + m.group(1) + "(" + ", ".join(args) + "))")
|
|
4952
|
+
if not calls:
|
|
4953
|
+
return text
|
|
4954
|
+
body = "\n".join(calls)
|
|
4955
|
+
try:
|
|
4956
|
+
compile(body, "<gu-code>", "exec")
|
|
4957
|
+
except Exception: # noqa: BLE001
|
|
4958
|
+
return text
|
|
4959
|
+
first = text.find("<tool_calls")
|
|
4960
|
+
if first < 0:
|
|
4961
|
+
first = text.find("<invoke")
|
|
4962
|
+
prose = text[:first].rstrip()
|
|
4963
|
+
return (prose + "\n" if prose else "") + "<code>\n" + body + "\n</code>"
|
|
4964
|
+
|
|
4965
|
+
|
|
4815
4966
|
def _code_block_readings(text):
|
|
4816
4967
|
"""Every plausible reading of where the code block is, CHEAPEST FIRST.
|
|
4817
4968
|
|
|
@@ -4876,6 +5027,10 @@ def _canonicalise_code_reply(text):
|
|
|
4876
5027
|
if _dsml != text:
|
|
4877
5028
|
_log("DSML tool-call markup converted to a code block (#119)")
|
|
4878
5029
|
return _dsml
|
|
5030
|
+
_tc = _toolcalls_to_code_block(text)
|
|
5031
|
+
if _tc != text:
|
|
5032
|
+
_log("tool_calls/invoke markup converted to a code block (#133)")
|
|
5033
|
+
return _tc
|
|
4879
5034
|
if not text or not _CODE_ANY_TAGGISH.search(text):
|
|
4880
5035
|
return text
|
|
4881
5036
|
fallback = None
|
|
@@ -9538,7 +9693,8 @@ def run_agent_chat(cfg):
|
|
|
9538
9693
|
"instead of stopping the block. Keep UNRELATED changes in separate blocks. Take a separate "
|
|
9539
9694
|
"step only when a call depends on a result you have not seen yet (e.g. web_search to "
|
|
9540
9695
|
"find a URL, THEN fetch_url to read it). You SEE the Observation before the next step. "
|
|
9541
|
-
"When you have enough to answer, call final_answer(<your answer>) in a code block
|
|
9696
|
+
"When you have enough to answer, call final_answer(<your answer>) in a code block — pass a readable "
|
|
9697
|
+
"markdown/prose recap (what you did, what you verified, what is left), never a dict, list or JSON. "
|
|
9542
9698
|
"Prefer FEWER steps, and never repeat a tool call that already succeeded.\n\n"
|
|
9543
9699
|
"{{TOOL_LIST}}\n" # filled in below, once from the REAL registered tool objects
|
|
9544
9700
|
+ _skills_mod.prompt_block(_skills)
|
|
@@ -14227,7 +14383,9 @@ def run_agent_chat(cfg):
|
|
|
14227
14383
|
_emit({"type": "step", "text": _retr})
|
|
14228
14384
|
_log(f"retrieval summary: {_c(_retr)}")
|
|
14229
14385
|
_emit({"type": "step", "text": "Composing answer…"})
|
|
14230
|
-
final_text = _strip_tool_tags(
|
|
14386
|
+
final_text = (_strip_tool_tags(_humanize_answer(result).strip())
|
|
14387
|
+
or _fallback_recap(_turn_steps, _last_obs.get("text"))
|
|
14388
|
+
or "[No result]")
|
|
14231
14389
|
_log(f"done (deterministic, no summarizer call): {len(final_text)} chars")
|
|
14232
14390
|
_emit({"type": "final", "text": final_text})
|
|
14233
14391
|
# onTurnEnd (task #270): after the answer is out, so a slow hook never delays it.
|
package/gu-cli.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
// FIRST, before anything that could itself fail on an old runtime: npm only WARNS about
|
|
13
13
|
// `engines`, so an unsupported Node installs fine and then breaks somewhere unrelated.
|
|
14
|
-
import { requireSupportedNode } from "./node-version.mjs";
|
|
14
|
+
import { localModeNodeProblem, requireSupportedNode } from "./node-version.mjs";
|
|
15
15
|
// The brand accent, shared with the REPL — see accent.mjs.
|
|
16
16
|
import { accent } from "./accent.mjs";
|
|
17
17
|
import { paint } from "./theme.mjs";
|
|
@@ -1068,13 +1068,16 @@ if (args[0] === "fix-path") {
|
|
|
1068
1068
|
console.log(" If `gu` is not found in PowerShell: gu-cli fix-powershell");
|
|
1069
1069
|
process.exit(0);
|
|
1070
1070
|
}
|
|
1071
|
-
const {
|
|
1071
|
+
const { fixPathPrefix } = await import("./install-path.mjs");
|
|
1072
1072
|
const { fileURLToPath } = await import("node:url");
|
|
1073
1073
|
const { dirname, join } = await import("node:path");
|
|
1074
1074
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
1075
|
-
|
|
1075
|
+
// #94: under `npx @tiens.nguyen/gu-cli fix-path` this file runs from npx's cache; fixPathPrefix
|
|
1076
|
+
// then falls back to npm's global prefix when gu is installed there.
|
|
1077
|
+
const prefix = fixPathPrefix({ here });
|
|
1076
1078
|
if (!prefix) {
|
|
1077
|
-
console.log(`
|
|
1079
|
+
console.log(` gu is not installed globally with npm (${here}) — there is no PATH to fix.`);
|
|
1080
|
+
console.log(" Install it with: npm install -g @tiens.nguyen/gu-cli");
|
|
1078
1081
|
process.exit(0);
|
|
1079
1082
|
}
|
|
1080
1083
|
const binDir = join(prefix, "bin");
|
|
@@ -1089,9 +1092,24 @@ if (args[0] === "fix-path") {
|
|
|
1089
1092
|
env: { ...env, npm_config_prefix: prefix, npm_config_global: "true" },
|
|
1090
1093
|
});
|
|
1091
1094
|
const said = `${r.stdout ?? ""}${r.stderr ?? ""}`.trim();
|
|
1092
|
-
// It prints only when it wrote something. Silence means the line is already in the
|
|
1093
|
-
//
|
|
1094
|
-
|
|
1095
|
+
// It prints only when it wrote something. Silence USUALLY means the line is already in the
|
|
1096
|
+
// profile — but check (#94): it is also silent when it skipped (e.g. a temp-dir prefix), and
|
|
1097
|
+
// saying "already adds" then is a lie the user acts on.
|
|
1098
|
+
if (said) {
|
|
1099
|
+
console.log(said);
|
|
1100
|
+
} else {
|
|
1101
|
+
const { existsSync, readFileSync } = await import("node:fs");
|
|
1102
|
+
const { homedir } = await import("node:os");
|
|
1103
|
+
const has = [".zshrc", ".bashrc", ".bash_profile", ".profile"].some((f) => {
|
|
1104
|
+
const p = join(homedir(), f);
|
|
1105
|
+
try { return existsSync(p) && readFileSync(p, "utf8").includes("# >>> gu-cli PATH >>>"); } catch { return false; }
|
|
1106
|
+
});
|
|
1107
|
+
if (has) console.log(` Your shell profile already adds ${binDir} to PATH — open a new terminal.`);
|
|
1108
|
+
else {
|
|
1109
|
+
console.log(` ${binDir} is not on your PATH and was not added to a shell profile. Add this line to it:`);
|
|
1110
|
+
console.log(` export PATH="${binDir}:$PATH"`);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1095
1113
|
process.exit(r.status ?? 0);
|
|
1096
1114
|
}
|
|
1097
1115
|
|
|
@@ -1285,14 +1303,24 @@ if (args[0] === "clear" || args[0] === "reset") {
|
|
|
1285
1303
|
}
|
|
1286
1304
|
// The DATABASE first, while ~/.gonext/api still exists — dropping it needs the mongodb
|
|
1287
1305
|
// driver that ships inside the downloaded API, and clearState is about to delete that folder.
|
|
1288
|
-
|
|
1306
|
+
// GitHub #125: the API's mongodb 7 driver needs Node 20.19+. On Node 18 it died with a bare
|
|
1307
|
+
// "crypto is not defined"; say what is wrong and what to do instead.
|
|
1308
|
+
const dropNodeProblem = groups.includes("work") && dbItem ? localModeNodeProblem() : "";
|
|
1309
|
+
if (dropNodeProblem) {
|
|
1310
|
+
console.log(
|
|
1311
|
+
"could not drop the local database — " + dropNodeProblem.split("\n")[0] +
|
|
1312
|
+
"\n Upgrade Node and run `gu clear` again to drop it; the next setup may inherit its settings."
|
|
1313
|
+
);
|
|
1314
|
+
} else if (groups.includes("work") && dbItem) {
|
|
1315
|
+
// GitHub #126: the client is closed even when connect/drop throws — an open client keeps
|
|
1316
|
+
// its sockets and timers alive.
|
|
1317
|
+
let client = null;
|
|
1289
1318
|
try {
|
|
1290
1319
|
const ce = await import("./client-expert-api.mjs");
|
|
1291
1320
|
const { MongoClient } = ce.loadMongoDriver(apiDirForDrop);
|
|
1292
|
-
|
|
1321
|
+
client = new MongoClient(ce.localMongoUri(), { serverSelectionTimeoutMS: 8000 });
|
|
1293
1322
|
await client.connect();
|
|
1294
1323
|
await client.db(ce.LOCAL_DB_NAME).dropDatabase();
|
|
1295
|
-
await client.close();
|
|
1296
1324
|
console.log("dropped the local database");
|
|
1297
1325
|
} catch (e) {
|
|
1298
1326
|
// Said out loud, not swallowed: a machine that keeps its old settings row will look
|
|
@@ -1300,10 +1328,18 @@ if (args[0] === "clear" || args[0] === "reset") {
|
|
|
1300
1328
|
console.log(
|
|
1301
1329
|
`could not drop the local database (${e.message}) — the next setup may inherit its settings`
|
|
1302
1330
|
);
|
|
1331
|
+
} finally {
|
|
1332
|
+
if (client) {await client.close(true).catch(() => {});}
|
|
1303
1333
|
}
|
|
1304
1334
|
}
|
|
1305
|
-
|
|
1306
|
-
|
|
1335
|
+
// GitHub #126: whatever clearState does, gu clear ends here and exits.
|
|
1336
|
+
try {
|
|
1337
|
+
const removed = await cs.clearState(items);
|
|
1338
|
+
console.log(`removed ${removed.length} item(s).`);
|
|
1339
|
+
} catch (e) {
|
|
1340
|
+
console.log(`could not remove everything (${e.message})`);
|
|
1341
|
+
process.exit(1);
|
|
1342
|
+
}
|
|
1307
1343
|
console.log(
|
|
1308
1344
|
groups.includes("identity")
|
|
1309
1345
|
? "\nNext `gu` starts from the beginning — it will ask which mode to run.\n"
|
package/gu-repl.mjs
CHANGED
|
@@ -676,6 +676,8 @@ let clientExpertRanThisProcess = false;
|
|
|
676
676
|
// Declared HERE, above the first-run block: that block runs at module top level, before any
|
|
677
677
|
// declaration further down exists.
|
|
678
678
|
let openPresetsAtStart = false;
|
|
679
|
+
// #132 follow-up: a hosted provider picked in first-run setup; its key and model are asked once the terminal exists.
|
|
680
|
+
let addProviderAtStart = null;
|
|
679
681
|
|
|
680
682
|
if (!workerKey || !apiBase) {
|
|
681
683
|
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
@@ -800,6 +802,7 @@ if (!workerKey || !apiBase) {
|
|
|
800
802
|
const sp = setupPrompts(rl0, { style: accent });
|
|
801
803
|
const setup = await runClientExpertSetup({ ask: ask0, askStep: sp.askStep, askSecret: sp.askSecret });
|
|
802
804
|
if (setup.loadPreset) openPresetsAtStart = true;
|
|
805
|
+
if (setup.addProvider) addProviderAtStart = setup.addProvider;
|
|
803
806
|
// STOP when setup cannot go further.
|
|
804
807
|
//
|
|
805
808
|
// This used to fall through to the hosted sign-in, on the theory that "this machine
|
|
@@ -5294,7 +5297,8 @@ async function markInterrupted(cwd, history, task, how) {
|
|
|
5294
5297
|
const status = interruptStatus(how);
|
|
5295
5298
|
if (!status) return;
|
|
5296
5299
|
last.status = status;
|
|
5297
|
-
|
|
5300
|
+
const keep = sessionUnfinishedTask || task;
|
|
5301
|
+
await saveSession(cwd, history, { unfinishedTask: isResumeWord(keep) ? "" : keep }); // #201
|
|
5298
5302
|
}
|
|
5299
5303
|
|
|
5300
5304
|
async function rememberUnfinishedTask(cwd, task) {
|
|
@@ -6028,8 +6032,8 @@ async function reconfigureOllamaServer() {
|
|
|
6028
6032
|
// with a single row is a question with one answer.
|
|
6029
6033
|
let chat = "";
|
|
6030
6034
|
if (agent) {
|
|
6031
|
-
|
|
6032
|
-
const agentDefault = os.
|
|
6035
|
+
// Task #36: the saved agent stays a choice (and the default) even when it is not a gemma4.
|
|
6036
|
+
const { cands, current: agentDefault } = os.agentChoices(
|
|
6033
6037
|
agent.models,
|
|
6034
6038
|
String(settings.agentModelName ?? "").trim()
|
|
6035
6039
|
);
|
|
@@ -6239,7 +6243,9 @@ async function reconfigureOllamaServer() {
|
|
|
6239
6243
|
// qwen3-embedding-only box) was written to settings above, so REQUIRED_EMBED_MODEL would
|
|
6240
6244
|
// falsely scream "changed" while the seeded value is exactly what settings now hold.
|
|
6241
6245
|
const seededEmbed = agent ? (os.defaultEmbedModel?.(agent.models) ?? os.REQUIRED_EMBED_MODEL) : "";
|
|
6242
|
-
|
|
6246
|
+
// Only a CHANGE when there was an embedder before — from none it is simply set (task #36).
|
|
6247
|
+
const prevEmbed = String(settings.ragEmbedModel ?? "").trim();
|
|
6248
|
+
if (agent && prevEmbed && prevEmbed !== seededEmbed) {
|
|
6243
6249
|
console.log(dim(" the embedder changed — existing RAG indexes need rebuilding (/rag)."));
|
|
6244
6250
|
}
|
|
6245
6251
|
console.log(dim(" takes effect on your next question.\n"));
|
|
@@ -6524,8 +6530,24 @@ async function changeProviderModel(ps, rows, p) {
|
|
|
6524
6530
|
/** Point a provider at a different URL, keeping its id so saved picks still resolve. */
|
|
6525
6531
|
async function changeProviderUrl(ps, rows, p) {
|
|
6526
6532
|
const md = await import("./model-doctor.mjs");
|
|
6527
|
-
|
|
6528
|
-
|
|
6533
|
+
// THE SAME PRESET LIST AS ADDING (#132), OpenAI-compatible ones only: an Ollama row has another
|
|
6534
|
+
// kind, and this flow keeps the row's id and kind. "type a URL…" is the default row, so Enter
|
|
6535
|
+
// still gets the old free-text question.
|
|
6536
|
+
const urlPresets = ps.providerPresets("coding").filter((x) => (x.dialect ?? "openai") === "openai");
|
|
6537
|
+
const urlRows = [...urlPresets.map((x) => `${x.name}${dim(` · ${x.url}`)}`), "type a URL…"];
|
|
6538
|
+
console.log("\n" + GUTTER + dim(`Which provider? (${PICK_HINT})`));
|
|
6539
|
+
const upi = await pickFromList(urlRows, urlRows.length - 1);
|
|
6540
|
+
if (upi < 0) {
|
|
6541
|
+
console.log(GUTTER + dim("(nothing changed)\n"));
|
|
6542
|
+
return;
|
|
6543
|
+
}
|
|
6544
|
+
let typed;
|
|
6545
|
+
if (upi < urlPresets.length) {
|
|
6546
|
+
typed = urlPresets[upi].url;
|
|
6547
|
+
} else {
|
|
6548
|
+
console.log(GUTTER + dim(`Enter keeps ${p.url}`));
|
|
6549
|
+
typed = (await ask(" provider URL: ")).trim();
|
|
6550
|
+
}
|
|
6529
6551
|
if (!typed || typed === p.url) {
|
|
6530
6552
|
console.log(GUTTER + dim("(nothing changed)\n"));
|
|
6531
6553
|
return;
|
|
@@ -6679,13 +6701,14 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
6679
6701
|
// without Claude, which serves no embeddings).
|
|
6680
6702
|
const presets = ps.providerPresets(role);
|
|
6681
6703
|
const rowsShown = [...presets.map((p) => `${p.name}${dim(` · ${p.url}`)}`), "another URL…"];
|
|
6704
|
+
// A preset names its own dialect (Ollama cloud is "ollama"); the rest are OpenAI-compatible.
|
|
6682
6705
|
console.log(dim(` Which provider? (${PICK_HINT})`));
|
|
6683
6706
|
const pi = await pickFromList(rowsShown, 0);
|
|
6684
6707
|
if (pi < 0) {
|
|
6685
6708
|
console.log(dim(" (nothing saved)\n"));
|
|
6686
6709
|
return;
|
|
6687
6710
|
}
|
|
6688
|
-
if (pi < presets.length) target = as.serverTarget("openai", presets[pi].url);
|
|
6711
|
+
if (pi < presets.length) target = as.serverTarget(presets[pi].dialect ?? "openai", presets[pi].url);
|
|
6689
6712
|
}
|
|
6690
6713
|
if (!target && !presetUrl) {
|
|
6691
6714
|
const dflt = as.defaultServerUrl(role, dialect, settings, existing);
|
|
@@ -6861,7 +6884,8 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
6861
6884
|
rowId = ps.mintProviderId(existing.map((b) => String(b.id ?? "")));
|
|
6862
6885
|
const { row } = as.rowForSave(role, { id: rowId, kind: target.dialect === "ollama" ? "ollama" : "openai", url: target.url, model, extras });
|
|
6863
6886
|
rows = [...existing, row];
|
|
6864
|
-
} else {
|
|
6887
|
+
} else if (found) {
|
|
6888
|
+
// A keyless agent/embed server with no row needs no row: the role's own setting holds its URL.
|
|
6865
6889
|
const r = as.rowForSave(role, { existing: found.row, model });
|
|
6866
6890
|
if (r.full) {
|
|
6867
6891
|
console.log(yellow(` ${host} already offers ${ps.MAX_MODELS_PER_SERVER} models — remove one in /model → manage providers first.\n`));
|
|
@@ -7102,6 +7126,14 @@ async function chooseModel() {
|
|
|
7102
7126
|
const chosen = choices[idx];
|
|
7103
7127
|
// Empty override means "use the account default" — so don't send an override when the
|
|
7104
7128
|
// user picks the default (keeps the payload clean and lets a later default change win).
|
|
7129
|
+
// GitHub #130: what to go back to if the key prompt for the new coder is cancelled (Esc).
|
|
7130
|
+
const prevCoder = {
|
|
7131
|
+
model: sessionCodingModel,
|
|
7132
|
+
label: sessionCodingLabel,
|
|
7133
|
+
url: sessionCodingUrl,
|
|
7134
|
+
where: activeCoderWhere,
|
|
7135
|
+
tail: sessionApiKeyTail,
|
|
7136
|
+
};
|
|
7105
7137
|
sessionCodingModel = chosen.id === def ? "" : chosen.id;
|
|
7106
7138
|
setModelFallback(null); // #343: the warning named the coder that was just replaced
|
|
7107
7139
|
// Remember the friendly name for the token footer, which should never show a raw
|
|
@@ -7143,7 +7175,23 @@ async function chooseModel() {
|
|
|
7143
7175
|
// exactly this key (provider-scoped), so mirror it here; a keyless coder shows no tail.
|
|
7144
7176
|
sessionApiKeyTail = ks.needsKey && !ks.missing && ks.stored ? ks.stored.slice(-4) : "";
|
|
7145
7177
|
if (!ks.needsKey) keyChoicePending = false; // Ollama or local MLX — nothing to choose
|
|
7146
|
-
if (ks.needsKey && ks.missing)
|
|
7178
|
+
if (ks.needsKey && ks.missing) {
|
|
7179
|
+
try {
|
|
7180
|
+
await promptForCodingKey(sessionCodingModel);
|
|
7181
|
+
} catch (e) {
|
|
7182
|
+
if (!wasPromptCancelled(e)) throw e;
|
|
7183
|
+
// GitHub #130: Esc at the new coder's key prompt cancels the PICK, not just the key —
|
|
7184
|
+
// otherwise gu is left on a coder it cannot use. Restore and re-save the previous one.
|
|
7185
|
+
sessionCodingModel = prevCoder.model;
|
|
7186
|
+
sessionCodingLabel = prevCoder.label;
|
|
7187
|
+
sessionCodingUrl = prevCoder.url;
|
|
7188
|
+
activeCoderWhere = prevCoder.where;
|
|
7189
|
+
sessionApiKeyTail = prevCoder.tail;
|
|
7190
|
+
await saveSessionCoding(resolve(process.cwd()));
|
|
7191
|
+
console.log(dim(" (cancelled — the coding model was not changed)\n"));
|
|
7192
|
+
return;
|
|
7193
|
+
}
|
|
7194
|
+
}
|
|
7147
7195
|
else if (ks.needsKey) {
|
|
7148
7196
|
// THIS FOLDER'S KEY, not the account's (task #222). ks.stored comes from GET /coding-key
|
|
7149
7197
|
// with no job named, which answers with the account default — so after picking "accounting"
|
|
@@ -10210,6 +10258,16 @@ let forgetActiveStatus = null;
|
|
|
10210
10258
|
let toggleActiveThought = null;
|
|
10211
10259
|
/** #324: the screen under the Yes/No picker when it opened — the list picker's bookkeeping (#321). */
|
|
10212
10260
|
let approvalOpenedOver = null;
|
|
10261
|
+
// #135: deadline (epoch ms) of the auto-"No" for the Yes/No prompt and for a timed list picker, 0 = none.
|
|
10262
|
+
// The remaining time is drawn next to the option that will be chosen, and ticks once a second.
|
|
10263
|
+
let approvalDeadline = 0;
|
|
10264
|
+
let listDeadline = 0;
|
|
10265
|
+
let listTimeoutIndex = -1;
|
|
10266
|
+
/** "2:49" above a minute, "45s" below; never negative. */
|
|
10267
|
+
function countdownLabel(msLeft) {
|
|
10268
|
+
const s = Math.max(0, Math.ceil(msLeft / 1000));
|
|
10269
|
+
return s >= 60 ? Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0") : s + "s";
|
|
10270
|
+
}
|
|
10213
10271
|
function drawApprovalOptions(redraw) {
|
|
10214
10272
|
// Two option lines, redrawn in place. On redraw the cursor sits one row below "No";
|
|
10215
10273
|
// step up 2 rows, clear+rewrite both, landing back where we started.
|
|
@@ -10218,16 +10276,19 @@ function drawApprovalOptions(redraw) {
|
|
|
10218
10276
|
// Both rows are a handful of columns wide, so they survive any resize on ONE physical row
|
|
10219
10277
|
// each and the \x1b[2A count stays exact — which is why a resize can simply redraw here.
|
|
10220
10278
|
const yes = approvalSel === 0 ? accent("▸ Yes") : dim(" Yes");
|
|
10221
|
-
const
|
|
10279
|
+
const tail = approvalDeadline ? " (auto-No in " + countdownLabel(approvalDeadline - Date.now()) + ")" : "";
|
|
10280
|
+
const no = approvalSel === 1 ? accent("▸ No" + tail) : dim(" No" + tail);
|
|
10281
|
+
const noRow = clipVisible(GUTTER + no, Math.max(20, process.stdout.columns || 80) - 1);
|
|
10222
10282
|
if (!redraw && barPinned() && !scrolledBack()) {
|
|
10223
10283
|
const content = displayRows(transcript.lines(), Math.max(20, process.stdout.columns || 80) - 1).length;
|
|
10224
10284
|
approvalOpenedOver = { content, pad: currentPad(content), height: viewportHeight() };
|
|
10225
10285
|
}
|
|
10226
|
-
regionUi((redraw ? "\x1b[2A" : "") + "\x1b[K" + GUTTER + yes + "\n\x1b[K" +
|
|
10286
|
+
regionUi((redraw ? "\x1b[2A" : "") + "\x1b[K" + GUTTER + yes + "\n\x1b[K" + noRow + "\n");
|
|
10227
10287
|
}
|
|
10228
|
-
function finishApproval(allow) {
|
|
10288
|
+
function finishApproval(allow, timedOut = false) {
|
|
10229
10289
|
if (!approvalActive) return;
|
|
10230
10290
|
approvalActive = false;
|
|
10291
|
+
approvalDeadline = 0;
|
|
10231
10292
|
// #324: on a full screen the two option rows SCROLLED the conversation up by two, and the one
|
|
10232
10293
|
// result line below takes only one of them back — the content now ends a row above the region's
|
|
10233
10294
|
// bottom. Unrecorded, every row put back from the transcript afterwards (the slash menu, a
|
|
@@ -10238,7 +10299,7 @@ function finishApproval(allow) {
|
|
|
10238
10299
|
setPad(blockClosedPad({ contentRows: over.content, pad: over.pad, height: over.height, blockRows: 2 }), over.content);
|
|
10239
10300
|
}
|
|
10240
10301
|
// Settle the picker: overwrite the two option lines with a single result line.
|
|
10241
|
-
process.stdout.write("\x1b[2A\x1b[J" + GUTTER + (allow ? accent("▸ Yes — running it") : red("▸ No — skipped")) + "\n");
|
|
10302
|
+
process.stdout.write("\x1b[2A\x1b[J" + GUTTER + (allow ? accent("▸ Yes — running it") : red(timedOut ? "▸ No — no answer in time, skipped" : "▸ No — skipped")) + "\n");
|
|
10242
10303
|
const r = approvalResolve;
|
|
10243
10304
|
approvalResolve = null;
|
|
10244
10305
|
if (r) r(!!allow);
|
|
@@ -10395,11 +10456,17 @@ async function approvalPrompt(command) {
|
|
|
10395
10456
|
approvalSel = 0;
|
|
10396
10457
|
approvalResolve = resolve;
|
|
10397
10458
|
approvalActive = true;
|
|
10459
|
+
approvalDeadline = Date.now() + 170000;
|
|
10398
10460
|
drawApprovalOptions(false);
|
|
10399
|
-
const t = setTimeout(() => finishApproval(false), 170000);
|
|
10461
|
+
const t = setTimeout(() => finishApproval(false, true), 170000);
|
|
10462
|
+
// #135: redraw the two option rows once a second so the countdown on "No" ticks down.
|
|
10463
|
+
const tick = setInterval(() => {
|
|
10464
|
+
if (approvalActive) drawApprovalOptions(true);
|
|
10465
|
+
}, 1000);
|
|
10400
10466
|
const orig = resolve;
|
|
10401
10467
|
approvalResolve = (v) => {
|
|
10402
10468
|
clearTimeout(t);
|
|
10469
|
+
clearInterval(tick);
|
|
10403
10470
|
orig(v);
|
|
10404
10471
|
};
|
|
10405
10472
|
});
|
|
@@ -10464,7 +10531,8 @@ function drawList(redraw) {
|
|
|
10464
10531
|
// and "this is where you are" — and collapsing them into one marker is what makes a
|
|
10465
10532
|
// multi-select feel like it forgot what you chose.
|
|
10466
10533
|
const box = listMulti ? (listChecked.has(i) ? "[x] " : "[ ] ") : "";
|
|
10467
|
-
const
|
|
10534
|
+
const left = listDeadline && i === listTimeoutIndex ? " (auto in " + countdownLabel(listDeadline - Date.now()) + ")" : "";
|
|
10535
|
+
const body = box + listItems[i] + left;
|
|
10468
10536
|
return clipVisible(GUTTER + (i === listSel ? accent("▸ " + body) : dim(" " + body)), width - 1);
|
|
10469
10537
|
};
|
|
10470
10538
|
const marker = (text) => clipVisible(GUTTER + dim(text), width - 1);
|
|
@@ -10653,6 +10721,12 @@ function pickFromList(items, initialSel = 0, opts = {}) {
|
|
|
10653
10721
|
listSel = Math.max(0, Math.min(initialSel, items.length - 1));
|
|
10654
10722
|
listResolve = resolve;
|
|
10655
10723
|
listPickerActive = true;
|
|
10724
|
+
listDeadline = 0;
|
|
10725
|
+
listTimeoutIndex = -1;
|
|
10726
|
+
if (opts && opts.timeoutMs > 0 && (opts.timeoutIndex ?? -1) >= 0) {
|
|
10727
|
+
listDeadline = Date.now() + opts.timeoutMs;
|
|
10728
|
+
listTimeoutIndex = opts.timeoutIndex;
|
|
10729
|
+
}
|
|
10656
10730
|
drawList(false);
|
|
10657
10731
|
// Optional auto-resolve after opts.timeoutMs (task #81 time-budget prompt): if the
|
|
10658
10732
|
// user doesn't choose in time, finish with opts.timeoutIndex (default -1). Wrap
|
|
@@ -10662,9 +10736,18 @@ function pickFromList(items, initialSel = 0, opts = {}) {
|
|
|
10662
10736
|
() => finishList(opts.timeoutIndex ?? -1),
|
|
10663
10737
|
opts.timeoutMs
|
|
10664
10738
|
);
|
|
10739
|
+
// #135: tick the countdown on the row that will be auto-chosen.
|
|
10740
|
+
const tick = listDeadline
|
|
10741
|
+
? setInterval(() => {
|
|
10742
|
+
if (listPickerActive) drawList(true);
|
|
10743
|
+
}, 1000)
|
|
10744
|
+
: null;
|
|
10665
10745
|
const orig = listResolve;
|
|
10666
10746
|
listResolve = (v) => {
|
|
10667
10747
|
clearTimeout(to);
|
|
10748
|
+
if (tick) clearInterval(tick);
|
|
10749
|
+
listDeadline = 0;
|
|
10750
|
+
listTimeoutIndex = -1;
|
|
10668
10751
|
orig(v);
|
|
10669
10752
|
};
|
|
10670
10753
|
}
|
|
@@ -10971,6 +11054,8 @@ const answerFrom = (s) =>
|
|
|
10971
11054
|
|
|
10972
11055
|
// Word-wrap a plain (ANSI-free) string into at most `maxLines` lines of width ≤ `width`,
|
|
10973
11056
|
// appending an ellipsis to the last line when text was dropped (task #96 expand view).
|
|
11057
|
+
const THOUGHT_PREVIEW_LINES = 3; // #134: dim lines always shown under the cut purple thought
|
|
11058
|
+
const THOUGHT_OPEN_LINES = 8; // …and after a click / Ctrl+T
|
|
10974
11059
|
const wrapThought = (s, width, maxLines) => {
|
|
10975
11060
|
const words = String(s || "").split(/\s+/).filter(Boolean);
|
|
10976
11061
|
const lines = [];
|
|
@@ -10992,7 +11077,13 @@ const wrapThought = (s, width, maxLines) => {
|
|
|
10992
11077
|
if (truncated && lines.length) {
|
|
10993
11078
|
const last = lines[maxLines - 1] ?? lines[lines.length - 1];
|
|
10994
11079
|
const room = Math.max(1, width - 1);
|
|
10995
|
-
|
|
11080
|
+
let kept = last;
|
|
11081
|
+
if (last.length > room) {
|
|
11082
|
+
// Cut at a word boundary (#134) so the "…" never lands inside a half word like "lim…".
|
|
11083
|
+
const sp = last.lastIndexOf(" ", room);
|
|
11084
|
+
kept = sp >= Math.floor(room * 0.5) ? last.slice(0, sp) : last.slice(0, room);
|
|
11085
|
+
}
|
|
11086
|
+
lines[lines.length - 1] = kept.replace(/\s+$/, "") + "…";
|
|
10996
11087
|
}
|
|
10997
11088
|
return lines;
|
|
10998
11089
|
};
|
|
@@ -11674,12 +11765,9 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11674
11765
|
// to reveal). Line 1 (what's happening now): a live command > the model's current Thought >
|
|
11675
11766
|
// "almost done" > "Thinking". When clickable we drive line 1 from the SAME fuller string, so
|
|
11676
11767
|
// the grey expansion CONTINUES where line 1 was cut off instead of repeating the head.
|
|
11677
|
-
const
|
|
11678
|
-
// #350: the click and Ctrl+T ask the SAME question the highlight does. They used to accept
|
|
11679
|
-
// any thought at all, so a line that was already whole took the click and redrew for nothing.
|
|
11680
|
-
liveExpandable = clickable;
|
|
11768
|
+
const split = !!fullLiveThought && !runningCmd && fullLiveThought.length > max;
|
|
11681
11769
|
let label, rest;
|
|
11682
|
-
if (
|
|
11770
|
+
if (split) {
|
|
11683
11771
|
// Split at a WORD boundary near the line-1 width so nothing is cut mid-word: the last
|
|
11684
11772
|
// space at/before max-1 (unless that snaps back too far into the line — then hard-cut).
|
|
11685
11773
|
// Line 1 shows the head; the grey expansion CONTINUES from the same point (no dup, no
|
|
@@ -11699,7 +11787,15 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11699
11787
|
}
|
|
11700
11788
|
// When clicked, unfold the remainder as up to 4 wrapped GREY lines BETWEEN the thought and
|
|
11701
11789
|
// the playful-word line.
|
|
11702
|
-
|
|
11790
|
+
// #134: the rest of a cut thought is ALWAYS visible as THOUGHT_PREVIEW_LINES dim lines under
|
|
11791
|
+
// the purple one (ending in "…" when more was dropped); a click / Ctrl+T opens it up to
|
|
11792
|
+
// THOUGHT_OPEN_LINES. #350: the click and Ctrl+T ask the SAME question the highlight does —
|
|
11793
|
+
// they only count when opening actually shows more rows than the preview already does.
|
|
11794
|
+
const previewLines = rest ? wrapThought(rest, max, THOUGHT_PREVIEW_LINES) : [];
|
|
11795
|
+
const openLines = rest ? wrapThought(rest, max, THOUGHT_OPEN_LINES) : [];
|
|
11796
|
+
const clickable = split && previewLines.join("\n") !== openLines.join("\n");
|
|
11797
|
+
liveExpandable = clickable;
|
|
11798
|
+
const expLines = thoughtExpanded ? openLines : previewLines;
|
|
11703
11799
|
// WHAT IS RUNNING, under the label (task #177). Capped at two rows and clipped, because
|
|
11704
11800
|
// this block is redrawn by walking the caret over PHYSICAL rows — a wrapped row here is
|
|
11705
11801
|
// the #109/#138 failure mode.
|
|
@@ -12806,6 +12902,9 @@ async function main() {
|
|
|
12806
12902
|
if (chosen === "client-expert") {
|
|
12807
12903
|
const { runClientExpertSetup } = await import("./client-expert-run.mjs");
|
|
12808
12904
|
const setup = await runClientExpertSetup({ ask });
|
|
12905
|
+
// GitHub #126: setup refused (too-old Node) and said why — stop here, do not carry on
|
|
12906
|
+
// into a REPL that cannot work and leave gu running after its last line.
|
|
12907
|
+
if (setup?.stoppedAt === "node") process.exit(1);
|
|
12809
12908
|
if (setup?.complete) reloadWorkerEnv();
|
|
12810
12909
|
}
|
|
12811
12910
|
} else if (!st.chosenMode && !gate.available) {
|
|
@@ -12829,8 +12928,10 @@ async function main() {
|
|
|
12829
12928
|
askStep: (q) => ask(q).catch(toSkipAll),
|
|
12830
12929
|
askSecret: (q) => askSecret(q).catch(toSkipAll),
|
|
12831
12930
|
});
|
|
12931
|
+
if (setup?.stoppedAt === "node") process.exit(1); // GitHub #126, as above
|
|
12832
12932
|
if (setup?.complete) reloadWorkerEnv();
|
|
12833
12933
|
if (setup?.loadPreset) openPresetsAtStart = true;
|
|
12934
|
+
if (setup?.addProvider) addProviderAtStart = setup.addProvider;
|
|
12834
12935
|
}
|
|
12835
12936
|
} catch {
|
|
12836
12937
|
/* never block the REPL on the mode question — the banner still shows what is configured */
|
|
@@ -13654,7 +13755,7 @@ async function main() {
|
|
|
13654
13755
|
// rl.prompt() still runs for the NON-frozen fallback path, where readline draws the
|
|
13655
13756
|
// input line itself. With the zone frozen (#141) discardTypedInput has already
|
|
13656
13757
|
// repainted it, and prompt()'s own _refreshLine lands on the same paintBar().
|
|
13657
|
-
rl.prompt();
|
|
13758
|
+
if (!_stdinClosed) rl.prompt(); // ERR_USE_AFTER_CLOSE: a menu may have closed readline
|
|
13658
13759
|
}
|
|
13659
13760
|
};
|
|
13660
13761
|
process.on("SIGINT", onInterrupt);
|
|
@@ -13685,6 +13786,17 @@ async function main() {
|
|
|
13685
13786
|
console.log(GUTTER + dim(`(could not open the presets: ${e?.message ?? e} — run /use-presets)`));
|
|
13686
13787
|
}
|
|
13687
13788
|
}
|
|
13789
|
+
// #132 follow-up: the provider chosen in first-run setup — the URL (or the preset list when none was
|
|
13790
|
+
// chosen), then the key, then the model, all by the same flow as "add another provider".
|
|
13791
|
+
if (addProviderAtStart) {
|
|
13792
|
+
const pick = addProviderAtStart;
|
|
13793
|
+
addProviderAtStart = null;
|
|
13794
|
+
try {
|
|
13795
|
+
await addServer({ role: "coding", dialect: pick.dialect || "openai", ...(pick.url ? { url: pick.url } : {}) });
|
|
13796
|
+
} catch (e) {
|
|
13797
|
+
console.log(GUTTER + dim(`(could not add the provider: ${e?.message ?? e} — run /model → add another provider)`));
|
|
13798
|
+
}
|
|
13799
|
+
}
|
|
13688
13800
|
|
|
13689
13801
|
for (;;) {
|
|
13690
13802
|
// #307: a held question whose turn ended without printing anything (and was not a quick
|
|
@@ -13701,6 +13813,9 @@ async function main() {
|
|
|
13701
13813
|
enableClickToPosition();
|
|
13702
13814
|
announceCliUpdate(); // deferred if a 6h refresh landed mid-turn; harmless once shown
|
|
13703
13815
|
announceScaffold(); // the startup scaffold line, if it arrived while a turn was running
|
|
13816
|
+
// ERR_USE_AFTER_CLOSE (task #36): Escape/EOF inside a menu can close readline; prompt() on a
|
|
13817
|
+
// closed interface throws from here with a Node stack trace. Input is over — leave the loop.
|
|
13818
|
+
if (_stdinClosed) break;
|
|
13704
13819
|
rl.prompt();
|
|
13705
13820
|
// #302, found live: prompt() puts the cursor at column 0, and a stopped turn's question is
|
|
13706
13821
|
// already in the line — so what was typed went IN FRONT of it and Ctrl+U erased nothing.
|
|
@@ -13724,6 +13839,10 @@ async function main() {
|
|
|
13724
13839
|
// prompt() erases from the caret down, so the row is rewritten in place and the empty
|
|
13725
13840
|
// submit becomes a visual no-op. Nothing to step back over if the line came from
|
|
13726
13841
|
// _lineQueue (no keypress, no echo) — hence the `echoed` guard.
|
|
13842
|
+
if (_stdinClosed) {
|
|
13843
|
+
lineRaw = null; // readline closed while we waited — never prompt() it again
|
|
13844
|
+
break;
|
|
13845
|
+
}
|
|
13727
13846
|
if (process.stdout.isTTY) {
|
|
13728
13847
|
// Frozen input line: nothing was echoed, so there is nothing to undo — just re-render.
|
|
13729
13848
|
if (barPinned()) {
|
|
@@ -14174,19 +14293,17 @@ async function main() {
|
|
|
14174
14293
|
// actually succeeds — saveSession writes the now-empty value then — so a terminal killed
|
|
14175
14294
|
// mid-retry still has something to resume next time.
|
|
14176
14295
|
sessionUnfinishedTask = "";
|
|
14177
|
-
const
|
|
14296
|
+
const resumeResult = resumeTask(line, pendingResume);
|
|
14297
|
+
const { resumed, nothingToResume } = resumeResult;
|
|
14298
|
+
// Nothing pending: the bare word goes to the coding model AS TYPED, with the conversation
|
|
14299
|
+
// history, so the model decides what "continue" means from context (no hardcoded reply).
|
|
14300
|
+
// The #201 loop stays closed at the WRITE side: rememberUnfinishedTask, saveSession and
|
|
14301
|
+
// markInterrupted never store a resume word, and unfinishedTaskFromSession rejects one.
|
|
14302
|
+
const taskLine = nothingToResume ? String(line ?? "").trim() : resumeResult.task;
|
|
14178
14303
|
// #302 part 3: "continue" after a lost connection FOLLOWS the job that may still be running,
|
|
14179
14304
|
// rather than sending the question again. Anything else typed lets it go.
|
|
14180
14305
|
const reattach = lostTurn && isResumeWord(line) && lostTurn.taskLine === taskLine ? lostTurn : null;
|
|
14181
14306
|
lostTurn = null;
|
|
14182
|
-
// "continue" with nothing behind it runs NOTHING (task #201). It used to be sent to the
|
|
14183
|
-
// model as the literal word, which then got remembered as the unfinished task — so every
|
|
14184
|
-
// later "continue" retried "continue". Worse than the loop: with no task the model invents
|
|
14185
|
-
// one, and the reported run edited a file and ran migrations nobody asked for.
|
|
14186
|
-
if (nothingToResume) {
|
|
14187
|
-
console.log(GUTTER + dim("nothing to continue — ask a question first.\n"));
|
|
14188
|
-
continue;
|
|
14189
|
-
}
|
|
14190
14307
|
if (resumed) {
|
|
14191
14308
|
const shown = taskLine.length > 80 ? taskLine.slice(0, 80) + "…" : taskLine;
|
|
14192
14309
|
console.log(GUTTER + dim(`↻ retrying: ${shown}\n`));
|
package/install-path.mjs
CHANGED
|
@@ -56,7 +56,7 @@ if (isEntryPoint) {
|
|
|
56
56
|
main();
|
|
57
57
|
} catch (e) {
|
|
58
58
|
// A postinstall that throws uninstalls the package. Report, swallow, succeed.
|
|
59
|
-
console.log(`gu-cli: PATH setup skipped (${e.message}) — run \`gu-cli fix-path\` if \`gu\` is not found.`);
|
|
59
|
+
console.log(`gu-cli: PATH setup skipped (${e.message}) — run \`npx @tiens.nguyen/gu-cli fix-path\` if \`gu\` is not found.`);
|
|
60
60
|
}
|
|
61
61
|
process.exit(0);
|
|
62
62
|
}
|
|
@@ -83,6 +83,27 @@ export function globalPrefixOf(packageDir) {
|
|
|
83
83
|
return prefix.join("/") || "/";
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The global prefix `gu-cli fix-path` should repair — or "" (issue #94).
|
|
88
|
+
*
|
|
89
|
+
* WHY A FALLBACK: when npm stops running the postinstall, `gu-cli` is not on PATH either, so the
|
|
90
|
+
* way out is `npx @tiens.nguyen/gu-cli fix-path`. npx runs a COPY from ~/.npm/_npx/<hash>/, which
|
|
91
|
+
* is not a global install, so globalPrefixOf(here) is "". npx (and npm exec) still pass npm's
|
|
92
|
+
* global prefix in the env; use it only when gu really is installed there.
|
|
93
|
+
* Lookup order: where this file lives → npm_config_global_prefix → npm_config_prefix.
|
|
94
|
+
*/
|
|
95
|
+
export function fixPathPrefix({ here = "", env = process.env, exists = existsSync } = {}) {
|
|
96
|
+
const own = globalPrefixOf(here);
|
|
97
|
+
if (own) return own;
|
|
98
|
+
for (const p of [env.npm_config_global_prefix, env.npm_config_prefix]) {
|
|
99
|
+
if (!p) continue;
|
|
100
|
+
const prefix = resolve(String(p));
|
|
101
|
+
if (prefix.split("/").includes("node_modules")) continue; // a project, not global
|
|
102
|
+
if (exists(join(prefix, "lib", "node_modules", "@tiens.nguyen", "gu-cli"))) return prefix;
|
|
103
|
+
}
|
|
104
|
+
return "";
|
|
105
|
+
}
|
|
106
|
+
|
|
86
107
|
function main() {
|
|
87
108
|
if (platform() === "win32") return; // registry PATH + PowerShell alias: not here
|
|
88
109
|
if (process.env.CI) return; // no user shell to help in CI
|
package/model-setup-ui.mjs
CHANGED
|
@@ -103,6 +103,34 @@ export function modelSetupMenu({ presets = 0 } = {}) {
|
|
|
103
103
|
];
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The provider question inside "Guided" (#132 follow-up): Ollama stays the Enter default, then every
|
|
108
|
+
* preset (OpenAI, Claude, Kimi, Qwen...), then "another URL". `presets` = providerPresets().
|
|
109
|
+
*/
|
|
110
|
+
export function providerChoiceMenu(presets = []) {
|
|
111
|
+
return [
|
|
112
|
+
"Which provider do you want to use?",
|
|
113
|
+
" 1 Ollama server — this machine or your own (Enter)",
|
|
114
|
+
...presets.map((p, i) => ` ${i + 2} ${p.name} · ${p.url}`),
|
|
115
|
+
` ${presets.length + 2} Another provider — type its URL`,
|
|
116
|
+
];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** An answer to providerChoiceMenu → {kind:"ollama"|"preset"|"url"|"skip", preset?}, or null to ask again. */
|
|
120
|
+
export function parseProviderChoice(raw, presets = []) {
|
|
121
|
+
if (raw === SKIP_ALL) return { kind: "skip" };
|
|
122
|
+
const t = String(raw ?? "").trim().toLowerCase();
|
|
123
|
+
if (t === "" || t === "1" || t === "ollama") return { kind: "ollama" };
|
|
124
|
+
if (t === "s" || t === "skip" || t === "n" || t === "no" || t === "-") return { kind: "skip" };
|
|
125
|
+
if (t === "u" || t === "url") return { kind: "url" };
|
|
126
|
+
if (/^\d+$/.test(t)) {
|
|
127
|
+
const n = Number(t);
|
|
128
|
+
if (n >= 2 && n <= presets.length + 1) return { kind: "preset", preset: presets[n - 2] };
|
|
129
|
+
if (n === presets.length + 2) return { kind: "url" };
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
106
134
|
/** An answer to the up-front question → "guided" | "preset" | "skip", or null to ask again. */
|
|
107
135
|
export function parseModelSetupChoice(raw) {
|
|
108
136
|
if (raw === SKIP_ALL) return "skip";
|
package/ollama-setup.mjs
CHANGED
|
@@ -93,6 +93,22 @@ export function defaultAgentModel(models, preferred = "") {
|
|
|
93
93
|
)[0].model;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* The AGENT choices /model offers (task #36): the gemma4 family, PLUS the agent already saved on
|
|
98
|
+
* this machine when this box has it. /agent-model may save any model (qwen3:14b, say); leaving it out
|
|
99
|
+
* here made the gemma4 default the preselected row, so Enter in /model silently replaced it.
|
|
100
|
+
* Returns { cands, current } — current is the row to preselect.
|
|
101
|
+
*/
|
|
102
|
+
export function agentChoices(models, saved = "") {
|
|
103
|
+
const list = Array.isArray(models) ? models : [];
|
|
104
|
+
const cands = agentCandidates(list);
|
|
105
|
+
const name = String(saved ?? "").trim();
|
|
106
|
+
const own = name && list.find((m) => m.model === name && !isLikelyEmbedder(m));
|
|
107
|
+
if (own && !cands.some((m) => m.model === name)) cands.unshift(own);
|
|
108
|
+
const current = own ? name : defaultAgentModel(list, name);
|
|
109
|
+
return { cands, current };
|
|
110
|
+
}
|
|
111
|
+
|
|
96
112
|
/** "nomic-embed-text" and "nomic-embed-text:latest" are the same model to a person. */
|
|
97
113
|
const sameModel = (a, b) => {
|
|
98
114
|
const bare = (s) => String(s ?? "").trim().toLowerCase().replace(/:latest$/, "");
|
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.817",
|
|
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/proxy-dispatcher.mjs
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* when shouldProxy() says so.
|
|
15
15
|
*/
|
|
16
16
|
import { shouldProxy, proxyUrl, redactSecrets } from "./proxy-config.mjs";
|
|
17
|
+
import { nodeAtLeast } from "./node-version.mjs";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* One ProxyAgent per dial string, kept for reuse.
|
|
@@ -25,6 +26,9 @@ import { shouldProxy, proxyUrl, redactSecrets } from "./proxy-config.mjs";
|
|
|
25
26
|
*/
|
|
26
27
|
const agents = new Map();
|
|
27
28
|
|
|
29
|
+
/** undici 7's engines floor (GitHub #127). */
|
|
30
|
+
export const UNDICI_MIN_NODE = "20.18.1";
|
|
31
|
+
|
|
28
32
|
let ProxyAgentCtor;
|
|
29
33
|
let loadError = "";
|
|
30
34
|
|
|
@@ -38,6 +42,12 @@ let loadError = "";
|
|
|
38
42
|
*/
|
|
39
43
|
async function proxyAgentCtor() {
|
|
40
44
|
if (ProxyAgentCtor || loadError) return ProxyAgentCtor;
|
|
45
|
+
// GitHub #127: undici 7 needs Node 20.18.1+, while gu itself (Cloud mode) still runs on 18.
|
|
46
|
+
// Name the real cause instead of letting the import fail with an unrelated error.
|
|
47
|
+
if (!nodeAtLeast(process.version, UNDICI_MIN_NODE)) {
|
|
48
|
+
loadError = `the proxy needs Node ${UNDICI_MIN_NODE} or newer — this is ${process.version}`;
|
|
49
|
+
return ProxyAgentCtor;
|
|
50
|
+
}
|
|
41
51
|
try {
|
|
42
52
|
({ ProxyAgent: ProxyAgentCtor } = await import("undici"));
|
|
43
53
|
} catch (err) {
|
package/terminal-layout.mjs
CHANGED
|
@@ -1660,6 +1660,11 @@ export function classifyTurnFailure(message) {
|
|
|
1660
1660
|
if (/The agent couldn't start|The agent's prompt (could not be loaded|could not be used)|agent's prompt templates could not be loaded/i.test(t)) {
|
|
1661
1661
|
return "setup";
|
|
1662
1662
|
}
|
|
1663
|
+
// DEPS — the agent's Python framework is missing on this machine (GitHub #129). Every turn
|
|
1664
|
+
// dies at import, before any model is called; neither retrying nor switching coder helps.
|
|
1665
|
+
if (/No module named ['"]?(smolagents|openai)\b|ModuleNotFoundError:.*(smolagents|openai)/i.test(t)) {
|
|
1666
|
+
return "deps";
|
|
1667
|
+
}
|
|
1663
1668
|
// CONFIG — the server is up and answering; it just has no model by that name (task #213).
|
|
1664
1669
|
// Ollama says: model 'gemma4:e4b' not found (and "…, try pulling it first" for embeddings).
|
|
1665
1670
|
// Checked BEFORE the backend patterns because it arrives as a 404 from a perfectly healthy
|
|
@@ -1757,6 +1762,10 @@ export function retryAdvice({ resumable = true, failures = 1, kind = "task", min
|
|
|
1757
1762
|
? `gu's own machinery has failed ${failures} times in a row — not your question, and not the coding model. Check it before retrying: gu-cli api status, and gu-cli start if the worker is not running.`
|
|
1758
1763
|
: 'that was gu\'s own machinery stopping, not your question — "continue" runs it again';
|
|
1759
1764
|
}
|
|
1765
|
+
if (kind === "deps") {
|
|
1766
|
+
// GitHub #129: name the missing piece and the one step that fixes it.
|
|
1767
|
+
return 'the agent framework (smolagents + openai) is not installed in this machine\'s Python, so nothing ran — run /doctor to install it, or restart gu and answer yes to "Install the agent framework now?", then "continue" sends your question again.';
|
|
1768
|
+
}
|
|
1760
1769
|
if (kind === "setup") {
|
|
1761
1770
|
// Nothing ran, so there is nothing to diagnose in the model or the question — only the step the
|
|
1762
1771
|
// message above names. "continue" is right AFTER that step, never before.
|