@tiens.nguyen/gonext-local-worker 1.0.187 → 1.0.189

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.
@@ -1815,6 +1815,7 @@ async function runAgentChatJob(job) {
1815
1815
 
1816
1816
  let inThink = false;
1817
1817
  let finalText = "";
1818
+ let answerStreamed = false; // an answer_stream delta already delivered the visible text
1818
1819
  // Raw model-stream tokens (event.type === "stream") are Python code + `Thought:`
1819
1820
  // prose + <code>/<|"|> fragments. The web Thought panel renders reasoning as
1820
1821
  // markdown, which turns `#`-prefixed code comments into <h1> headings and
@@ -1866,6 +1867,20 @@ async function runAgentChatJob(job) {
1866
1867
  enqueueText("<think>");
1867
1868
  }
1868
1869
  enqueueText(event.text + "\n\n");
1870
+ } else if (event.type === "answer_stream" && typeof event.text === "string") {
1871
+ // Live delta of the FINAL user-facing answer (the plain-chat fast path streams
1872
+ // its reply as it generates, instead of blocking for the whole thing) — this is
1873
+ // the actual answer, NOT reasoning, so it must stay OUTSIDE <think> (wrapping it
1874
+ // would hide it in the web's collapsed Thinking panel instead of the live main
1875
+ // answer bubble). Close any open think/stream fence first in case a prior step
1876
+ // left one dangling.
1877
+ closeStreamFence();
1878
+ if (inThink) {
1879
+ inThink = false;
1880
+ enqueueText("</think>");
1881
+ }
1882
+ enqueueText(event.text);
1883
+ answerStreamed = true;
1869
1884
  } else if (event.type === "final" && typeof event.text === "string") {
1870
1885
  closeStreamFence();
1871
1886
  if (inThink) {
@@ -1873,7 +1888,12 @@ async function runAgentChatJob(job) {
1873
1888
  enqueueText("</think>");
1874
1889
  }
1875
1890
  finalText = event.text;
1876
- enqueueText(event.text);
1891
+ // If the answer was already delivered live via answer_stream deltas, its text is
1892
+ // ALREADY in resultText — appending it again here would duplicate the whole reply
1893
+ // (streamed once, then dumped again in full). Only append when nothing streamed it.
1894
+ if (!answerStreamed) {
1895
+ enqueueText(event.text);
1896
+ }
1877
1897
  }
1878
1898
  }, pdfEnv);
1879
1899
 
package/gonext-repl.mjs CHANGED
@@ -53,6 +53,16 @@ const isCodeCloseLine = (line) => line.trim() === "</code>";
53
53
  // "unzip_file(...) | → Unzipped 256 files…") duplicates what's already visible in the
54
54
  // gonext-local-worker daemon's own terminal — swallow it here to keep the REPL terse.
55
55
  const isToolStepSummary = (line) => / \| → /.test(line);
56
+ // The router's internal play-by-play ("Routing your request…", "→ Agent mode (needs
57
+ // tools)", "→ Chat reply", "Choosing a tool…", "Composing a reply…") is implementation
58
+ // detail, not something a user needs — also visible in the daemon's own terminal.
59
+ // Swallowed; see routingAnnounced. "Composing a reply…" specifically must be included so
60
+ // it doesn't print (undimmed, since plainReplyFlow is already true by then) right before
61
+ // the real streamed answer, where it would visually blend into the answer text.
62
+ const isRoutingLine = (line) =>
63
+ /^(Routing your request…|Choosing a tool…|Composing a reply…|→ Agent mode.*|→ Chat reply)$/.test(
64
+ line.trim()
65
+ );
56
66
 
57
67
  // Playful words for the local "thinking…" ticker, reused verbatim from the worker's
58
68
  // thinking_words.txt sibling (falls back to a tiny built-in list if it's missing).
@@ -106,7 +116,17 @@ if (!workerKey || !apiBase) {
106
116
  // awaits (workspace prompt fetch, payload probe) are queued instead of lost — vital
107
117
  // for piped/scripted input, harmless for interactive TTYs. Both ask() and the main
108
118
  // loop consume from this queue (never rl.question, to avoid double-capture).
109
- const rl = createInterface({ input: process.stdin, output: process.stdout });
119
+ //
120
+ // terminal: false — we draw our OWN prompt (cyan ">> ") and colorize output ourselves;
121
+ // readline's default terminal:true (auto-enabled whenever output is a TTY) makes it ALSO
122
+ // track cursor position and redraw the input line on every keystroke using ITS OWN
123
+ // (empty, since we never call rl.setPrompt/rl.prompt) prompt — that tracking goes stale
124
+ // the moment we write anything readline doesn't know about (which is everything we print
125
+ // between prompts), and its next redraw emits a stray leading "[" (a malformed/partial
126
+ // cursor-position escape) right before typed input, e.g. "[>> hi". With terminal:false,
127
+ // readline stops managing the line/cursor itself; the OS's own canonical-mode terminal
128
+ // still echoes keystrokes normally, and 'line' events fire exactly the same.
129
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false });
110
130
  const _lineQueue = [];
