@tiens.nguyen/gu-cli 1.0.813 → 1.0.816
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 +32 -12
- package/gonext_agent_chat.py +118 -4
- package/gu-cli.mjs +60 -12
- package/gu-repl.mjs +110 -17
- package/install-path.mjs +22 -1
- package/ollama-setup.mjs +16 -0
- package/package.json +1 -1
- package/proxy-dispatcher.mjs +10 -0
- package/terminal-layout.mjs +58 -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
|
@@ -312,8 +312,8 @@ export async function ensureNativeMongoMac(ask, mongo, { log = (l) => console.lo
|
|
|
312
312
|
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
313
|
for (const s of steps) log(` brew ${s.join(" ")}`);
|
|
314
314
|
log("");
|
|
315
|
-
const yes = await ask
|
|
316
|
-
if (
|
|
315
|
+
const yes = await readYesNo(ask, installing ? " Install MongoDB now? [Y/n]: " : " Start MongoDB now? [Y/n]: ", true, log);
|
|
316
|
+
if (!yes) {
|
|
317
317
|
log(dim(`\n Not ${installing ? "installed" : "started"} — run the commands above yourself.`));
|
|
318
318
|
return { ok: false, explained: true };
|
|
319
319
|
}
|
|
@@ -440,8 +440,8 @@ export async function ensureNativeMongo(ask, mongo, {
|
|
|
440
440
|
const needs = platform === "win32"
|
|
441
441
|
? "Windows may ask for administrator permission"
|
|
442
442
|
: isRoot ? "" : "sudo will ask for your password";
|
|
443
|
-
const yes = await ask
|
|
444
|
-
if (
|
|
443
|
+
const yes = await readYesNo(ask, ` ${installing ? "Install" : "Start"} MongoDB now?${needs ? ` (${needs})` : ""} [Y/n]: `, true, log);
|
|
444
|
+
if (!yes) {
|
|
445
445
|
log(dim(`\n Not ${installing ? "installed" : "started"} — run the commands above yourself.`));
|
|
446
446
|
return { ok: false, explained: true };
|
|
447
447
|
}
|
|
@@ -608,8 +608,7 @@ export async function startMongoInDocker(ask, { platform = process.platform } =
|
|
|
608
608
|
console.log(dim(` Simplest path: ${plan.summary}.`));
|
|
609
609
|
console.log(dim(" Your conversation history lives in that volume and survives a restart."));
|
|
610
610
|
console.log(dim("\n " + plan.commands[0] + "\n"));
|
|
611
|
-
|
|
612
|
-
if (String(yes ?? "").trim().toLowerCase().startsWith("n")) return false;
|
|
611
|
+
if (!(await readYesNo(ask, " Start MongoDB in Docker now? [Y/n]: ", true))) return false;
|
|
613
612
|
|
|
614
613
|
for (const cmd of plan.commands) {
|
|
615
614
|
console.log(dim(` $ ${cmd}`));
|
|
@@ -672,13 +671,36 @@ export async function checkMongoConnection(uri) {
|
|
|
672
671
|
* Returns { uri, custom } to continue with, or { exit: true } when the user declines — the
|
|
673
672
|
* caller ends setup with the `gu clear` → `gu` hint.
|
|
674
673
|
*/
|
|
674
|
+
/**
|
|
675
|
+
* A [Y/n] / [y/N] answer (GitHub #128). Enter → the default; y/yes → yes; n/no → no. Anything else
|
|
676
|
+
* ("3", "maybe") is NOT taken as an answer: the question is asked again with what to type —
|
|
677
|
+
* "3" at "Install MongoDB now? [Y/n]" used to install MongoDB. A closed input (null) is "no", as
|
|
678
|
+
* it always was. After three unrecognised answers the default is used.
|
|
679
|
+
*/
|
|
680
|
+
export function parseYesNo(raw, defaultYes) {
|
|
681
|
+
if (raw === null || raw === undefined) return false;
|
|
682
|
+
const a = String(raw).trim().toLowerCase();
|
|
683
|
+
if (!a) return defaultYes;
|
|
684
|
+
if (a === "y" || a === "yes") return true;
|
|
685
|
+
if (a === "n" || a === "no") return false;
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
export async function readYesNo(ask, prompt, defaultYes, say = (l) => console.log(l)) {
|
|
690
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
691
|
+
const verdict = parseYesNo(await ask(prompt), defaultYes);
|
|
692
|
+
if (verdict !== null) return verdict;
|
|
693
|
+
say(dim(` Type y or n (Enter = ${defaultYes ? "yes" : "no"}).`));
|
|
694
|
+
}
|
|
695
|
+
return defaultYes;
|
|
696
|
+
}
|
|
697
|
+
|
|
675
698
|
export async function askMongoCredentials(ask, askSecret = null) {
|
|
676
699
|
const { host, port } = mongoHostPort(LOCAL_MONGO_URI);
|
|
677
700
|
console.log(dim(` MongoDB is answering at ${host}:${port}.`));
|
|
678
701
|
console.log(dim(" If that is a MongoDB this machine already had, it may have its OWN"));
|
|
679
702
|
console.log(dim(" username and password — which setup does not know, and cannot change."));
|
|
680
|
-
|
|
681
|
-
if (!String(yes ?? "").trim().toLowerCase().startsWith("n")) {
|
|
703
|
+
if (await readYesNo(ask, " Use the default (empty username and password)? [Y/n]: ", true)) {
|
|
682
704
|
return { uri: LOCAL_MONGO_URI, custom: false };
|
|
683
705
|
}
|
|
684
706
|
// NO → the instance has its own credentials. Ask for them, and CHECK before any installing
|
|
@@ -700,15 +722,13 @@ export async function askMongoCredentials(ask, askSecret = null) {
|
|
|
700
722
|
}
|
|
701
723
|
if (verdict.unverifiable) {
|
|
702
724
|
console.log("\n " + yellow("⚠ could not verify: ") + dim(verdict.why));
|
|
703
|
-
|
|
704
|
-
if (!String(carryOn ?? "").trim().toLowerCase().startsWith("n")) {
|
|
725
|
+
if (await readYesNo(ask, " Continue with these credentials anyway? [Y/n]: ", true)) {
|
|
705
726
|
return { uri, custom: true, unverified: true };
|
|
706
727
|
}
|
|
707
728
|
} else {
|
|
708
729
|
console.log("\n " + red("✗ Check Connection failed") + dim(` — ${verdict.why}`));
|
|
709
730
|
}
|
|
710
|
-
|
|
711
|
-
if (!String(again ?? "").trim().toLowerCase().startsWith("y")) return { exit: true };
|
|
731
|
+
if (!(await readYesNo(ask, " Re-enter the username and password? [y/N]: ", false))) return { exit: true };
|
|
712
732
|
}
|
|
713
733
|
return { exit: true };
|
|
714
734
|
}
|
package/gonext_agent_chat.py
CHANGED
|
@@ -2628,6 +2628,10 @@ _RAG_STEP: dict = {"n": 0}
|
|
|
2628
2628
|
# "unavailable" — RAG isn't configured at all
|
|
2629
2629
|
# A metric that cannot tell "it wasn't needed" from "it couldn't have worked" answers nothing.
|
|
2630
2630
|
_RAG_STATE: dict = {"s": "unknown"}
|
|
2631
|
+
# Issue #120: mid-turn auto-compaction, per turn. n = passes that actually saved something,
|
|
2632
|
+
# tok = the prompt size (tokens) that triggered the latest one. Rides on the prompt breakdown
|
|
2633
|
+
# so the REPL can show a "compacting context" note on the bottom bar.
|
|
2634
|
+
_COMPACT_STATE: dict = {"n": 0, "tok": 0}
|
|
2631
2635
|
# Is this turn's index on LOCAL disk? Module-level because _rag_known_sources() reads it from
|
|
2632
2636
|
# outside run_agent_chat — and it had no module-level definition at all, so that function raised
|
|
2633
2637
|
# NameError on EVERY call, in every mode. Found by testing the three storage modes (#180).
|
|
@@ -2979,6 +2983,9 @@ def _prompt_breakdown(messages, total_tokens: int, step: int = 0, ledger=None) -
|
|
|
2979
2983
|
"grepCalls": int(_SEARCH_TALLY.get("grep") or 0),
|
|
2980
2984
|
"grepHits": int(_SEARCH_TALLY.get("grep_hits") or 0),
|
|
2981
2985
|
"readCalls": int(_SEARCH_TALLY.get("read") or 0),
|
|
2986
|
+
# Issue #120: how often this turn auto-compacted, and at what size (tokens).
|
|
2987
|
+
"compacted": int(_COMPACT_STATE.get("n") or 0),
|
|
2988
|
+
"compactedAt": int(_COMPACT_STATE.get("tok") or 0),
|
|
2982
2989
|
}
|
|
2983
2990
|
|
|
2984
2991
|
|
|
@@ -4575,6 +4582,99 @@ def _strip_tool_tags(text: str) -> str:
|
|
|
4575
4582
|
return "\n".join(out).strip()
|
|
4576
4583
|
|
|
4577
4584
|
|
|
4585
|
+
def _md_scalar(v) -> str:
|
|
4586
|
+
if v is None or v == "" or v == [] or v == {}:
|
|
4587
|
+
return "none"
|
|
4588
|
+
if isinstance(v, bool):
|
|
4589
|
+
return "yes" if v else "no"
|
|
4590
|
+
return str(v)
|
|
4591
|
+
|
|
4592
|
+
|
|
4593
|
+
def _md_lines(value, depth=0):
|
|
4594
|
+
"""Nested dict/list -> markdown bullet lines (no raw braces, quotes or brackets)."""
|
|
4595
|
+
pad = " " * depth
|
|
4596
|
+
lines = []
|
|
4597
|
+
if isinstance(value, dict):
|
|
4598
|
+
for k, v in value.items():
|
|
4599
|
+
label = str(k).replace("_", " ").strip()
|
|
4600
|
+
label = label[:1].upper() + label[1:]
|
|
4601
|
+
if isinstance(v, (dict, list, tuple)) and v:
|
|
4602
|
+
lines.append(f"{pad}- **{label}:**")
|
|
4603
|
+
lines.extend(_md_lines(v, depth + 1))
|
|
4604
|
+
else:
|
|
4605
|
+
lines.append(f"{pad}- **{label}:** {_md_scalar(v)}")
|
|
4606
|
+
else:
|
|
4607
|
+
for item in value:
|
|
4608
|
+
if isinstance(item, (dict, list, tuple)) and item:
|
|
4609
|
+
lines.extend(_md_lines(item, depth + 1))
|
|
4610
|
+
else:
|
|
4611
|
+
lines.append(f"{pad}- {_md_scalar(item)}")
|
|
4612
|
+
return lines
|
|
4613
|
+
|
|
4614
|
+
|
|
4615
|
+
def _fallback_recap(steps, last_text="") -> str:
|
|
4616
|
+
"""Task #131: the model ended its turn with no final text (e.g. only reasoning). Build a
|
|
4617
|
+
short readable recap from the steps that did run instead of showing '[No result]'."""
|
|
4618
|
+
tried = []
|
|
4619
|
+
for st in (steps or [])[-8:]:
|
|
4620
|
+
did = str(st.get("did") or "").strip().replace(chr(10), " ")
|
|
4621
|
+
m = re.search(r"([a-z_]+\([^\n]{0,70})", did)
|
|
4622
|
+
label = (m.group(1) if m else did)[:70].strip()
|
|
4623
|
+
if label:
|
|
4624
|
+
tried.append("- " + label)
|
|
4625
|
+
last = str(last_text or "").strip()
|
|
4626
|
+
if not tried and not last:
|
|
4627
|
+
return ""
|
|
4628
|
+
out = ["**Status:** the model finished without writing a summary."]
|
|
4629
|
+
if tried:
|
|
4630
|
+
out += ["", "**What was run**"] + tried
|
|
4631
|
+
if last:
|
|
4632
|
+
out += ["", "**Last result**", last[:600]]
|
|
4633
|
+
return chr(10).join(out)
|
|
4634
|
+
|
|
4635
|
+
|
|
4636
|
+
def _humanize_answer(result) -> str:
|
|
4637
|
+
"""Task #131: a final answer that is a dict/list (or a string that is just a JSON /
|
|
4638
|
+
Python-dict literal, as DeepSeek produces) is rendered as readable markdown instead of
|
|
4639
|
+
being shown as its raw repr. Plain prose is returned untouched."""
|
|
4640
|
+
if result is None:
|
|
4641
|
+
return ""
|
|
4642
|
+
data = result
|
|
4643
|
+
if isinstance(result, str):
|
|
4644
|
+
s = result.strip()
|
|
4645
|
+
if len(s) < 2 or s[0] not in "{[" or s[-1] not in "}]":
|
|
4646
|
+
return result
|
|
4647
|
+
data = None
|
|
4648
|
+
try:
|
|
4649
|
+
data = json.loads(s)
|
|
4650
|
+
except Exception: # noqa: BLE001
|
|
4651
|
+
try:
|
|
4652
|
+
import ast
|
|
4653
|
+
data = ast.literal_eval(s)
|
|
4654
|
+
except Exception: # noqa: BLE001
|
|
4655
|
+
return result
|
|
4656
|
+
if not isinstance(data, (dict, list)) or not data:
|
|
4657
|
+
return result
|
|
4658
|
+
elif isinstance(result, tuple):
|
|
4659
|
+
data = list(result)
|
|
4660
|
+
elif not isinstance(result, (dict, list)):
|
|
4661
|
+
return str(result)
|
|
4662
|
+
if isinstance(data, dict):
|
|
4663
|
+
out = []
|
|
4664
|
+
for k, v in data.items():
|
|
4665
|
+
label = str(k).replace("_", " ").strip()
|
|
4666
|
+
label = label[:1].upper() + label[1:]
|
|
4667
|
+
if isinstance(v, (dict, list, tuple)) and v:
|
|
4668
|
+
out.append("")
|
|
4669
|
+
out.append(f"**{label}**")
|
|
4670
|
+
out.extend(_md_lines(v, 0))
|
|
4671
|
+
else:
|
|
4672
|
+
out.append("")
|
|
4673
|
+
out.append(f"**{label}:** {_md_scalar(v)}")
|
|
4674
|
+
return chr(10).join(out).strip()
|
|
4675
|
+
return chr(10).join(_md_lines(data, 0)).strip()
|
|
4676
|
+
|
|
4677
|
+
|
|
4578
4678
|
_THINK_BLOCK = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
|
|
4579
4679
|
|
|
4580
4680
|
# Malformed variants of smolagents' code-block tags. Weak coding models routinely
|
|
@@ -8319,6 +8419,8 @@ def run_agent_chat(cfg):
|
|
|
8319
8419
|
# credit RAG for tokens it never cost, and a wrong number is worse than none.
|
|
8320
8420
|
_rag_ledger_reset()
|
|
8321
8421
|
_RAG_STEP["n"] = 0
|
|
8422
|
+
_COMPACT_STATE["n"] = 0
|
|
8423
|
+
_COMPACT_STATE["tok"] = 0
|
|
8322
8424
|
_overload_notified["yes"] = False
|
|
8323
8425
|
try:
|
|
8324
8426
|
from smolagents import CodeAgent, OpenAIServerModel, tool
|
|
@@ -9529,7 +9631,8 @@ def run_agent_chat(cfg):
|
|
|
9529
9631
|
"instead of stopping the block. Keep UNRELATED changes in separate blocks. Take a separate "
|
|
9530
9632
|
"step only when a call depends on a result you have not seen yet (e.g. web_search to "
|
|
9531
9633
|
"find a URL, THEN fetch_url to read it). You SEE the Observation before the next step. "
|
|
9532
|
-
"When you have enough to answer, call final_answer(<your answer>) in a code block
|
|
9634
|
+
"When you have enough to answer, call final_answer(<your answer>) in a code block — pass a readable "
|
|
9635
|
+
"markdown/prose recap (what you did, what you verified, what is left), never a dict, list or JSON. "
|
|
9533
9636
|
"Prefer FEWER steps, and never repeat a tool call that already succeeded.\n\n"
|
|
9534
9637
|
"{{TOOL_LIST}}\n" # filled in below, once from the REAL registered tool objects
|
|
9535
9638
|
+ _skills_mod.prompt_block(_skills)
|
|
@@ -10615,8 +10718,11 @@ def run_agent_chat(cfg):
|
|
|
10615
10718
|
# Yes/No before deep-compacting (the auto-trim above is always-on; THIS is
|
|
10616
10719
|
# the extra, opt-in aggressive pass). Only in the terminal (interactive) —
|
|
10617
10720
|
# web has no picker, so it just keeps the auto-trim.
|
|
10618
|
-
|
|
10619
|
-
|
|
10721
|
+
# Issue #120: this used to be gated on an interactive terminal AND read
|
|
10722
|
+
# _compact_state["decision"], a key removed with the old Yes/No prompt — the
|
|
10723
|
+
# KeyError was swallowed below as "memory-trim skip", so mid-turn compaction
|
|
10724
|
+
# NEVER ran and the context grew past the model's window. Always on now.
|
|
10725
|
+
if _steps:
|
|
10620
10726
|
# Size of the last request in prompt tokens (exact if the server
|
|
10621
10727
|
# reported it, else ~chars/4 of the current step memory).
|
|
10622
10728
|
_tu = getattr(step_log, "token_usage", None)
|
|
@@ -10652,6 +10758,12 @@ def run_agent_chat(cfg):
|
|
|
10652
10758
|
f"{_VERBOSE_PREFIX}Context reached ~{_in_tok // 1000}k tokens — trimming older "
|
|
10653
10759
|
"steps to stay fast (files are re-readable)")})
|
|
10654
10760
|
if _cs:
|
|
10761
|
+
_COMPACT_STATE["n"] += 1
|
|
10762
|
+
_COMPACT_STATE["tok"] = int(_in_tok)
|
|
10763
|
+
# Issue #120: tell the terminal NOW (not on the next request's
|
|
10764
|
+
# breakdown) so it can lock input and show the compacting bar.
|
|
10765
|
+
_emit({"type": "compact", "n": _COMPACT_STATE["n"],
|
|
10766
|
+
"tok": _COMPACT_STATE["tok"]})
|
|
10655
10767
|
_log(f"context compact: saved ~{_cs} chars "
|
|
10656
10768
|
f"(at ~{_in_tok} tok, threshold {_compact_at}, "
|
|
10657
10769
|
f"window {_ctx_window or 'unknown'})")
|
|
@@ -14209,7 +14321,9 @@ def run_agent_chat(cfg):
|
|
|
14209
14321
|
_emit({"type": "step", "text": _retr})
|
|
14210
14322
|
_log(f"retrieval summary: {_c(_retr)}")
|
|
14211
14323
|
_emit({"type": "step", "text": "Composing answer…"})
|
|
14212
|
-
final_text = _strip_tool_tags(
|
|
14324
|
+
final_text = (_strip_tool_tags(_humanize_answer(result).strip())
|
|
14325
|
+
or _fallback_recap(_turn_steps, _last_obs.get("text"))
|
|
14326
|
+
or "[No result]")
|
|
14213
14327
|
_log(f"done (deterministic, no summarizer call): {len(final_text)} chars")
|
|
14214
14328
|
_emit({"type": "final", "text": final_text})
|
|
14215
14329
|
# 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"
|
|
@@ -4394,6 +4430,18 @@ async function runAgentChatJob(job) {
|
|
|
4394
4430
|
}),
|
|
4395
4431
|
}).catch(() => {});
|
|
4396
4432
|
}
|
|
4433
|
+
} else if (event.type === "compact" && Number.isFinite(event.n)) {
|
|
4434
|
+
// Issue #120: a mid-turn compaction just landed — put it on the job straight away so
|
|
4435
|
+
// the REPL locks input and shows its progress bar now, not at the next request.
|
|
4436
|
+
latestPromptBreakdown = {
|
|
4437
|
+
...(latestPromptBreakdown || {}),
|
|
4438
|
+
compacted: event.n,
|
|
4439
|
+
...(Number.isFinite(event.tok) ? { compactedAt: event.tok } : {}),
|
|
4440
|
+
};
|
|
4441
|
+
workerFetch(`/api/worker/jobs/${jobId}`, {
|
|
4442
|
+
method: "PATCH",
|
|
4443
|
+
body: JSON.stringify({ agentPromptBreakdown: latestPromptBreakdown }),
|
|
4444
|
+
}).catch(() => {});
|
|
4397
4445
|
} else if (event.type === "code_request") {
|
|
4398
4446
|
// Task #155: the row is opened BEFORE the request goes out, so a step that is
|
|
4399
4447
|
// cancelled, hangs, or is refused still leaves a readable record of exactly what was
|
package/gu-repl.mjs
CHANGED
|
@@ -62,6 +62,8 @@ import {
|
|
|
62
62
|
thoughtBlockRows,
|
|
63
63
|
ragSegmentLabel,
|
|
64
64
|
ragBarNote,
|
|
65
|
+
compactBarNote,
|
|
66
|
+
compactProgressNote,
|
|
65
67
|
searchSegmentLabel,
|
|
66
68
|
runningDetailRows,
|
|
67
69
|
formatThinkTime,
|
|
@@ -549,6 +551,8 @@ const brightText = (s) => s; // default terminal colour (un-dimmed) — the "pop
|
|
|
549
551
|
// forward reference here is a startup crash, not a lint warning.
|
|
550
552
|
const BULLET_STYLE = {
|
|
551
553
|
fail: { mark: CROSS, markColor: red, textColor: brightText },
|
|
554
|
+
// A look-up (grep/pgrep/rg…) that found nothing: not an error — a dim grey ✗, dim text.
|
|
555
|
+
soft: { mark: CROSS, markColor: dim, textColor: dim },
|
|
552
556
|
ok: { mark: CHECK, markColor: accent, textColor: dim },
|
|
553
557
|
info: { mark: BULLET, markColor: dim, textColor: dim },
|
|
554
558
|
};
|
|
@@ -2726,6 +2730,39 @@ const ragLineText = () => {
|
|
|
2726
2730
|
// that counts rows with ESC[nA / ESC[nB (the #109 failure mode). Called fresh on every draw,
|
|
2727
2731
|
// so a resize simply re-lays it out (task #138); the RAG row is verbose-only, which is why the
|
|
2728
2732
|
// row COUNT is taken from this array rather than hard-coded anywhere.
|
|
2733
|
+
// Issue #120: while a mid-turn compaction runs, EVERYTHING is locked — keys are swallowed, the
|
|
2734
|
+
// turn's output is held back — and only the progress bar on the bottom bar moves. The trim
|
|
2735
|
+
// itself takes milliseconds, so the lock lasts a short fixed window the user can actually see.
|
|
2736
|
+
// Ctrl+C (SIGINT) still cancels the turn: a lock must never trap the user.
|
|
2737
|
+
const COMPACT_LOCK_MS = 1500;
|
|
2738
|
+
let compactLock = null; // { startedAt, until } while locked
|
|
2739
|
+
let compactSeenPasses = 0; // compaction passes already shown this turn
|
|
2740
|
+
let compactLockTimer = null;
|
|
2741
|
+
const compactLocked = () => !!compactLock && Date.now() < compactLock.until;
|
|
2742
|
+
function startCompactLock() {
|
|
2743
|
+
const now = Date.now();
|
|
2744
|
+
compactLock = { startedAt: now, until: now + COMPACT_LOCK_MS };
|
|
2745
|
+
if (compactLockTimer) clearInterval(compactLockTimer);
|
|
2746
|
+
compactLockTimer = setInterval(() => {
|
|
2747
|
+
if (!compactLocked()) endCompactLock();
|
|
2748
|
+
else if (barPinned()) paintBar();
|
|
2749
|
+
}, 80);
|
|
2750
|
+
if (barPinned()) paintBar();
|
|
2751
|
+
}
|
|
2752
|
+
function endCompactLock() {
|
|
2753
|
+
if (compactLockTimer) clearInterval(compactLockTimer);
|
|
2754
|
+
compactLockTimer = null;
|
|
2755
|
+
const was = !!compactLock;
|
|
2756
|
+
compactLock = null;
|
|
2757
|
+
if (was && barPinned()) paintBar();
|
|
2758
|
+
}
|
|
2759
|
+
const compactLockNote = () =>
|
|
2760
|
+
compactLock
|
|
2761
|
+
? compactProgressNote({
|
|
2762
|
+
frac: (Date.now() - compactLock.startedAt) / COMPACT_LOCK_MS,
|
|
2763
|
+
at: lastPromptBreakdown?.compactedAt,
|
|
2764
|
+
})
|
|
2765
|
+
: "";
|
|
2729
2766
|
const footerRows = () =>
|
|
2730
2767
|
buildFooterRows({
|
|
2731
2768
|
width: process.stdout.columns || 80,
|
|
@@ -2736,7 +2773,8 @@ const footerRows = () =>
|
|
|
2736
2773
|
mode: modeLineText(),
|
|
2737
2774
|
// Task #34: "rag: embedder down → grep fallback" as a soft yellow note beside the mode, not an
|
|
2738
2775
|
// alarming red segment on the token row. yellow goes through paint(), so NO_COLOR turns it off.
|
|
2739
|
-
|
|
2776
|
+
// Issue #120: an auto-compaction this turn outranks the rag note (same soft yellow slot).
|
|
2777
|
+
modeNote: compactLockNote() || compactBarNote(lastPromptBreakdown) || ragBarNote(lastPromptBreakdown),
|
|
2740
2778
|
notePaint: yellow,
|
|
2741
2779
|
// How to paste here (issue #67): the platform's text key, plus gu's image key when the coder
|
|
2742
2780
|
// can take an image.
|
|
@@ -4358,6 +4396,13 @@ if (process.stdin.isTTY) {
|
|
|
4358
4396
|
// re-rendered the slash overlay (the line still starts with "/" from before the paste) and
|
|
4359
4397
|
// re-walked the paste store. Same for a mouse report's characters, which are not keys.
|
|
4360
4398
|
if (pasteBuffer !== null || inMouseKeyWindow()) return;
|
|
4399
|
+
// Issue #120: compacting → every key is locked (Ctrl+C still arrives as SIGINT).
|
|
4400
|
+
if (compactLocked()) {
|
|
4401
|
+
rl.line = "";
|
|
4402
|
+
rl.cursor = 0;
|
|
4403
|
+
rl.historyIndex = -1;
|
|
4404
|
+
return;
|
|
4405
|
+
}
|
|
4361
4406
|
// #247: the restored question was erased (Backspace, Ctrl+U, a picker taking the line) — it
|
|
4362
4407
|
// is let go, exactly as with Ctrl+C, so the next message does not REPLACE it in history.
|
|
4363
4408
|
if (prefillFor && (listPickerActive || restoredQuestionLetGo(rl.line, key))) prefillFor = null;
|
|
@@ -5249,7 +5294,8 @@ async function markInterrupted(cwd, history, task, how) {
|
|
|
5249
5294
|
const status = interruptStatus(how);
|
|
5250
5295
|
if (!status) return;
|
|
5251
5296
|
last.status = status;
|
|
5252
|
-
|
|
5297
|
+
const keep = sessionUnfinishedTask || task;
|
|
5298
|
+
await saveSession(cwd, history, { unfinishedTask: isResumeWord(keep) ? "" : keep }); // #201
|
|
5253
5299
|
}
|
|
5254
5300
|
|
|
5255
5301
|
async function rememberUnfinishedTask(cwd, task) {
|
|
@@ -5983,8 +6029,8 @@ async function reconfigureOllamaServer() {
|
|
|
5983
6029
|
// with a single row is a question with one answer.
|
|
5984
6030
|
let chat = "";
|
|
5985
6031
|
if (agent) {
|
|
5986
|
-
|
|
5987
|
-
const agentDefault = os.
|
|
6032
|
+
// Task #36: the saved agent stays a choice (and the default) even when it is not a gemma4.
|
|
6033
|
+
const { cands, current: agentDefault } = os.agentChoices(
|
|
5988
6034
|
agent.models,
|
|
5989
6035
|
String(settings.agentModelName ?? "").trim()
|
|
5990
6036
|
);
|
|
@@ -6194,7 +6240,9 @@ async function reconfigureOllamaServer() {
|
|
|
6194
6240
|
// qwen3-embedding-only box) was written to settings above, so REQUIRED_EMBED_MODEL would
|
|
6195
6241
|
// falsely scream "changed" while the seeded value is exactly what settings now hold.
|
|
6196
6242
|
const seededEmbed = agent ? (os.defaultEmbedModel?.(agent.models) ?? os.REQUIRED_EMBED_MODEL) : "";
|
|
6197
|
-
|
|
6243
|
+
// Only a CHANGE when there was an embedder before — from none it is simply set (task #36).
|
|
6244
|
+
const prevEmbed = String(settings.ragEmbedModel ?? "").trim();
|
|
6245
|
+
if (agent && prevEmbed && prevEmbed !== seededEmbed) {
|
|
6198
6246
|
console.log(dim(" the embedder changed — existing RAG indexes need rebuilding (/rag)."));
|
|
6199
6247
|
}
|
|
6200
6248
|
console.log(dim(" takes effect on your next question.\n"));
|
|
@@ -6816,7 +6864,8 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
6816
6864
|
rowId = ps.mintProviderId(existing.map((b) => String(b.id ?? "")));
|
|
6817
6865
|
const { row } = as.rowForSave(role, { id: rowId, kind: target.dialect === "ollama" ? "ollama" : "openai", url: target.url, model, extras });
|
|
6818
6866
|
rows = [...existing, row];
|
|
6819
|
-
} else {
|
|
6867
|
+
} else if (found) {
|
|
6868
|
+
// A keyless agent/embed server with no row needs no row: the role's own setting holds its URL.
|
|
6820
6869
|
const r = as.rowForSave(role, { existing: found.row, model });
|
|
6821
6870
|
if (r.full) {
|
|
6822
6871
|
console.log(yellow(` ${host} already offers ${ps.MAX_MODELS_PER_SERVER} models — remove one in /model → manage providers first.\n`));
|
|
@@ -7057,6 +7106,14 @@ async function chooseModel() {
|
|
|
7057
7106
|
const chosen = choices[idx];
|
|
7058
7107
|
// Empty override means "use the account default" — so don't send an override when the
|
|
7059
7108
|
// user picks the default (keeps the payload clean and lets a later default change win).
|
|
7109
|
+
// GitHub #130: what to go back to if the key prompt for the new coder is cancelled (Esc).
|
|
7110
|
+
const prevCoder = {
|
|
7111
|
+
model: sessionCodingModel,
|
|
7112
|
+
label: sessionCodingLabel,
|
|
7113
|
+
url: sessionCodingUrl,
|
|
7114
|
+
where: activeCoderWhere,
|
|
7115
|
+
tail: sessionApiKeyTail,
|
|
7116
|
+
};
|
|
7060
7117
|
sessionCodingModel = chosen.id === def ? "" : chosen.id;
|
|
7061
7118
|
setModelFallback(null); // #343: the warning named the coder that was just replaced
|
|
7062
7119
|
// Remember the friendly name for the token footer, which should never show a raw
|
|
@@ -7098,7 +7155,23 @@ async function chooseModel() {
|
|
|
7098
7155
|
// exactly this key (provider-scoped), so mirror it here; a keyless coder shows no tail.
|
|
7099
7156
|
sessionApiKeyTail = ks.needsKey && !ks.missing && ks.stored ? ks.stored.slice(-4) : "";
|
|
7100
7157
|
if (!ks.needsKey) keyChoicePending = false; // Ollama or local MLX — nothing to choose
|
|
7101
|
-
if (ks.needsKey && ks.missing)
|
|
7158
|
+
if (ks.needsKey && ks.missing) {
|
|
7159
|
+
try {
|
|
7160
|
+
await promptForCodingKey(sessionCodingModel);
|
|
7161
|
+
} catch (e) {
|
|
7162
|
+
if (!wasPromptCancelled(e)) throw e;
|
|
7163
|
+
// GitHub #130: Esc at the new coder's key prompt cancels the PICK, not just the key —
|
|
7164
|
+
// otherwise gu is left on a coder it cannot use. Restore and re-save the previous one.
|
|
7165
|
+
sessionCodingModel = prevCoder.model;
|
|
7166
|
+
sessionCodingLabel = prevCoder.label;
|
|
7167
|
+
sessionCodingUrl = prevCoder.url;
|
|
7168
|
+
activeCoderWhere = prevCoder.where;
|
|
7169
|
+
sessionApiKeyTail = prevCoder.tail;
|
|
7170
|
+
await saveSessionCoding(resolve(process.cwd()));
|
|
7171
|
+
console.log(dim(" (cancelled — the coding model was not changed)\n"));
|
|
7172
|
+
return;
|
|
7173
|
+
}
|
|
7174
|
+
}
|
|
7102
7175
|
else if (ks.needsKey) {
|
|
7103
7176
|
// THIS FOLDER'S KEY, not the account's (task #222). ks.stored comes from GET /coding-key
|
|
7104
7177
|
// with no job named, which answers with the account default — so after picking "accounting"
|
|
@@ -11299,6 +11372,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11299
11372
|
following = true;
|
|
11300
11373
|
followingAttached = attached;
|
|
11301
11374
|
typedDuringTurn = false;
|
|
11375
|
+
compactSeenPasses = 0; // issue #120: python counts passes per turn
|
|
11302
11376
|
followAborted = false;
|
|
11303
11377
|
currentJobId = jobId;
|
|
11304
11378
|
jobIdForApprovalRace = jobId; // so an approval picker can watch THIS job for early end (#110)
|
|
@@ -12369,6 +12443,12 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
12369
12443
|
if (job.agentPromptBreakdown && typeof job.agentPromptBreakdown === "object") {
|
|
12370
12444
|
lastPromptBreakdown = job.agentPromptBreakdown;
|
|
12371
12445
|
liveStepTokens = readStepTokens(job.agentPromptBreakdown);
|
|
12446
|
+
// Issue #120: a NEW compaction pass this turn → lock everything and show the bar.
|
|
12447
|
+
const compactPasses = Number.isFinite(job.agentPromptBreakdown.compacted) ? job.agentPromptBreakdown.compacted : 0;
|
|
12448
|
+
if (compactPasses > compactSeenPasses) {
|
|
12449
|
+
compactSeenPasses = compactPasses;
|
|
12450
|
+
startCompactLock();
|
|
12451
|
+
}
|
|
12372
12452
|
// Persisted per folder so the next `gu` here starts from the real picture instead
|
|
12373
12453
|
// of a zero that looks like a failure. Fire-and-forget: a bar value is never worth
|
|
12374
12454
|
// delaying a turn for, and losing one is harmless. Shown while attached (it is what
|
|
@@ -12385,6 +12465,9 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
12385
12465
|
// Mirror both into the bottom bar's token row so it counts UP live while the turn runs
|
|
12386
12466
|
// (the bar rides under the status block now), instead of sitting on the last turn's
|
|
12387
12467
|
// figures until this one finishes. The turn-end assignment writes the same numbers again.
|
|
12468
|
+
// Issue #120: hold the turn's output while compacting — only the bar moves. Ctrl+C ends it.
|
|
12469
|
+
while (compactLocked() && !cancelRequested && !followAborted) await sleep(80);
|
|
12470
|
+
if (compactLock && (cancelRequested || followAborted)) endCompactLock();
|
|
12388
12471
|
lastTurnInputTokens = latestCodeTokens;
|
|
12389
12472
|
lastTurnOutputTokens = latestOutputTokens;
|
|
12390
12473
|
lastTurnTotalOutput = globalOutputTokens + latestOutputTokens;
|
|
@@ -12621,6 +12704,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
12621
12704
|
following = false;
|
|
12622
12705
|
followingAttached = false;
|
|
12623
12706
|
typedDuringTurn = false;
|
|
12707
|
+
endCompactLock(); // issue #120: never leave the terminal locked after a turn
|
|
12624
12708
|
currentJobId = null;
|
|
12625
12709
|
// Drop anything typed (but not submitted) during the turn — echo was muted, so it's
|
|
12626
12710
|
// invisible; without this it would surface on the next prompt.
|
|
@@ -12750,6 +12834,9 @@ async function main() {
|
|
|
12750
12834
|
if (chosen === "client-expert") {
|
|
12751
12835
|
const { runClientExpertSetup } = await import("./client-expert-run.mjs");
|
|
12752
12836
|
const setup = await runClientExpertSetup({ ask });
|
|
12837
|
+
// GitHub #126: setup refused (too-old Node) and said why — stop here, do not carry on
|
|
12838
|
+
// into a REPL that cannot work and leave gu running after its last line.
|
|
12839
|
+
if (setup?.stoppedAt === "node") process.exit(1);
|
|
12753
12840
|
if (setup?.complete) reloadWorkerEnv();
|
|
12754
12841
|
}
|
|
12755
12842
|
} else if (!st.chosenMode && !gate.available) {
|
|
@@ -12773,6 +12860,7 @@ async function main() {
|
|
|
12773
12860
|
askStep: (q) => ask(q).catch(toSkipAll),
|
|
12774
12861
|
askSecret: (q) => askSecret(q).catch(toSkipAll),
|
|
12775
12862
|
});
|
|
12863
|
+
if (setup?.stoppedAt === "node") process.exit(1); // GitHub #126, as above
|
|
12776
12864
|
if (setup?.complete) reloadWorkerEnv();
|
|
12777
12865
|
if (setup?.loadPreset) openPresetsAtStart = true;
|
|
12778
12866
|
}
|
|
@@ -13598,7 +13686,7 @@ async function main() {
|
|
|
13598
13686
|
// rl.prompt() still runs for the NON-frozen fallback path, where readline draws the
|
|
13599
13687
|
// input line itself. With the zone frozen (#141) discardTypedInput has already
|
|
13600
13688
|
// repainted it, and prompt()'s own _refreshLine lands on the same paintBar().
|
|
13601
|
-
rl.prompt();
|
|
13689
|
+
if (!_stdinClosed) rl.prompt(); // ERR_USE_AFTER_CLOSE: a menu may have closed readline
|
|
13602
13690
|
}
|
|
13603
13691
|
};
|
|
13604
13692
|
process.on("SIGINT", onInterrupt);
|
|
@@ -13645,6 +13733,9 @@ async function main() {
|
|
|
13645
13733
|
enableClickToPosition();
|
|
13646
13734
|
announceCliUpdate(); // deferred if a 6h refresh landed mid-turn; harmless once shown
|
|
13647
13735
|
announceScaffold(); // the startup scaffold line, if it arrived while a turn was running
|
|
13736
|
+
// ERR_USE_AFTER_CLOSE (task #36): Escape/EOF inside a menu can close readline; prompt() on a
|
|
13737
|
+
// closed interface throws from here with a Node stack trace. Input is over — leave the loop.
|
|
13738
|
+
if (_stdinClosed) break;
|
|
13648
13739
|
rl.prompt();
|
|
13649
13740
|
// #302, found live: prompt() puts the cursor at column 0, and a stopped turn's question is
|
|
13650
13741
|
// already in the line — so what was typed went IN FRONT of it and Ctrl+U erased nothing.
|
|
@@ -13668,6 +13759,10 @@ async function main() {
|
|
|
13668
13759
|
// prompt() erases from the caret down, so the row is rewritten in place and the empty
|
|
13669
13760
|
// submit becomes a visual no-op. Nothing to step back over if the line came from
|
|
13670
13761
|
// _lineQueue (no keypress, no echo) — hence the `echoed` guard.
|
|
13762
|
+
if (_stdinClosed) {
|
|
13763
|
+
lineRaw = null; // readline closed while we waited — never prompt() it again
|
|
13764
|
+
break;
|
|
13765
|
+
}
|
|
13671
13766
|
if (process.stdout.isTTY) {
|
|
13672
13767
|
// Frozen input line: nothing was echoed, so there is nothing to undo — just re-render.
|
|
13673
13768
|
if (barPinned()) {
|
|
@@ -14118,19 +14213,17 @@ async function main() {
|
|
|
14118
14213
|
// actually succeeds — saveSession writes the now-empty value then — so a terminal killed
|
|
14119
14214
|
// mid-retry still has something to resume next time.
|
|
14120
14215
|
sessionUnfinishedTask = "";
|
|
14121
|
-
const
|
|
14216
|
+
const resumeResult = resumeTask(line, pendingResume);
|
|
14217
|
+
const { resumed, nothingToResume } = resumeResult;
|
|
14218
|
+
// Nothing pending: the bare word goes to the coding model AS TYPED, with the conversation
|
|
14219
|
+
// history, so the model decides what "continue" means from context (no hardcoded reply).
|
|
14220
|
+
// The #201 loop stays closed at the WRITE side: rememberUnfinishedTask, saveSession and
|
|
14221
|
+
// markInterrupted never store a resume word, and unfinishedTaskFromSession rejects one.
|
|
14222
|
+
const taskLine = nothingToResume ? String(line ?? "").trim() : resumeResult.task;
|
|
14122
14223
|
// #302 part 3: "continue" after a lost connection FOLLOWS the job that may still be running,
|
|
14123
14224
|
// rather than sending the question again. Anything else typed lets it go.
|
|
14124
14225
|
const reattach = lostTurn && isResumeWord(line) && lostTurn.taskLine === taskLine ? lostTurn : null;
|
|
14125
14226
|
lostTurn = null;
|
|
14126
|
-
// "continue" with nothing behind it runs NOTHING (task #201). It used to be sent to the
|
|
14127
|
-
// model as the literal word, which then got remembered as the unfinished task — so every
|
|
14128
|
-
// later "continue" retried "continue". Worse than the loop: with no task the model invents
|
|
14129
|
-
// one, and the reported run edited a file and ran migrations nobody asked for.
|
|
14130
|
-
if (nothingToResume) {
|
|
14131
|
-
console.log(GUTTER + dim("nothing to continue — ask a question first.\n"));
|
|
14132
|
-
continue;
|
|
14133
|
-
}
|
|
14134
14227
|
if (resumed) {
|
|
14135
14228
|
const shown = taskLine.length > 80 ? taskLine.slice(0, 80) + "…" : taskLine;
|
|
14136
14229
|
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/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.816",
|
|
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
|
@@ -198,6 +198,34 @@ export function ragBarNote(b) {
|
|
|
198
198
|
return ragSegmentLabel(b) || "";
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Issue #120: the bottom bar's note while a turn has AUTO-COMPACTED its context, or "".
|
|
203
|
+
*
|
|
204
|
+
* python trims older steps mid-turn once the prompt reaches ~2/3 of the model's window and
|
|
205
|
+
* reports it on the prompt breakdown (compacted = passes this turn, compactedAt = tokens at
|
|
206
|
+
* the latest one). A stale breakdown reloaded from disk is not this turn, so it gets no note.
|
|
207
|
+
*/
|
|
208
|
+
export function compactBarNote(b) {
|
|
209
|
+
if (!b || typeof b !== "object" || b.stale) return "";
|
|
210
|
+
const n = Number.isFinite(b.compacted) ? b.compacted : 0;
|
|
211
|
+
if (n <= 0) return "";
|
|
212
|
+
const at = Number.isFinite(b.compactedAt) && b.compactedAt > 0
|
|
213
|
+
? ` at ~${Math.round(b.compactedAt / 1000)}k` : "";
|
|
214
|
+
return `⟳ compacting context${at} — older steps trimmed${n > 1 ? ` (${n}×)` : ""}`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Issue #120: the bottom bar's ANIMATED note while a compaction holds the terminal locked.
|
|
219
|
+
* `frac` 0..1 is how far through the lock window we are; `at` the prompt tokens at the trim.
|
|
220
|
+
*/
|
|
221
|
+
export function compactProgressNote({ frac = 0, at = 0, cells = 12 } = {}) {
|
|
222
|
+
const f = Math.max(0, Math.min(1, Number.isFinite(frac) ? frac : 0));
|
|
223
|
+
const full = Math.round(f * cells);
|
|
224
|
+
const bar = "█".repeat(full) + "░".repeat(cells - full);
|
|
225
|
+
const k = Number.isFinite(at) && at > 0 ? ` at ~${Math.round(at / 1000)}k` : "";
|
|
226
|
+
return `⟳ compacting context${k} ▕${bar}▏ ${Math.round(f * 100)}% — input locked`;
|
|
227
|
+
}
|
|
228
|
+
|
|
201
229
|
/** Cut plain text to `room` visible columns, ending with "…" when it was cut. */
|
|
202
230
|
function fitPlain(s, room) {
|
|
203
231
|
s = String(s ?? "");
|
|
@@ -1114,7 +1142,28 @@ export function trimWords(text, limit) {
|
|
|
1114
1142
|
return toks.join(" ").trimEnd() + "…";
|
|
1115
1143
|
}
|
|
1116
1144
|
|
|
1145
|
+
// A LOOK-UP command (grep, rg, pgrep, which, lsof…) exits non-zero when it simply finds
|
|
1146
|
+
// nothing. That is an answer ("not there"), not a fault — so a "Command failed → pgrep -f x"
|
|
1147
|
+
// must not pop as a red ✗ (#41's "the one thing you must not miss"). It is "soft": a dim
|
|
1148
|
+
// grey ✗ with dim text. Judged by the LAST segment of a chain/pipeline (its exit status is
|
|
1149
|
+
// the one reported): `ps aux | grep foo` and `cd src && rg Foo` are both look-ups.
|
|
1150
|
+
const SEARCH_CMDS = new Set([
|
|
1151
|
+
"grep", "egrep", "fgrep", "zgrep", "rg", "ag", "ack", "pgrep", "which", "whereis", "lsof",
|
|
1152
|
+
]);
|
|
1153
|
+
export function isSoftFailCommand(cmd) {
|
|
1154
|
+
const segs = String(cmd ?? "").split(/&&|\|\||[|;]/).map((s) => s.trim()).filter(Boolean);
|
|
1155
|
+
if (!segs.length) return false;
|
|
1156
|
+
const toks = segs[segs.length - 1].split(/\s+/);
|
|
1157
|
+
let i = 0;
|
|
1158
|
+
while (i < toks.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i]) || ["env", "command", "time", "nice"].includes(toks[i]))) i++;
|
|
1159
|
+
const prog = (toks[i] || "").split("/").pop();
|
|
1160
|
+
if (SEARCH_CMDS.has(prog)) return true;
|
|
1161
|
+
return prog === "git" && toks[i + 1] === "grep";
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1117
1164
|
export function bulletKind(text) {
|
|
1165
|
+
const mFail = /^Command failed → (.+)$/.exec(String(text ?? "").trim());
|
|
1166
|
+
if (mFail && isSoftFailCommand(mFail[1])) return "soft";
|
|
1118
1167
|
if (/\bfail(ed|s|ure)?\b|\berror(ed)?\b|→\s*(?:HTTP\s*)?[45]\d\d\b/i.test(text)) return "fail";
|
|
1119
1168
|
if (/^(?:Command finished|Server up|App launched)\b|→\s*(?:HTTP\s*)?2\d\d\b|\b(?:passed|succeeded|ready)\b/i.test(text))
|
|
1120
1169
|
return "ok";
|
|
@@ -1611,6 +1660,11 @@ export function classifyTurnFailure(message) {
|
|
|
1611
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)) {
|
|
1612
1661
|
return "setup";
|
|
1613
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
|
+
}
|
|
1614
1668
|
// CONFIG — the server is up and answering; it just has no model by that name (task #213).
|
|
1615
1669
|
// Ollama says: model 'gemma4:e4b' not found (and "…, try pulling it first" for embeddings).
|
|
1616
1670
|
// Checked BEFORE the backend patterns because it arrives as a 404 from a perfectly healthy
|
|
@@ -1708,6 +1762,10 @@ export function retryAdvice({ resumable = true, failures = 1, kind = "task", min
|
|
|
1708
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.`
|
|
1709
1763
|
: 'that was gu\'s own machinery stopping, not your question — "continue" runs it again';
|
|
1710
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
|
+
}
|
|
1711
1769
|
if (kind === "setup") {
|
|
1712
1770
|
// Nothing ran, so there is nothing to diagnose in the model or the question — only the step the
|
|
1713
1771
|
// message above names. "continue" is right AFTER that step, never before.
|