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

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
@@ -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);
@@ -406,17 +409,17 @@ async function runAgentTurn(history) {
406
409
  // until a full line is known, or we'd risk showing part of something we meant to hide.
407
410
  let plainReplyFlow = false;
408
411
 
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.
412
+ // --- Live in-progress ("blinking") bullet -------------------------------------------
413
+ // The agent's raw Thought/code stream is SUPPRESSED (see the ~~~-fence handling in
414
+ // consume) the terminal shows only a clean list of action bullets. While a step is
415
+ // being thought about / run, we show ONE in-place blinking bullet that counts up
416
+ // "◐ working… (7s)" instead of dumping the model's rambling reasoning and stamping a
417
+ // "✔ completed thought" on every token-stream micro-pause (the reported clutter).
415
418
  const QUIET_MS = 700; // stream idle this long ⇒ we're waiting on the model
416
419
  let lastContentAt = Date.now();
417
420
  let thinking = false;
418
421
  let thinkingSince = 0;
419
- let thinkWord = pickWord();
422
+ let almostDone = false; // set from the worker's "…almost completed thinking…" heartbeat
420
423
  const thinkSecs = () => Math.max(1, Math.round((Date.now() - thinkingSince) / 1000));
421
424
 
422
425
  const clearStatus = () => {
@@ -427,52 +430,25 @@ async function runAgentTurn(history) {
427
430
  if (!following || followAborted || !jobRunning) return;
428
431
  if (Date.now() - lastContentAt < QUIET_MS) return; // tokens still flowing
429
432
  // 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.
433
+ // done — overwriting the ticker there corrupts the visible answer mid-word. Stay
434
+ // silent once any of THIS line has already been shown (see carryFlushedLen).
435
435
  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.
438
- if (!thinking) { thinking = true; thinkingSince = lastContentAt; thinkWord = pickWord(); }
436
+ // Count from when the stream went quiet, so the seconds reflect the true wait.
437
+ if (!thinking) { thinking = true; thinkingSince = lastContentAt; }
439
438
  const secs = thinkSecs();
440
- if (secs % 12 === 0) thinkWord = pickWord(); // rotate the word on long waits
441
- process.stdout.write(CLEAR_LINE + yellow(`${thinkWord} thinking… (${secs}s)`));
439
+ const glyph = SPINNER[secs % SPINNER.length]; // rotates reads as "in progress"
440
+ const label = almostDone ? "almost done" : "working";
441
+ process.stdout.write(CLEAR_LINE + yellow(`${glyph} ${label}… (${secs}s)`));
442
442
  statusShown = true;
443
443
  };
444
444
  const ticker = setInterval(tick, 1000);
445
445
 
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
- };
446
+ // Called right before any real (bullet/edit-card/answer) content prints: drop the
447
+ // blinking-bullet status line so content lands cleanly on its own line. No "completed
448
+ // thought" stamp each finished action prints its own solid bullet instead.
465
449
  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;
450
+ clearStatus();
451
+ if (thinking) { process.stdout.write(CLEAR_LINE); thinking = false; }
476
452
  lastContentAt = Date.now();
477
453
  };
478
454
 
@@ -483,6 +459,12 @@ async function runAgentTurn(history) {
483
459
  // blank lines keep spacing; everything else commits dim to scrollback.
484
460
  let inCode = false;
485
461
  let inEditCard = false; // inside a colored edit-card block (see isEditCardHeader)
462
+ // The worker wraps the raw model token stream (Thought prose + code) in ~~~ fences;
463
+ // the concrete ACTION events (tool steps, edit cards) are emitted OUTSIDE them. We
464
+ // suppress everything IN a fence — the rambling Thought and redundant raw code (the
465
+ // edit card already shows real changes) — and render the out-of-fence actions as
466
+ // bullets. This is the whole "action bullets only" declutter (task #41).
467
+ let inStreamFence = false;
486
468
  let lastWasBlank = true; // suppresses a leading blank line and collapses repeats
487
469
  // Chars of the CURRENT trailing (newline-less) `carry` line already flushed live — lets
488
470
  // a long plain-reply answer stream character-by-character instead of waiting for a "\n"
@@ -496,7 +478,20 @@ async function runAgentTurn(history) {
496
478
  carry = carry.slice(nl + 1);
497
479
  const alreadyFlushed = carryFlushedLen;
498
480
  carryFlushedLen = 0; // reset — `carry` now starts a fresh pending line
499
- if (isHeartbeatLine(line) || isFenceLine(line)) continue; // swallow, no trace
481
+ if (isHeartbeatLine(line)) {
482
+ // Heartbeats are swallowed (the local ticker drives the display), but they
483
+ // still carry ONE bit we want: once the worker's word is "…almost completed
484
+ // thinking…", this model call is past prompt-eval and producing output — flip
485
+ // the local ticker to the same wording. A plain random-word heartbeat means
486
+ // we're back to waiting (a fresh step's prompt-eval), so clear the flag.
487
+ almostDone = /almost/i.test(line);
488
+ continue;
489
+ }
490
+ if (isFenceLine(line)) { inStreamFence = !inStreamFence; continue; }
491
+ // Inside a ~~~ fence = raw model Thought/code stream → suppressed entirely. Do NOT
492
+ // bump lastContentAt: while the (now hidden) tokens flow, the blinking bullet keeps
493
+ // ticking so the user sees the step is still being worked on.
494
+ if (inStreamFence) continue;
500
495
  if (isToolStepSummary(line)) { lastContentAt = Date.now(); continue; } // swallow
501
496
  if (isRoutingLine(line)) {
502
497
  // Replace the whole routing play-by-play with ONE friendly line, shown once
@@ -547,7 +542,8 @@ async function runAgentTurn(history) {
547
542
  if (openM) {
548
543
  // Enter code mode (or stay in it on a duplicate tag). Anything glued after
549
544
  // 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
545
+ // (Reached only for a stray <code> OUTSIDE a ~~~ fence; in-fence code is
546
+ // suppressed above. Kept as a safety net.)
551
547
  inCode = true;
552
548
  let rest = t.slice(openM[0].length).trim();
553
549
  if (CODE_CLOSE_RE.test(rest)) {
@@ -598,12 +594,18 @@ async function runAgentTurn(history) {
598
594
  continue;
599
595
  }
600
596
  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;
597
+ if (plainReplyFlow) {
598
+ // Plain-reply content IS the answer print it in the default color and remember
599
+ // it's visible so the caller doesn't reprint it once the turn completes.
600
+ process.stdout.write(line.slice(alreadyFlushed) + "\n");
601
+ answerShownLive = true;
602
+ } else {
603
+ // Agent flow: with the raw Thought/code stream suppressed above, the only lines
604
+ // that reach here are the worker's out-of-fence ACTION events ("Running → npx…",
605
+ // "Searching code → import (11 hits)", "Server up → …", "Composing answer…").
606
+ // Render each as ONE solid bullet — the clean, summarized action list (task #41).
607
+ process.stdout.write(green(BULLET) + " " + line.trim() + "\n");
608
+ }
607
609
  lastWasBlank = false;
608
610
  }
609
611
  // 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.203",
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",