111
131
  let _lineWaiter = null;
112
132
  let _stdinClosed = false;
@@ -256,6 +276,15 @@ async function runAgentTurn(history) {
256
276
  let statusShown = false; // a transient status line is currently on screen
257
277
  let warnedPending = false;
258
278
  let jobRunning = false; // only tick "thinking…" once the worker has claimed the job
279
+ let answerShownLive = false; // plain-reply content already printed — don't re-print it
280
+ let routingAnnounced = false; // one friendly line replaces the whole routing play-by-play
281
+ // True once we've SPECIFICALLY seen "→ Chat reply" (the plain-reply/trivial-chat
282
+ // branch), as opposed to "→ Agent mode…" (the tool-use branch). Only the plain-reply
283
+ // path streams its answer via answer_stream with NO heartbeat/tool-summary/code lines
284
+ // ever interleaved — so only THERE is it safe to partial-flush trailing content live
285
+ // (see carryFlushedLen below); the agent path's code/tool-call lines must stay buffered
286
+ // until a full line is known, or we'd risk showing part of something we meant to hide.
287
+ let plainReplyFlow = false;
259
288
 
260
289
  // --- Live "thinking…" ticker ---------------------------------------------------
261
290
  // Between visible tokens (prompt-eval / awaiting the first token) we show ONE in-place
@@ -326,14 +355,35 @@ async function runAgentTurn(history) {
326
355
  // them prints in blue (not dim, so it stands out from "Thought:" prose); blank lines
327
356
  // keep spacing; everything else commits dim to scrollback.
328
357
  let inCode = false;
358
+ let lastWasBlank = true; // suppresses a leading blank line and collapses repeats
359
+ // Chars of the CURRENT trailing (newline-less) `carry` line already flushed live — lets
360
+ // a long plain-reply answer stream character-by-character instead of waiting for a "\n"
361
+ // that might not arrive until the very end (most short replies have none at all).
362
+ let carryFlushedLen = 0;
329
363
  const consume = (chunk) => {
330
364
  carry += stripThinkTags(chunk);
331
365
  let nl;
332
366
  while ((nl = carry.indexOf("\n")) >= 0) {
333
367
  const line = carry.slice(0, nl);
334
368
  carry = carry.slice(nl + 1);
369
+ const alreadyFlushed = carryFlushedLen;
370
+ carryFlushedLen = 0; // reset — `carry` now starts a fresh pending line
335
371
  if (isHeartbeatLine(line) || isFenceLine(line)) continue; // swallow, no trace
336
372
  if (isToolStepSummary(line)) { lastContentAt = Date.now(); continue; } // swallow
373
+ if (isRoutingLine(line)) {
374
+ // Replace the whole routing play-by-play with ONE friendly line, shown once
375
+ // per turn — the raw labels are implementation detail (visible in the daemon's
376
+ // own terminal if you need them).
377
+ if (line.trim() === "→ Chat reply") plainReplyFlow = true;
378
+ if (!routingAnnounced) {
379
+ routingAnnounced = true;
380
+ onContent();
381
+ process.stdout.write(dim(`${pickWord()}…`) + "\n");
382
+ lastWasBlank = false;
383
+ }
384
+ lastContentAt = Date.now();
385
+ continue;
386
+ }
337
387
  if (!inCode && isCodeOpenLine(line)) {
338
388
  stampIfThinking(); // hold/refresh the stamp; the code body commits it below
339
389
  inCode = true;
@@ -344,14 +394,37 @@ async function runAgentTurn(history) {
344
394
  if (line.trim() === "") { lastContentAt = Date.now(); continue; } // skip blank lines in code
345
395
  onContent();
346
396
  process.stdout.write(blue(line) + "\n");
397
+ lastWasBlank = false;
347
398
  continue;
348
399
  }
349
400
  if (line.trim() === "") {
350
- if (!thinking && !statusShown) process.stdout.write("\n");
401
+ // Several sources (swallowed routing lines, swallowed step summaries) each leave
402
+ // their own blank-line companion behind — collapse consecutive blanks into one.
403
+ if (!thinking && !statusShown && !lastWasBlank) {
404
+ process.stdout.write("\n");
405
+ lastWasBlank = true;
406
+ }
351
407
  continue;
352
408
  }
353
- onContent();
354
- process.stdout.write(dim(line) + "\n");
409
+ if (alreadyFlushed === 0) onContent();
410
+ // Plain-reply content IS the answer (not background narration) print it in the
411
+ // terminal's default color, not dim, and remember it's already visible so the
412
+ // caller doesn't print the same text again once the turn completes.
413
+ const paint = plainReplyFlow ? (s) => s : dim;
414
+ process.stdout.write(paint(line.slice(alreadyFlushed)) + "\n");
415
+ if (plainReplyFlow) answerShownLive = true;
416
+ lastWasBlank = false;
417
+ }
418
+ // Plain-reply answers often have NO internal newline at all (a short one-sentence
419
+ // reply) and can take many seconds to generate — without this, nothing would print
420
+ // until the whole thing finally lands, right back to the "wait then dump" problem
421
+ // this was meant to fix. Only safe in the plain-reply flow (see plainReplyFlow above).
422
+ if (plainReplyFlow && !inCode && carry.length > carryFlushedLen) {
423
+ if (carryFlushedLen === 0) onContent();
424
+ process.stdout.write(carry.slice(carryFlushedLen)); // default color — this IS the answer
425
+ carryFlushedLen = carry.length;
426
+ answerShownLive = true;
427
+ lastWasBlank = false;
355
428
  }
356
429
  // A non-empty remainder is a partial CONTENT line (heartbeats always arrive whole with
357
430
  // a newline), so tokens are actively flowing — keep the ticker asleep.
@@ -365,7 +438,7 @@ async function runAgentTurn(history) {
365
438
  process.stdout.write(
366
439
  dim("\n(stopped following — the job keeps running on the worker)\n")
367
440
  );
368
- return "";
441
+ return { text: "", shown: false };
369
442
  }
370
443
  const jr = await fetch(`${apiBase}/api/worker/jobs/${jobId}`, {
371
444
  headers: { "X-Worker-Key": workerKey },
@@ -375,10 +448,11 @@ async function runAgentTurn(history) {
375
448
  jobRunning = job.jobStatus === "running";
376
449
  const text = typeof job.resultText === "string" ? job.resultText : "";
377
450
  if (job.jobStatus === "completed") {
378
- // Drop the transient status line and any partial reasoning tail; the bright answer
379
- // prints right after via the caller.
451
+ // Drop the transient status line and any partial reasoning tail. If this was a
452
+ // plain reply, its text is ALREADY fully visible (streamed live above) — the
453
+ // caller must not print it again; `shown` tells it so.
380
454
  clearStatus();
381
- return answerFrom(text);
455
+ return { text: answerFrom(text), shown: answerShownLive };
382
456
  }
383
457
  if (text.length > shownChars) {
384
458
  consume(text.slice(shownChars));
@@ -430,7 +504,10 @@ async function main() {
430
504
  console.log(dim("Ask about this repo, or /help. Ctrl-C aborts a running turn.\n"));
431
505
 
432
506
  const history = [];
433
- rl.on("SIGINT", () => {
507
+ // With terminal:false, Ctrl-C is no longer intercepted by readline's raw-mode byte
508
+ // scanning (rl.on("SIGINT", …) would never fire) — it now delivers a real OS SIGINT to
509
+ // the process instead, which we catch directly here. Same behavior as before.
510
+ process.on("SIGINT", () => {
434
511
  if (following) {
435
512
  followAborted = true;
436
513
  process.stdout.write(red("\n^C "));
@@ -472,10 +549,12 @@ async function main() {
472
549
  }
473
550
  history.push({ role: "user", content: line });
474
551
  try {
475
- const answer = await runAgentTurn(history);
552
+ const { text: answer, shown } = await runAgentTurn(history);
476
553
  if (answer) {
477
554
  history.push({ role: "assistant", content: answer });
478
- console.log(`\n\n${answer}\n`);
555
+ // A plain-reply answer already streamed live in the loop above — printing it
556
+ // again here would just duplicate it; a blank line is enough for spacing.
557
+ console.log(shown ? "" : `\n\n${answer}\n`);
479
558
  } else {
480
559
  history.pop(); // aborted/failed turn — don't poison the next request's history
481
560
  console.log(red("\n(no answer — run aborted or failed)\n"));
@@ -690,6 +690,43 @@ def _chat_create(client, **kwargs):
690
690
  raise
691
691
 
692
692
 
693
+ def _chat_create_stream(client, on_delta, **kwargs) -> str:
694
+ """Like _chat_create but STREAMS the completion: on_delta(text) is called for each
695
+ content piece AS IT ARRIVES (so the caller can emit it live), and the full assembled
696
+ text is returned once the stream ends. Applies the same mlx_lm model-not-found retry
697
+ as _chat_create — that failure always happens at/near the very first network
698
+ round-trip (before any token streams), so a clean retry from scratch never risks
699
+ re-emitting a partial answer twice."""
700
+ kwargs = dict(kwargs)
701
+ kwargs["stream"] = True
702
+
703
+ def _run(kw):
704
+ stream = client.chat.completions.create(**kw)
705
+ parts = []
706
+ for chunk in stream:
707
+ choices = getattr(chunk, "choices", None)
708
+ delta = getattr(choices[0], "delta", None) if choices else None
709
+ piece = getattr(delta, "content", None) if delta else None
710
+ if piece:
711
+ parts.append(piece)
712
+ on_delta(piece)
713
+ return "".join(parts)
714
+
715
+ try:
716
+ return _run(kwargs)
717
+ except Exception as e: # noqa: BLE001
718
+ if kwargs.get("model") != "default_model" and _is_model_not_found_err(e):
719
+ _log(f"model {kwargs.get('model')!r} not found on server; retrying as "
720
+ "'default_model' (mlx_lm preloaded model)")
721
+ try:
722
+ kwargs2 = dict(kwargs)
723
+ kwargs2["model"] = "default_model"
724
+ return _run(kwargs2)
725
+ except Exception: # noqa: BLE001
726
+ raise e # surface the clearer original error, not the retry's
727
+ raise
728
+
729
+
693
730
  def _route(task_text: str, base_url: str, api_key: str, model_id: str) -> bool:
694
731
  """Decide if the task needs the HTTP agent (True) or a plain chat reply (False).
695
732
 
@@ -787,9 +824,12 @@ def _summarize_result(task_text: str, agent_output: str,
787
824
  def _plain_reply(messages: list, base_url: str, api_key: str, model_id: str,
788
825
  fallback_base_url: str = "", fallback_model_id: str = "") -> str:
789
826
  """Plain chat completion using the full conversation history — a SMALL prompt (no tool
790
- preamble), so it returns fast. If the primary model is unreachable (e.g. the local MLX
791
- server is down) and a distinct fallback model is given (the coding model), retry there
792
- so a greeting still gets answered instead of erroring."""
827
+ preamble), so it returns fast. STREAMS the answer live via {"type":"answer_stream"}
828
+ deltas as the model generates it (instead of blocking until the whole reply is ready
829
+ and delivering it in one burst) the worker renders these as the live-updating MAIN
830
+ answer, not the collapsible Thinking panel. If the primary model is unreachable (e.g.
831
+ the local MLX server is down) and a distinct fallback model is given (the coding
832
+ model), retry there so a greeting still gets answered instead of erroring."""
793
833
  _THINK_RE_LOCAL = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
794
834
  chat_messages = [{"role": "system", "content": "You are a helpful assistant."}]
795
835
  for m in messages:
@@ -812,20 +852,30 @@ def _plain_reply(messages: list, base_url: str, api_key: str, model_id: str,
812
852
 
813
853
  last_err = None
814
854
  for i, (b, mid) in enumerate(targets):
855
+ emitted_here = False
856
+
857
+ def _track(piece):
858
+ nonlocal emitted_here
859
+ emitted_here = True
860
+ _emit({"type": "answer_stream", "text": piece})
861
+
815
862
  try:
816
863
  from openai import OpenAI
817
864
  client = OpenAI(base_url=b, api_key=api_key or "local",
818
865
  max_retries=0, timeout=60)
819
- resp = _chat_create(
820
- client,
866
+ return _chat_create_stream(
867
+ client, _track,
821
868
  model=mid,
822
869
  messages=chat_messages,
823
870
  temperature=0.7,
824
871
  max_tokens=512,
825
- )
826
- return (resp.choices[0].message.content or "").strip()
872
+ ).strip()
827
873
  except Exception as e: # noqa: BLE001
828
874
  last_err = e
875
+ if emitted_here:
876
+ # Part of THIS attempt's answer already streamed to the user — don't
877
+ # silently splice a DIFFERENT model's output after a partial one.
878
+ break
829
879
  if i + 1 < len(targets):
830
880
  nb, nmid = targets[i + 1] # the model we're about to try next
831
881
  _log(f"plain reply: {mid} @ {b} failed ({e}); "
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.187",
3
+ "version": "1.0.189",
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",