@tiens.nguyen/gonext-local-worker 1.0.202 → 1.0.204

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-repl.mjs CHANGED
@@ -43,11 +43,11 @@ const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
43
43
  const green = (s) => `\x1b[32m${s}\x1b[0m`;
44
44
  const red = (s) => `\x1b[31m${s}\x1b[0m`;
45
45
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
46
+ const white = (s) => `\x1b[37m${s}\x1b[0m`;
46
47
  // ANSI 90 ("bright black") — a reliable neutral grey across terminal themes, unlike
47
48
  // code 34 (blue) which many dark-theme palettes render as purple/indigo instead.
48
49
  const codeColor = (s) => `\x1b[90m${s}\x1b[0m`;
49
50
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
50
- const CLEAR_LINE = "\r\x1b[K"; // carriage-return + erase-to-end-of-line
51
51
 
52
52
  // The worker's "still thinking" heartbeat lines look like "Caffeinating… (90s)" — we
53
53
  // SWALLOW them from the terminal (the local ticker below drives progress instead), and
@@ -106,6 +106,9 @@ const THINKING_WORDS = (() => {
106
106
  })();
107
107
  const pickWord = () =>
108
108
  THINKING_WORDS[Math.floor(Math.random() * THINKING_WORDS.length)] || "Thinking";
109
+ // Rotating half-circle = an in-progress ("blinking") bullet for the step being worked on.
110
+ const SPINNER = ["◐", "◓", "◑", "◒"];
111
+ const BULLET = "●"; // a completed action
109
112
 
110
113
  // ---------- flags ----------
111
114
  const argv = process.argv.slice(2);
