@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.
@@ -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
- if (_interactive_approval and _job_id and worker_api_base and worker_api_key
10619
- and _compact_state["decision"] != "never"):
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
- modeNote: ragBarNote(lastPromptBreakdown),
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.813",
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",
@@ -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";