@tiens.nguyen/gu-cli 1.0.788 → 1.0.789

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/gu-repl.mjs CHANGED
@@ -65,6 +65,9 @@ import {
65
65
  runningDetailRows,
66
66
  runningCommandLabel,
67
67
  runningCommandRows,
68
+ dropRestatement,
69
+ isRestatement,
70
+ liveThoughtText,
68
71
  formatThinkTime,
69
72
  plainAnswer,
70
73
  summarizeError,
@@ -3316,7 +3319,33 @@ const paintThoughtRows = (block, hovered, collapsed = block.collapsed) => {
3316
3319
  collapsed,
3317
3320
  lines: THOUGHT_COLLAPSED_LINES,
3318
3321
  });
3319
- return rows.map((r) => GUTTER + paint(r));
3322
+ return (block.lead ? [""] : []).concat(rows.map((r) => THOUGHT_GUTTER + paint(r)));
3323
+ };
3324
+
3325
+ /**
3326
+ * A THOUGHT HAS ITS OWN MARK AND ITS OWN ROOM (task #334).
3327
+ *
3328
+ * Reported with a screenshot: "● listed files · read file 7×" and, flush under it in the same
3329
+ * grey at the same indent, "The user wants me to use the .env file…" — which read as the rest of
3330
+ * the bullet. Two things were missing, both since #326 made the look-ups ONE row that stays where
3331
+ * it is while thoughts print under it (before that a blank or the question always sat above):
3332
+ *
3333
+ * THE MARK — a dim rule down the margin, on every row of the block, so two or twenty rows read
3334
+ * as one thing and never as an action ("●" means something was DONE). It takes the gutter's own
3335
+ * three cells, so no row gets wider and nothing that measures rows changes.
3336
+ *
3337
+ * THE BLANK ROW — owned by the BLOCK (`lead`), not written beside it. The open group's row is
3338
+ * rewritten in place and everything under it is found by row arithmetic that expects the blocks
3339
+ * to touch (stepGroupStillOpen, the merge below, the shift when a block opens); a blank that
3340
+ * belonged to nobody would break each of those. As the block's first row it moves, repaints
3341
+ * and is replaced with the block.
3342
+ */
3343
+ const THOUGHT_GUTTER = " " + dim("│") + " ";
3344
+
3345
+ /** Is the last finished line of the transcript empty (so a block printed now needs no blank)? */
3346
+ const transcriptEndsBlank = () => {
3347
+ const lines = transcript.lines();
3348
+ return lines.length < 2 || stripAnsiEscapes(lines[lines.length - 2] ?? "").trim() === "";
3320
3349
  };
3321
3350
 
3322
3351
  /** Width available to a thought row: the screen, less the gutter it hangs in. */
@@ -3332,7 +3361,9 @@ const thoughtWidth = () =>
3332
3361
  */