@@ -397,7 +400,6 @@ async function runAgentTurn(history) {
397
400
  let warnedPending = false;
398
401
  let jobRunning = false; // only tick "thinking…" once the worker has claimed the job
399
402
  let answerShownLive = false; // plain-reply content already printed — don't re-print it
400
- let routingAnnounced = false; // one friendly line replaces the whole routing play-by-play
401
403
  // True once we've SPECIFICALLY seen "→ Chat reply" (the plain-reply/trivial-chat
402
404
  // branch), as opposed to "→ Agent mode…" (the tool-use branch). Only the plain-reply
403
405
  // path streams its answer via answer_stream with NO heartbeat/tool-summary/code lines
@@ -406,73 +408,64 @@ async function runAgentTurn(history) {
406
408
  // until a full line is known, or we'd risk showing part of something we meant to hide.
407
409
  let plainReplyFlow = false;
408
410
 
409
- // --- Live "thinking…" ticker ---------------------------------------------------
410
- // Between visible tokens (prompt-eval / awaiting the first token) we show ONE in-place
411
- // status line that counts UP every second "Percolating thinking… (7s)" so the wait
412
- // feels alive. When output resumes we stamp a dim "✔ completed thought (Ns)" and let the
413
- // tokens stream. The worker's own 45s heartbeat lines (and ~~~ fences) are swallowed;
414
- // this local 1-second ticker owns the progress display.
411
+ // --- Live in-progress ("blinking") bullet -------------------------------------------
412
+ // The agent's raw Thought/code stream is SUPPRESSED (see the ~~~-fence handling in
413
+ // consume) the terminal shows only a clean list of action bullets. While a step is
414
+ // being thought about / run, we show ONE in-place blinking bullet that counts up
415
+ // "◐ working… (7s)" instead of dumping the model's rambling reasoning and stamping a
416
+ // "✔ completed thought" on every token-stream micro-pause (the reported clutter).
415
417
  const QUIET_MS = 700; // stream idle this long ⇒ we're waiting on the model
416
418
  let lastContentAt = Date.now();
417
419
  let thinking = false;
418
420
  let thinkingSince = 0;
419
- let thinkWord = pickWord();
421
+ let thinkWord = pickWord(); // playful word shown UNDER the in-progress line (flavor)
422
+ let almostDone = false; // set from the worker's "…almost completed thinking…" heartbeat
423
+ let runningCmd = null; // the command currently executing (from a "Running → …" event)
420
424
  const thinkSecs = () => Math.max(1, Math.round((Date.now() - thinkingSince) / 1000));
421
425
 
426
+ // The status is TWO in-place lines — the in-progress action, then the playful word
427
+ // under it. Cursor sits at the end of line 2, so clearing = wipe line 2, move up, wipe
428
+ // line 1, leaving the cursor back at the status's origin for a redraw or real content.
422
429
  const clearStatus = () => {
423
- if (statusShown) { process.stdout.write(CLEAR_LINE); statusShown = false; }
430
+ if (!statusShown) return;
431
+ process.stdout.write("\r\x1b[K\x1b[1A\r\x1b[K");
432
+ statusShown = false;
424
433
  };
425
434
 
426
435
  const tick = () => {
427
436
  if (!following || followAborted || !jobRunning) return;
428
437
  if (Date.now() - lastContentAt < QUIET_MS) return; // tokens still flowing
429
438
  // A plain-reply answer streams onto a line with NO trailing newline until it's fully
430
- // done — if the model pauses mid-sentence (normal token-generation jitter) and we're
431
- // already partway through that open line, CLEAR_LINE can only erase the terminal's
432
- // CURRENT visual row, not the whole logical line once it's wrapped — overwriting the
433
- // ticker there corrupts the visible answer text mid-word. Stay silent once any of
434
- // THIS line has already been shown; the next completed line (fresh, empty) is safe.
439
+ // done — overwriting the ticker there corrupts the visible answer mid-word. Stay
440
+ // silent once any of THIS line has already been shown (see carryFlushedLen).
435
441
  if (plainReplyFlow && carryFlushedLen > 0) return;
436
- // Count from when the stream actually went quiet, so the seconds reflect the true
437
- // wait for the first token (1s, 2s, 3s…) rather than from the first tick.
442
+ // Count from when this phase went quiet, so the seconds reflect the current wait/run.
438
443
  if (!thinking) { thinking = true; thinkingSince = lastContentAt; thinkWord = pickWord(); }
439
444
  const secs = thinkSecs();
440
445
  if (secs % 12 === 0) thinkWord = pickWord(); // rotate the word on long waits
441
- process.stdout.write(CLEAR_LINE + yellow(`${thinkWord} thinking… (${secs}s)`));
446
+ const glyph = SPINNER[secs % SPINNER.length]; // rotates reads as "in progress"
447
+ // Line 1 = WHAT is happening now: the running command, else the phase (Thinking /
448
+ // Almost done). Line 2 = the playful word (flavor). Truncate so neither wraps.
449
+ const primary = runningCmd
450
+ ? `Running ${runningCmd}`
451
+ : almostDone ? "Almost done" : "Thinking";
452
+ const max = Math.max(24, (process.stdout.columns || 80) - 14);
453
+ const fit = (s) => (s.length > max ? s.slice(0, max - 1) + "…" : s);
454
+ clearStatus();
455
+ process.stdout.write(
456
+ yellow(`${glyph} ${fit(primary)}… (${secs}s)`) + "\n" + dim(` ${thinkWord}…`)
457
+ );
442
458
  statusShown = true;
443
459
  };
444
460
  const ticker = setInterval(tick, 1000);
445
461
 
446
- // Close out a "thinking…" phase right before real output prints. A model streams in
447
- // small bursts with natural micro-pauses between them (token batching, network jitter);
448
- // each pause >700ms would otherwise stamp its OWN "✔ completed thought" line, stacking
449
- // many near-duplicates through a single Thought/code generation. `stampIfThinking()` —
450
- // called ONLY at the <code> tag transition, where nothing prints right after it — HOLDS
451
- // the stamp as the current status line (like the ticker): a later completion overwrites
452
- // it in place instead of stacking a new line. `onContent()` — called immediately before
453
- // any real content prints — always COMMITS whatever is pending (a fresh stamp or a held
454
- // one) with a trailing newline first, so content never lands on the same line as a stamp.
455
- let pendingStamp = false;
456
- const stampIfThinking = () => {
457
- if (thinking) {
458
- process.stdout.write(CLEAR_LINE + dim(`✔ completed thought (${thinkSecs()}s)`));
459
- thinking = false;
460
- statusShown = true;
461
- pendingStamp = true;
462
- }
463
- lastContentAt = Date.now();
464
- };
462
+ // Called right before any real (bullet/edit-card/answer) content prints: drop the
463
+ // status lines so content lands cleanly, and end the current phase (so the next phase's
464
+ // seconds count fresh, and any running command is considered finished).
465
465
  const onContent = () => {
466
- if (thinking) {
467
- process.stdout.write(CLEAR_LINE + dim(`✔ completed thought (${thinkSecs()}s)`) + "\n");
468
- thinking = false;
469
- } else if (pendingStamp) {
470
- process.stdout.write("\n"); // commit the held stamp; content prints right after
471
- } else {
472
- clearStatus();
473
- }
474
- pendingStamp = false;
475
- statusShown = false;
466
+ clearStatus();
467
+ thinking = false;
468
+ runningCmd = null;
476
469
  lastContentAt = Date.now();
477
470
  };
478
471
 
@@ -483,6 +476,12 @@ async function runAgentTurn(history) {
483
476
  // blank lines keep spacing; everything else commits dim to scrollback.
484
477
  let inCode = false;
485
478
  let inEditCard = false; // inside a colored edit-card block (see isEditCardHeader)
479
+ // The worker wraps the raw model token stream (Thought prose + code) in ~~~ fences;
480
+ // the concrete ACTION events (tool steps, edit cards) are emitted OUTSIDE them. We
481
+ // suppress everything IN a fence — the rambling Thought and redundant raw code (the
482
+ // edit card already shows real changes) — and render the out-of-fence actions as
483
+ // bullets. This is the whole "action bullets only" declutter (task #41).
484
+ let inStreamFence = false;
486
485
  let lastWasBlank = true; // suppresses a leading blank line and collapses repeats
487
486
  // Chars of the CURRENT trailing (newline-less) `carry` line already flushed live — lets
488
487
  // a long plain-reply answer stream character-by-character instead of waiting for a "\n"
@@ -496,20 +495,26 @@ async function runAgentTurn(history) {
496
495
  carry = carry.slice(nl + 1);
497
496
  const alreadyFlushed = carryFlushedLen;
498
497
  carryFlushedLen = 0; // reset — `carry` now starts a fresh pending line
499
- if (isHeartbeatLine(line) || isFenceLine(line)) continue; // swallow, no trace
498
+ if (isHeartbeatLine(line)) {
499
+ // Heartbeats are swallowed (the local ticker drives the display), but they
500
+ // still carry ONE bit we want: once the worker's word is "…almost completed
501
+ // thinking…", this model call is past prompt-eval and producing output — flip
502
+ // the local ticker to the same wording. A plain random-word heartbeat means
503
+ // we're back to waiting (a fresh step's prompt-eval), so clear the flag.
504
+ almostDone = /almost/i.test(line);
505
+ continue;
506
+ }
507
+ if (isFenceLine(line)) { inStreamFence = !inStreamFence; continue; }
508
+ // Inside a ~~~ fence = raw model Thought/code stream → suppressed entirely. Do NOT
509
+ // bump lastContentAt: while the (now hidden) tokens flow, the blinking bullet keeps
510
+ // ticking so the user sees the step is still being worked on.
511
+ if (inStreamFence) continue;
500
512
  if (isToolStepSummary(line)) { lastContentAt = Date.now(); continue; } // swallow
501
513
  if (isRoutingLine(line)) {
502
- // Replace the whole routing play-by-play with ONE friendly line, shown once
503
- // per turn the raw labels are implementation detail (visible in the daemon's
504
- // own terminal if you need them).
514
+ // Swallow the router's play-by-play entirely the blinking-bullet ticker (which
515
+ // already shows the playful word + seconds) is the single in-progress indicator,
516
+ // so a separate static "Booting the big brain…" line here would just duplicate it.
505
517
  if (line.trim() === "→ Chat reply") plainReplyFlow = true;
506
- if (!routingAnnounced) {
507
- routingAnnounced = true;
508
- onContent();
509
- process.stdout.write(dim(`${pickWord()}…`) + "\n");
510
- lastWasBlank = false;
511
- }
512
- lastContentAt = Date.now();
513
518
  continue;
514
519
  }
515
520
  // Edit card (a file change made by the agent's edit tools) — rendered as a
@@ -547,7 +552,8 @@ async function runAgentTurn(history) {
547
552
  if (openM) {
548
553
  // Enter code mode (or stay in it on a duplicate tag). Anything glued after
549
554
  // the tag on the same line is code — print it, minus a glued close tag.
550
- stampIfThinking(); // hold/refresh the stamp; the code body commits it below
555
+ // (Reached only for a stray <code> OUTSIDE a ~~~ fence; in-fence code is
556
+ // suppressed above. Kept as a safety net.)
551
557
  inCode = true;
552
558
  let rest = t.slice(openM[0].length).trim();
553
559
  if (CODE_CLOSE_RE.test(rest)) {
@@ -597,13 +603,26 @@ async function runAgentTurn(history) {
597
603
  }
598
604
  continue;
599
605
  }
606
+ // Agent flow: a "Running → <cmd>" is the START of a command — show it as the live
607
+ // in-progress status (the blinking bullet names it) rather than a solid bullet; its
608
+ // completion ("Command passed/failed → …" / "Server up → …") prints the done bullet.
609
+ // So the pair collapses to: "◑ Running <cmd>…" while it runs, then "● <result>".
610
+ if (!plainReplyFlow) {
611
+ const mRun = /^Running → (.+)$/.exec(line.trim());
612
+ if (mRun) { runningCmd = mRun[1]; thinking = false; lastContentAt = Date.now(); continue; }
613
+ }
600
614
  if (alreadyFlushed === 0) onContent();
601
- // Plain-reply content IS the answer (not background narration) — print it in the
602
- // terminal's default color, not dim, and remember it's already visible so the
603
- // caller doesn't print the same text again once the turn completes.
604
- const paint = plainReplyFlow ? (s) => s : dim;
605
- process.stdout.write(paint(line.slice(alreadyFlushed)) + "\n");
606
- if (plainReplyFlow) answerShownLive = true;
615
+ if (plainReplyFlow) {
616
+ // Plain-reply content IS the answer print it in the default color and remember
617
+ // it's visible so the caller doesn't reprint it once the turn completes.
618
+ process.stdout.write(line.slice(alreadyFlushed) + "\n");
619
+ answerShownLive = true;
620
+ } else {
621
+ // The remaining out-of-fence ACTION events ("Command passed → …", "Searching code
622
+ // → …", "Server up → …", "Composing answer…") each print as ONE solid bullet —
623
+ // the clean, summarized action list (task #41).
624
+ process.stdout.write(white(BULLET) + " " + line.trim() + "\n");
625
+ }
607
626
  lastWasBlank = false;
608
627
  }
609
628
  // Plain-reply answers often have NO internal newline at all (a short one-sentence
@@ -42,6 +42,13 @@ _REAL_STDOUT = sys.stdout
42
42
  # a random one is picked per tick so the wait feels alive instead of a fixed string.
43
43
  _THINKING_WORDS: list = []
44
44
 
45
+ # Shown INSTEAD of a random word once the model's first token arrives (prompt-eval done,
46
+ # tokens now flowing) — the long silent wait is over and output is being produced, so the
47
+ # status flips from "still thinking" to "almost done". Ends with "…" so the REPL/web can
48
+ # append "(Ns)". The REPL detects the word "almost" in a heartbeat line to switch its own
49
+ # local ticker to the same wording (see gonext-repl.mjs).
50
+ _STATUS_ALMOST_DONE = "…almost completed thinking…"
51
+
45
52
 
46
53
  def _thinking_word() -> str:
47
54
  """A random playful status word (e.g. 'Caffeinating'). Falls back to a small
@@ -3390,8 +3397,15 @@ def run_agent_chat(cfg):
3390
3397
  nonlocal first_token_at
3391
3398
  if first_token_at is None:
3392
3399
  first_token_at = time.monotonic()
3393
- _log(f"streamed generate: FIRST token after {first_token_at - t0:.1f}s "
3400
+ elapsed = first_token_at - t0
3401
+ _log(f"streamed generate: FIRST token after {elapsed:.1f}s "
3394
3402
  "(prompt-eval done; tokens now flowing)")
3403
+ # Flip the status from "still thinking" to "almost done" the instant
3404
+ # the wait ends — the heartbeat below keeps it there for the rest of
3405
+ # this call (self._first_token_seen), and this one-shot emit shows it
3406
+ # immediately (before the next 45s heartbeat would).
3407
+ self._first_token_seen = True
3408
+ _emit({"type": "step", "text": f"{_STATUS_ALMOST_DONE} ({elapsed:.0f}s)"})
3395
3409
  # Separate this step's live thinking from the previous step summary.
3396
3410
  _emit({"type": "stream", "text": "\n"})
3397
3411
 
@@ -3531,16 +3545,21 @@ def run_agent_chat(cfg):
3531
3545
  # 31B on a remote Ollama box) can take minutes on prompt-eval before the
3532
3546
  # first byte — emit a keepalive step every 45s until the call returns.
3533
3547
  hb_stop = threading.Event()
3548
+ # Reset per call: True once this call's first token arrives (set in
3549
+ # _mark_first), read by the heartbeat below to switch its wording.
3550
+ self._first_token_seen = False
3534
3551
 
3535
3552
  def _heartbeat():
3536
3553
  waited = 0
3537
3554
  while not hb_stop.wait(45):
3538
3555
  waited += 45
3539
- # A random playful word each tick (e.g. "Caffeinating… (90s)") instead
3540
- # of a fixed "still working" line a large or cold-loading model can
3541
- # legitimately take a few minutes on prompt-eval.
3542
- _emit({"type": "step",
3543
- "text": f"{_thinking_word()}… ({waited}s)"})
3556
+ # Before the first token: a random playful word ("Caffeinating… (90s)")
3557
+ # a large/cold model can take minutes on prompt-eval. After the first
3558
+ # token: "…almost completed thinking… (Ns)", since output is now flowing
3559
+ # and the call is in its finishing phase, not stuck waiting.
3560
+ word = (_STATUS_ALMOST_DONE if getattr(self, "_first_token_seen", False)
3561
+ else f"{_thinking_word()}…")
3562
+ _emit({"type": "step", "text": f"{word} ({waited}s)"})
3544
3563
  _log(f"heartbeat: model call in flight {waited}s")
3545
3564
 
3546
3565
  hb = threading.Thread(target=_heartbeat, daemon=True)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.202",
3
+ "version": "1.0.204",
4
4
  "description": "Polls GoNext cloud API for async local LLM jobs and runs them against Ollama/OpenAI-compatible servers on this Mac",
5
5
  "type": "module",
6
6
  "license": "MIT",