@tiens.nguyen/gu-cli 1.0.813 → 1.0.814
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/gonext_agent_chat.py +20 -2
- package/gu-cli.mjs +12 -0
- package/gu-repl.mjs +57 -1
- package/package.json +1 -1
- package/terminal-layout.mjs +49 -0
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
|
|
|
@@ -8319,6 +8326,8 @@ def run_agent_chat(cfg):
|
|
|
8319
8326
|
# credit RAG for tokens it never cost, and a wrong number is worse than none.
|
|
8320
8327
|
_rag_ledger_reset()
|
|
8321
8328
|
_RAG_STEP["n"] = 0
|
|
8329
|
+
_COMPACT_STATE["n"] = 0
|
|
8330
|
+
_COMPACT_STATE["tok"] = 0
|
|
8322
8331
|
_overload_notified["yes"] = False
|
|
8323
8332
|
try:
|
|
8324
8333
|
from smolagents import CodeAgent, OpenAIServerModel, tool
|
|
@@ -10615,8 +10624,11 @@ def run_agent_chat(cfg):
|
|
|
10615
10624
|
# Yes/No before deep-compacting (the auto-trim above is always-on; THIS is
|
|
10616
10625
|
# the extra, opt-in aggressive pass). Only in the terminal (interactive) —
|
|
10617
10626
|
# web has no picker, so it just keeps the auto-trim.
|
|
10618
|
-
|
|
10619
|
-
|
|
10627
|
+
# Issue #120: this used to be gated on an interactive terminal AND read
|
|
10628
|
+
# _compact_state["decision"], a key removed with the old Yes/No prompt — the
|
|
10629
|
+
# KeyError was swallowed below as "memory-trim skip", so mid-turn compaction
|
|
10630
|
+
# NEVER ran and the context grew past the model's window. Always on now.
|
|
10631
|
+
if _steps:
|
|
10620
10632
|
# Size of the last request in prompt tokens (exact if the server
|
|
10621
10633
|
# reported it, else ~chars/4 of the current step memory).
|
|
10622
10634
|
_tu = getattr(step_log, "token_usage", None)
|
|
@@ -10652,6 +10664,12 @@ def run_agent_chat(cfg):
|
|
|
10652
10664
|
f"{_VERBOSE_PREFIX}Context reached ~{_in_tok // 1000}k tokens — trimming older "
|
|
10653
10665
|
"steps to stay fast (files are re-readable)")})
|
|
10654
10666
|
if _cs:
|
|
10667
|
+
_COMPACT_STATE["n"] += 1
|
|
10668
|
+
_COMPACT_STATE["tok"] = int(_in_tok)
|
|
10669
|
+
# Issue #120: tell the terminal NOW (not on the next request's
|
|
10670
|
+
# breakdown) so it can lock input and show the compacting bar.
|
|
10671
|
+
_emit({"type": "compact", "n": _COMPACT_STATE["n"],
|
|
10672
|
+
"tok": _COMPACT_STATE["tok"]})
|
|
10655
10673
|
_log(f"context compact: saved ~{_cs} chars "
|
|
10656
10674
|
f"(at ~{_in_tok} tok, threshold {_compact_at}, "
|
|
10657
10675
|
f"window {_ctx_window or 'unknown'})")
|
package/gu-cli.mjs
CHANGED
|
@@ -4394,6 +4394,18 @@ async function runAgentChatJob(job) {
|
|
|
4394
4394
|
}),
|
|
4395
4395
|
}).catch(() => {});
|
|
4396
4396
|
}
|
|
4397
|
+
} else if (event.type === "compact" && Number.isFinite(event.n)) {
|
|
4398
|
+
// Issue #120: a mid-turn compaction just landed — put it on the job straight away so
|
|
4399
|
+
// the REPL locks input and shows its progress bar now, not at the next request.
|
|
4400
|
+
latestPromptBreakdown = {
|
|
4401
|
+
...(latestPromptBreakdown || {}),
|
|
4402
|
+
compacted: event.n,
|
|
4403
|
+
...(Number.isFinite(event.tok) ? { compactedAt: event.tok } : {}),
|
|
4404
|
+
};
|
|
4405
|
+
workerFetch(`/api/worker/jobs/${jobId}`, {
|
|
4406
|
+
method: "PATCH",
|
|
4407
|
+
body: JSON.stringify({ agentPromptBreakdown: latestPromptBreakdown }),
|
|
4408
|
+
}).catch(() => {});
|
|
4397
4409
|
} else if (event.type === "code_request") {
|
|
4398
4410
|
// Task #155: the row is opened BEFORE the request goes out, so a step that is
|
|
4399
4411
|
// 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;
|
|
@@ -11299,6 +11344,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11299
11344
|
following = true;
|
|
11300
11345
|
followingAttached = attached;
|
|
11301
11346
|
typedDuringTurn = false;
|
|
11347
|
+
compactSeenPasses = 0; // issue #120: python counts passes per turn
|
|
11302
11348
|
followAborted = false;
|
|
11303
11349
|
currentJobId = jobId;
|
|
11304
11350
|
jobIdForApprovalRace = jobId; // so an approval picker can watch THIS job for early end (#110)
|
|
@@ -12369,6 +12415,12 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
12369
12415
|
if (job.agentPromptBreakdown && typeof job.agentPromptBreakdown === "object") {
|
|
12370
12416
|
lastPromptBreakdown = job.agentPromptBreakdown;
|
|
12371
12417
|
liveStepTokens = readStepTokens(job.agentPromptBreakdown);
|
|
12418
|
+
// Issue #120: a NEW compaction pass this turn → lock everything and show the bar.
|
|
12419
|
+
const compactPasses = Number.isFinite(job.agentPromptBreakdown.compacted) ? job.agentPromptBreakdown.compacted : 0;
|
|
12420
|
+
if (compactPasses > compactSeenPasses) {
|
|
12421
|
+
compactSeenPasses = compactPasses;
|
|
12422
|
+
startCompactLock();
|
|
12423
|
+
}
|
|
12372
12424
|
// Persisted per folder so the next `gu` here starts from the real picture instead
|
|
12373
12425
|
// of a zero that looks like a failure. Fire-and-forget: a bar value is never worth
|
|
12374
12426
|
// delaying a turn for, and losing one is harmless. Shown while attached (it is what
|
|
@@ -12385,6 +12437,9 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
12385
12437
|
// Mirror both into the bottom bar's token row so it counts UP live while the turn runs
|
|
12386
12438
|
// (the bar rides under the status block now), instead of sitting on the last turn's
|
|
12387
12439
|
// figures until this one finishes. The turn-end assignment writes the same numbers again.
|
|
12440
|
+
// Issue #120: hold the turn's output while compacting — only the bar moves. Ctrl+C ends it.
|
|
12441
|
+
while (compactLocked() && !cancelRequested && !followAborted) await sleep(80);
|
|
12442
|
+
if (compactLock && (cancelRequested || followAborted)) endCompactLock();
|
|
12388
12443
|
lastTurnInputTokens = latestCodeTokens;
|
|
12389
12444
|
lastTurnOutputTokens = latestOutputTokens;
|
|
12390
12445
|
lastTurnTotalOutput = globalOutputTokens + latestOutputTokens;
|
|
@@ -12621,6 +12676,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
12621
12676
|
following = false;
|
|
12622
12677
|
followingAttached = false;
|
|
12623
12678
|
typedDuringTurn = false;
|
|
12679
|
+
endCompactLock(); // issue #120: never leave the terminal locked after a turn
|
|
12624
12680
|
currentJobId = null;
|
|
12625
12681
|
// Drop anything typed (but not submitted) during the turn — echo was muted, so it's
|
|
12626
12682
|
// invisible; without this it would surface on the next prompt.
|
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.814",
|
|
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/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";
|