3333
3362
  function printThoughtBlock(raw, before) {
3334
3363
  if (!sessionShowThought) return false;
3335
- const prose = thoughtProse(raw);
3364
+ // The question said back ("The user wants me to…") is dropped from the front (#333): it sits
3365
+ // directly under the question, and it was all the two collapsed rows had room for.
3366
+ const prose = dropRestatement(thoughtProse(raw), turnQuestionText);
3336
3367
  if (!prose) return false; // a bare tool label or pure code — not what anyone means by a thought
3337
3368
  const rows = thoughtBlockRows(prose, {
3338
3369
  width: thoughtWidth(),
@@ -3359,12 +3390,7 @@ function printThoughtBlock(raw, before) {
3359
3390
  const prev = thoughtBlocks[thoughtBlocks.length - 1];
3360
3391
  if (prev && !prev.kind && prev.collapsed && prev.to === transcript.dropped() + transcript.length() - 2) {
3361
3392
  const merged = prev.prose.replace(/\s+$/, "") + " " + prose;
3362
- const grown = thoughtBlockRows(merged, {
3363
- width: thoughtWidth(),
3364
- collapsed: true,
3365
- lines: THOUGHT_COLLAPSED_LINES,
3366
- });
3367
- const newEnd = transcript.replaceRange(prev.from, prev.to, grown.map((r) => GUTTER + dim(r)));
3393
+ const newEnd = transcript.replaceRange(prev.from, prev.to, paintThoughtRows({ ...prev, prose: merged }, false));
3368
3394
  if (newEnd !== null) {
3369
3395
  prev.prose = merged;
3370
3396
  prev.to = newEnd;
@@ -3377,11 +3403,16 @@ function printThoughtBlock(raw, before) {
3377
3403
  }
3378
3404
  // Scrolled out of the buffer — fall through and print a new block rather than lose it.
3379
3405
  }
3380
- process.stdout.write(rows.map((r) => GUTTER + dim(r)).join("\n") + "\n");
3406
+ // Its own blank row when what is above is not one — see THOUGHT_GUTTER.
3407
+ const block = { from: 0, to: 0, prose, collapsed: true, lead: !transcriptEndsBlank() };
3408
+ const painted = paintThoughtRows(block, false);
3409
+ process.stdout.write(painted.join("\n") + "\n");
3381
3410
  // Record where it landed. Measured AFTER the write so a trim during it cannot skew the
3382
3411
  // range: the last completed line is length-2 (the final entry is the still-open line).
3383
3412
  const endAbs = transcript.dropped() + transcript.length() - 2;
3384
- thoughtBlocks.push({ from: endAbs - rows.length + 1, to: endAbs, prose, collapsed: true });
3413
+ block.from = endAbs - painted.length + 1;
3414
+ block.to = endAbs;
3415
+ thoughtBlocks.push(block);
3385
3416
  if (thoughtBlocks.length > 200) thoughtBlocks = thoughtBlocks.slice(-200);
3386
3417
  return true;
3387
3418
  }
@@ -10765,7 +10796,25 @@ async function askTokenAtStart() {
10765
10796
  /** The token refresh started at launch — askTokenAtStart waits for it (see there). */
10766
10797
  let startupTokenRefresh = null;
10767
10798
 
10799
+ /**
10800
+ * The fuller live thought (what the status row is driven from once it is longer than the row),
10801
+ * held to the same rule as the short one (#333): from its first real sentence, without the
10802
+ * question or gu's own instructions said back — "" while that is all there is so far.
10803
+ */
10804
+ const liveThoughtClean = (text) => {
10805
+ const kept = dropRestatement(text, turnQuestionText);
10806
+ return isRestatement(kept, turnQuestionText) ? "" : liveThoughtText(kept);
10807
+ };
10808
+
10809
+ // The question the running turn is answering — what a thought is compared with so that the
10810
+ // question said back is not printed under the question (#333). Display only.
10811
+ let turnQuestionText = "";
10812
+
10768
10813
  async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {}) {
10814
+ {
10815
+ const asked = [...(Array.isArray(history) ? history : [])].reverse().find((m) => m?.role === "user");
10816
+ turnQuestionText = typeof asked?.content === "string" ? asked.content : "";
10817
+ }
10769
10818
  // #238: when the licence is ENFORCED and this account is not entitled, no turn is sent. Decided at
10770
10819
  // startup (see licenceBlock) — never re-checked here, so a turn cannot die halfway through.
10771
10820
  if (licenceBlock) {
@@ -11008,6 +11057,28 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11008
11057
  // became of it. A no-code retry notice discards it (the model restates the plan on the retry);
11009
11058
  // anything else prints it. Always null under --verbose, which shows everything as it comes.
11010
11059
  let heldThought = null;
11060
+ // A THOUGHT INTERRUPTED BY THE HEARTBEAT IS STILL ONE THOUGHT (#333). The worker's
11061
+ // "…almost completed thinking… (180s)" is a step, and a step closes the stream fence — in the
11062
+ // MIDDLE of the model's sentence. The fence then reopens and the rest of the sentence arrived
11063
+ // as a new thought: the live line read `one, call use_skill(name) FIRST…` and
11064
+ // `` `, `bootstrap-api-service.mjs` — add… `` (both from stored streams), and the history got
11065
+ // the thought in two halves. So a closing fence is only PARKED here: if nothing but blanks and
11066
+ // heartbeats follow and the fence opens again, the thought carries on where it stopped;
11067
+ // anything else finishes it exactly as before.
11068
+ let pausedFence = null; // { step, fence } while a fence is closed but not yet finished
11069
+ const finishPausedFence = () => {
11070
+ if (!pausedFence) return;
11071
+ const step = pausedFence.step;
11072
+ pausedFence = null;
11073
+ // The step's model output is complete. Print its reasoning HERE, before the tools
11074
+ // report what they did, so the transcript reads in the order it happened: the model
11075
+ // says what it intends, then the actions it took appear under it.
11076
+ if (!REPL_VERBOSE && step.trim() && !stepHasCode(step)) {
11077
+ heldThought = step; // #45: decided by the next line — see releaseHeldThought
11078
+ } else if (printThoughtBlock(step, onContent)) {
11079
+ lastWasBlank = false;
11080
+ }
11081
+ };
11011
11082
  const releaseHeldThought = () => {
11012
11083
  if (heldThought === null) return;
11013
11084
  const raw = heldThought;
@@ -11551,6 +11622,9 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11551
11622
  almostDone = /almost/i.test(line);
11552
11623
  continue;
11553
11624
  }
11625
+ // A parked fence (see pausedFence) is finished by the first real line that is not the
11626
+ // fence opening again.
11627
+ if (pausedFence && line.trim() !== "" && !isFenceLine(line)) finishPausedFence();
11554
11628
  // #45: retry notices and context-window bookkeeping are the agent's own machinery. Shown
11555
11629
  // under --verbose only; otherwise swallowed, together with the no-code false start the
11556
11630
  // retry notice is about.
@@ -11606,17 +11680,19 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11606
11680
  if (isFenceLine(line)) {
11607
11681
  inStreamFence = !inStreamFence;
11608
11682
  if (inStreamFence) {
11609
- fenceThought = "";
11610
- stepThought = "";
11611
- } else {
11612
- // The step's model output is complete. Print its reasoning HERE, before the tools
11613
- // report what they did, so the transcript reads in the order it happened: the model
11614
- // says what it intends, then the actions it took appear under it.
11615
- if (!REPL_VERBOSE && stepThought.trim() && !stepHasCode(stepThought)) {
11616
- heldThought = stepThought; // #45: decided by the next line — see releaseHeldThought
11617
- } else if (printThoughtBlock(stepThought, onContent)) {
11618
- lastWasBlank = false;
11683
+ if (pausedFence) {
11684
+ // …the same thought, carrying on. The fence close put a line break where the model
11685
+ // had none ("…When the TASK matches" / " one, call…"), so the two halves are joined
11686
+ // on the line they were cut in.
11687
+ stepThought = pausedFence.step.replace(/\n$/, "");
11688
+ fenceThought = pausedFence.fence.replace(/\n$/, "");
11689
+ pausedFence = null;
11690
+ } else {
11691
+ fenceThought = "";
11692
+ stepThought = "";
11619
11693
  }
11694
+ } else {
11695
+ pausedFence = { step: stepThought, fence: fenceThought };
11620
11696
  stepThought = "";
11621
11697
  }
11622
11698
  continue;
@@ -11636,11 +11712,13 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11636
11712
  // Accumulate a bounded head of the stream (enough to see the Thought prose OR the
11637
11713
  // tool call), capped so a 16k-char runaway can't grow the buffer. normalizeThought
11638
11714
  // yields a clean label — never raw code/CSS.
11639
- if (fenceThought.length < 400) {
11715
+ // (1200, not 400, since #333: the line is now taken from past the question said back,
11716
+ // and a restatement alone can run to a few hundred characters)
11717
+ if (fenceThought.length < 1200) {
11640
11718
  fenceThought += line + "\n";
11641
- const th = normalizeThought(fenceThought);
11719
+ const th = normalizeThought(fenceThought, { question: turnQuestionText });
11642
11720
  if (th) liveThought = th;
11643
- fullLiveThought = thoughtFull(fenceThought); // #96: keep the fuller thought for expand
11721
+ fullLiveThought = liveThoughtClean(thoughtFull(fenceThought)); // #96: keep the fuller thought for expand
11644
11722
  }
11645
11723
  continue;
11646
11724
  }
@@ -11852,9 +11930,9 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11852
11930
  // live clause from the partial too, so the blinking line updates mid-sentence instead
11853
11931
  // of only once the line finally breaks (task #64).
11854
11932
  if (inStreamFence && fenceThought.length < 400) {
11855
- const th = normalizeThought(fenceThought + carry);
11933
+ const th = normalizeThought(fenceThought + carry, { question: turnQuestionText });
11856
11934
  if (th) liveThought = th;
11857
- fullLiveThought = thoughtFull(fenceThought + carry); // #96: fuller thought for expand
11935
+ fullLiveThought = liveThoughtClean(thoughtFull(fenceThought + carry)); // #96: fuller thought for expand
11858
11936
  }
11859
11937
  // A non-empty remainder is a partial CONTENT line (heartbeats always arrive whole with
11860
11938
  // a newline), so tokens are actively flowing — keep the ticker asleep. EXCEPT a buffered
@@ -12055,6 +12133,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
12055
12133
  lastWasBlank = false;
12056
12134
  }
12057
12135
  }
12136
+ finishPausedFence(); // #333: a fence closed and nothing followed — it is finished now
12058
12137
  releaseHeldThought(); // #45: nothing followed it, so it was not a false start
12059
12138
  clearStatus();
12060
12139
  // Task #104: expose this turn's code-model INPUT + the CUMULATIVE output-token
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.788",
3
+ "version": "1.0.789",
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",
@@ -2434,11 +2434,104 @@ export function firstToolArg(codePart) {
2434
2434
  return { name: m[1], arg: String(value).trim() };
2435
2435
  }
2436
2436
 
2437
+ /**
2438
+ * WHERE A THOUGHT LINE REALLY STARTS (task #333).
2439
+ *
2440
+ * The live line once read `one, call use_skill(name) FIRST, read what it returns, and follow it".
2441
+ * I'll…` for 83 seconds, and later `` `, `bootstrap-api-service.mjs` — add … `` — both the tail of
2442
+ * a sentence whose head had been cut away. The main cause is fixed where it happened (a heartbeat
2443
+ * splitting the thought, see the fence handling in gu-repl.mjs); this is the guard for every other
2444
+ * way a line can arrive headless (a poll boundary, a buffer cap): a line that opens with closing
2445
+ * punctuation, or with a lower-case word, is the middle of something. Start at the next sentence
2446
+ * instead — and when there is none, say nothing ("" keeps whatever the line showed before).
2447
+ */
2448
+ const _SENTENCE_END = /[.!?]["'”’`)\]]*\s+(?=["'“‘`(\[]?[A-Z0-9])/g;
2449
+
2450
+ export function thoughtLineStart(line) {
2451
+ const s = String(line ?? "").trim();
2452
+ if (!s) return "";
2453
+ const headless =
2454
+ /^[`'"”’)\]}]*\s*[,.;:!?)\]}]/.test(s) || // "`, `bootstrap…", ", and then", ") so"
2455
+ /^[a-z]/.test(s); // "one, call use_skill…" — a sentence starts with a capital
2456
+ if (!headless) return s;
2457
+ _SENTENCE_END.lastIndex = 0;
2458
+ const m = _SENTENCE_END.exec(s);
2459
+ return m ? s.slice(m.index + m[0].length).trim() : "";
2460
+ }
2461
+
2462
+ /**
2463
+ * gu's OWN INSTRUCTIONS, recited (task #333). A model weighing its prompt quotes it — "Rules say
2464
+ * call use_skill FIRST when the task matches" — and the status line then shows gu's scaffolding as
2465
+ * if it were news about the user's task. Matched by the fixed phrases of the prompt blocks the
2466
+ * worker writes (gu_skills.prompt_block, the workspace directives); a sentence carrying one is
2467
+ * not shown on the live line.
2468
+ */
2469
+ const _PROMPT_ECHO = /use_skill\(name\)|call use_skill\b[^.]*\bFIRST\b|When the TASK matches|WORK LIKE A SENIOR DEVELOPER|packaged instructions for particular kinds of work/i;
2470
+
2471
+ /** Split prose into sentences, keeping each one's own punctuation. */
2472
+ function _sentences(text) {
2473
+ const s = String(text ?? "").trim();
2474
+ const out = [];
2475
+ let from = 0;
2476
+ _SENTENCE_END.lastIndex = 0;
2477
+ let m;
2478
+ while ((m = _SENTENCE_END.exec(s))) {
2479
+ out.push(s.slice(from, m.index + m[0].length).trim());
2480
+ from = m.index + m[0].length;
2481
+ }
2482
+ if (from < s.length) out.push(s.slice(from).trim());
2483
+ return out.filter(Boolean);
2484
+ }
2485
+
2486
+ /** The line to show live: from its first real sentence, past any recital of gu's own prompt. */
2487
+ export function liveThoughtText(line) {
2488
+ const sentences = _sentences(thoughtLineStart(line));
2489
+ const at = sentences.findIndex((x) => !_PROMPT_ECHO.test(x));
2490
+ return at < 0 ? "" : sentences.slice(at).join(" ");
2491
+ }
2492
+
2493
+ /**
2494
+ * THE QUESTION, SAID BACK (task #333). A thought that opens "The user wants me to use the .env
2495
+ * file and add a template file." sits directly under ">> currently we are not using the .env
2496
+ * file, please use that and also add a template file" — the same thing twice, and on the two
2497
+ * collapsed rows that are all most people read. Measured on this machine's stored turns: 169 of
2498
+ * 296 first thoughts (57%) open this way.
2499
+ *
2500
+ * Leading sentences that restate are dropped; what the model said NEXT — what it will look at,
2501
+ * what it found — becomes the block's first row. Two tests, either is enough: the sentence is
2502
+ * shaped like a restatement ("The user wants/says/asks…", "Let me understand the task"), or most
2503
+ * of its words are the question's own. Only from the START, and never the whole thought: when
2504
+ * every sentence restates, the last one is kept, so a thought is shortened, not lost.
2505
+ * Display only — what the model wrote, and what is sent back to it, are untouched.
2506
+ */
2507
+ const _RESTATE_SHAPE = /^(?:(?:ok(?:ay)?|so|alright|right|now|first)[,.]?\s+)*(?:let me (?:first\s+)?(?:understand|re-?read|restate|parse|break down|think about)\b[^.]*\b(?:task|request|question|ask)|the user(?:'s (?:request|question|task|message))?\s+(?:wants?|says?|said|is asking|asks?|asked|would like|needs?|is|has asked|requests?)|the (?:task|request|question)(?:\s+is)?\s*[:,]|(?:the )?user (?:request|query|message)\s*[:,]|the (?:system prompt|instructions?|rules?|skill)\s+(?:says?|tells?|states?|wants?|requires?)|(?:rules?|instructions?) say\b)/i;
2508
+
2509
+ const _words = (t) => String(t ?? "").toLowerCase().match(/[a-z0-9_.-]{3,}/g) ?? [];
2510
+
2511
+ /** Is this ONE sentence the question (or gu's instructions) said back? */
2512
+ export function isRestatement(sentence, question = "") {
2513
+ if (_RESTATE_SHAPE.test(sentence)) return true;
2514
+ const asked = new Set(_words(question));
2515
+ if (asked.size < 4) return false;
2516
+ const w = _words(sentence);
2517
+ if (w.length < 4) return false;
2518
+ return w.filter((x) => asked.has(x)).length / w.length >= 0.7;
2519
+ }
2520
+
2521
+ export function dropRestatement(prose, question = "") {
2522
+ const sentences = _sentences(prose);
2523
+ if (sentences.length < 2) return String(prose ?? "").trim();
2524
+ const restates = (sentence) => isRestatement(sentence, question);
2525
+ let at = 0;
2526
+ while (at < sentences.length - 1 && restates(sentences[at])) at++;
2527
+ return sentences.slice(at).join(" ");
2528
+ }
2529
+
2437
2530
  /**
2438
2531
  * Raw in-fence stream → a short, meaningful status: a real Thought clause if the model wrote
2439
2532
  * one, else a label naming the tool it is calling, else "" (the caller shows "Thinking…").
2440
2533
  */
2441
- export function normalizeThought(buf) {
2534
+ export function normalizeThought(buf, { question = null } = {}) {
2442
2535
  const t = String(buf || "");
2443
2536
  const ci = t.search(/<code[\s>]/i);
2444
2537
  const head = ci >= 0 ? t.slice(0, ci) : t;
@@ -2455,7 +2548,24 @@ export function normalizeThought(buf) {
2455
2548
  .map((x) => x.trim())
2456
2549
  .find(Boolean);
2457
2550
  const proseIsClause = !!prose && prose.length >= 6 && prose.trim().split(/\s+/).length >= 2;
2458
- if (proseIsClause && !_thoughtLooksCodey(prose)) return prose.slice(0, 100);
2551
+ if (proseIsClause && !_thoughtLooksCodey(prose)) {
2552
+ // …from where a sentence starts, and never gu's own prompt said back (#333). A line with
2553
+ // nothing left falls through to the tool label / "", like any other non-thought.
2554
+ //
2555
+ // WITH THE QUESTION KNOWN (the terminal passes it), the line is taken from the whole head
2556
+ // rather than its first line, minus the question said back: "The user wants me to read
2557
+ // package.json…" sat on the status row for the whole wait while the next sentence — what
2558
+ // the model was actually about to do — was already there. While all there is so far IS the
2559
+ // restatement, nothing is shown: the row keeps "Thinking" for the second that takes.
2560
+ let text = prose;
2561
+ if (question !== null) {
2562
+ const whole = head.replace(/^\s*Thought:\s*/i, "").replace(/\s+/g, " ").trim();
2563
+ text = dropRestatement(whole, question);
2564
+ if (isRestatement(text, question)) text = "";
2565
+ }
2566
+ const shown = liveThoughtText(text);
2567
+ if (shown.length >= 6 && shown.split(/\s+/).length >= 2) return shown.slice(0, 100);
2568
+ }
2459
2569
  // 2) otherwise name the tool being called (from the code part).
2460
2570
  const codePart = ci >= 0 ? t.slice(ci) : t;
2461
2571
  const call = firstToolArg(codePart);