@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 +84 -65
- package/gonext_agent_chat.py +25 -6
- package/package.json +1 -1
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 "
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
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)
|
|
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 —
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
};
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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)
|
|
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
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
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.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",
|