@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 +56 -54
- package/gonext_agent_chat.py +25 -6
- package/package.json +1 -1
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 "
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
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
|
|
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 —
|
|
431
|
-
//
|
|
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
|
|
437
|
-
|
|
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
|
-
|
|
441
|
-
|
|
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
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
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
|
-
|
|
467
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
package/gonext_agent_chat.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
3540
|
-
#
|
|
3541
|
-
#
|
|
3542
|
-
|
|
3543
|
-
|
|
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.
|
|
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",
|