@tiens.nguyen/gonext-local-worker 1.0.186 → 1.0.188

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.
Files changed (2) hide show
  1. package/gonext-repl.mjs +95 -20
  2. package/package.json +1 -1
package/gonext-repl.mjs CHANGED
@@ -36,6 +36,7 @@ const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
36
36
  const green = (s) => `\x1b[32m${s}\x1b[0m`;
37
37
  const red = (s) => `\x1b[31m${s}\x1b[0m`;
38
38
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
39
+ const blue = (s) => `\x1b[34m${s}\x1b[0m`;
39
40
  const CLEAR_LINE = "\r\x1b[K"; // carriage-return + erase-to-end-of-line
40
41
 
41
42
  // The worker's "still thinking" heartbeat lines look like "Caffeinating… (90s)" — we
@@ -43,11 +44,20 @@ const CLEAR_LINE = "\r\x1b[K"; // carriage-return + erase-to-end-of-line
43
44
  // also drop the worker's ~~~ stream fences (they only matter to the web renderer).
44
45
  const isHeartbeatLine = (line) => /…\s?\(\d+s\)\s*$/.test(line);
45
46
  const isFenceLine = (line) => line.trim() === "~~~";
46
- // The model's own raw Python code blob, delimited by <code>/</code> lines in its
47
- // Thought+Code output. It's redundant with the step summary that follows it (e.g.
48
- // "Downloading url"), so we don't print it — only the "Thought:" prose survives.
47
+ // The model's raw Python code blob is delimited by <code>/</code> lines. We don't print
48
+ // the tag lines themselves, but the code IN BETWEEN prints in a distinct color (blue)
49
+ // instead of dim prose, so it visually separates from the "Thought:" reasoning.
49
50
  const isCodeOpenLine = (line) => line.trim() === "<code>";
50
51
  const isCodeCloseLine = (line) => line.trim() === "</code>";
52
+ // The step summary the worker emits right after a tool call finishes (e.g.
53
+ // "unzip_file(...) | → Unzipped 256 files…") duplicates what's already visible in the
54
+ // gonext-local-worker daemon's own terminal — swallow it here to keep the REPL terse.
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…") is implementation detail, not something a
58
+ // user needs — also visible in the daemon's own terminal. Swallowed; see routingAnnounced.
59
+ const isRoutingLine = (line) =>
60
+ /^(Routing your request…|Choosing a tool…|→ Agent mode.*|→ Chat reply)$/.test(line.trim());
51
61
 
52
62
  // Playful words for the local "thinking…" ticker, reused verbatim from the worker's
53
63
  // thinking_words.txt sibling (falls back to a tiny built-in list if it's missing).
@@ -101,7 +111,17 @@ if (!workerKey || !apiBase) {
101
111
  // awaits (workspace prompt fetch, payload probe) are queued instead of lost — vital
102
112
  // for piped/scripted input, harmless for interactive TTYs. Both ask() and the main
103
113
  // loop consume from this queue (never rl.question, to avoid double-capture).
104
- const rl = createInterface({ input: process.stdin, output: process.stdout });
114
+ //
115
+ // terminal: false — we draw our OWN prompt (cyan ">> ") and colorize output ourselves;
116
+ // readline's default terminal:true (auto-enabled whenever output is a TTY) makes it ALSO
117
+ // track cursor position and redraw the input line on every keystroke using ITS OWN
118
+ // (empty, since we never call rl.setPrompt/rl.prompt) prompt — that tracking goes stale
119
+ // the moment we write anything readline doesn't know about (which is everything we print
120
+ // between prompts), and its next redraw emits a stray leading "[" (a malformed/partial
121
+ // cursor-position escape) right before typed input, e.g. "[>> hi". With terminal:false,
122
+ // readline stops managing the line/cursor itself; the OS's own canonical-mode terminal
123
+ // still echoes keystrokes normally, and 'line' events fire exactly the same.
124
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false });
105
125
  const _lineQueue = [];
106
126
  let _lineWaiter = null;
107
127
  let _stdinClosed = false;
@@ -251,6 +271,7 @@ async function runAgentTurn(history) {
251
271
  let statusShown = false; // a transient status line is currently on screen
252
272
  let warnedPending = false;
253
273
  let jobRunning = false; // only tick "thinking…" once the worker has claimed the job
274
+ let routingAnnounced = false; // one friendly line replaces the whole routing play-by-play
254
275
 
255
276
  // --- Live "thinking…" ticker ---------------------------------------------------
256
277
  // Between visible tokens (prompt-eval / awaiting the first token) we show ONE in-place
@@ -282,48 +303,92 @@ async function runAgentTurn(history) {
282
303
  };
283
304
  const ticker = setInterval(tick, 1000);
284
305
 
285
- // Close out a "thinking…" phase right before real output prints.
306
+ // Close out a "thinking…" phase right before real output prints. A model streams in
307
+ // small bursts with natural micro-pauses between them (token batching, network jitter);
308
+ // each pause >700ms would otherwise stamp its OWN "✔ completed thought" line, stacking
309
+ // many near-duplicates through a single Thought/code generation. `stampIfThinking()` —
310
+ // called ONLY at the <code> tag transition, where nothing prints right after it — HOLDS
311
+ // the stamp as the current status line (like the ticker): a later completion overwrites
312
+ // it in place instead of stacking a new line. `onContent()` — called immediately before
313
+ // any real content prints — always COMMITS whatever is pending (a fresh stamp or a held
314
+ // one) with a trailing newline first, so content never lands on the same line as a stamp.
315
+ let pendingStamp = false;
316
+ const stampIfThinking = () => {
317
+ if (thinking) {
318
+ process.stdout.write(CLEAR_LINE + dim(`✔ completed thought (${thinkSecs()}s)`));
319
+ thinking = false;
320
+ statusShown = true;
321
+ pendingStamp = true;
322
+ }
323
+ lastContentAt = Date.now();
324
+ };
286
325
  const onContent = () => {
287
326
  if (thinking) {
288
327
  process.stdout.write(CLEAR_LINE + dim(`✔ completed thought (${thinkSecs()}s)`) + "\n");
289
328
  thinking = false;
290
- statusShown = false;
329
+ } else if (pendingStamp) {
330
+ process.stdout.write("\n"); // commit the held stamp; content prints right after
291
331
  } else {
292
332
  clearStatus();
293
333
  }
334
+ pendingStamp = false;
335
+ statusShown = false;
294
336
  lastContentAt = Date.now();
295
337
  };
296
338
 
297
- // Split newly-arrived text into lines: heartbeats + ~~~ fences are swallowed (the ticker
298
- // shows progress); the model's raw <code>…</code> blob is swallowed too (redundant with
299
- // the step summary that follows it, e.g. "Downloading url"); blank lines keep spacing;
300
- // everything else (the "Thought:" prose) commits dim to scrollback.
339
+ // Split newly-arrived text into lines: heartbeats + ~~~ fences + the post-tool-call step
340
+ // summary ("tool(...) | result", redundant with the daemon's own terminal) are
341
+ // swallowed; the model's <code>/</code> TAG lines are hidden but the code body between
342
+ // them prints in blue (not dim, so it stands out from "Thought:" prose); blank lines
343
+ // keep spacing; everything else commits dim to scrollback.
301
344
  let inCode = false;
345
+ let lastWasBlank = true; // suppresses a leading blank line and collapses repeats
302
346
  const consume = (chunk) => {
303
347
  carry += stripThinkTags(chunk);
304
348
  let nl;
305
349
  while ((nl = carry.indexOf("\n")) >= 0) {
306
350
  const line = carry.slice(0, nl);
307
351
  carry = carry.slice(nl + 1);
308
- if (isHeartbeatLine(line) || isFenceLine(line)) continue; // swallow
352
+ if (isHeartbeatLine(line) || isFenceLine(line)) continue; // swallow, no trace
353
+ if (isToolStepSummary(line)) { lastContentAt = Date.now(); continue; } // swallow
354
+ if (isRoutingLine(line)) {
355
+ // Replace the whole routing play-by-play with ONE friendly line, shown once
356
+ // per turn — the raw labels are implementation detail (visible in the daemon's
357
+ // own terminal if you need them).
358
+ if (!routingAnnounced) {
359
+ routingAnnounced = true;
360
+ onContent();
361
+ process.stdout.write(dim(`${pickWord()}…`) + "\n");
362
+ lastWasBlank = false;
363
+ }
364
+ lastContentAt = Date.now();
365
+ continue;
366
+ }
309
367
  if (!inCode && isCodeOpenLine(line)) {
310
- // Reasoning just ended and code is about to run — stamp the thought complete,
311
- // then suppress the code body itself.
312
- onContent();
368
+ stampIfThinking(); // hold/refresh the stamp; the code body commits it below
313
369
  inCode = true;
314
370
  continue;
315
371
  }
316
372
  if (inCode) {
317
- lastContentAt = Date.now(); // content IS arriving, just not printed
318
- if (isCodeCloseLine(line)) inCode = false;
373
+ if (isCodeCloseLine(line)) { inCode = false; lastContentAt = Date.now(); continue; }
374
+ if (line.trim() === "") { lastContentAt = Date.now(); continue; } // skip blank lines in code
375
+ onContent();
376
+ process.stdout.write(blue(line) + "\n");
377
+ lastWasBlank = false;
319
378
  continue;
320
379
  }
321
380
  if (line.trim() === "") {
322
- if (!thinking && !statusShown) process.stdout.write("\n");
381
+ // Several sources (swallowed routing lines, swallowed step summaries) each leave
382
+ // their own blank-line companion behind — collapse consecutive blanks into one.
383
+ if (!thinking && !statusShown && !lastWasBlank) {
384
+ process.stdout.write("\n");
385
+ lastWasBlank = true;
386
+ }
323
387
  continue;
324
388
  }
325
389
  onContent();
326
390
  process.stdout.write(dim(line) + "\n");
391
+ lastWasBlank = false;
327
392
  }
328
393
  // A non-empty remainder is a partial CONTENT line (heartbeats always arrive whole with
329
394
  // a newline), so tokens are actively flowing — keep the ticker asleep.
@@ -402,7 +467,10 @@ async function main() {
402
467
  console.log(dim("Ask about this repo, or /help. Ctrl-C aborts a running turn.\n"));
403
468
 
404
469
  const history = [];
405
- rl.on("SIGINT", () => {
470
+ // With terminal:false, Ctrl-C is no longer intercepted by readline's raw-mode byte
471
+ // scanning (rl.on("SIGINT", …) would never fire) — it now delivers a real OS SIGINT to
472
+ // the process instead, which we catch directly here. Same behavior as before.
473
+ process.on("SIGINT", () => {
406
474
  if (following) {
407
475
  followAborted = true;
408
476
  process.stdout.write(red("\n^C "));
@@ -413,10 +481,17 @@ async function main() {
413
481
 
414
482
  for (;;) {
415
483
  process.stdout.write(cyan(">> "));
416
- const lineRaw = await nextLine();
484
+ // Absorb blank Enter-presses SILENTLY under the same prompt — without this, lines
485
+ // typed while the agent was busy on a long turn (queued, not yet read) all drain at
486
+ // once the moment the turn ends, each re-printing ">> " with no newline between them
487
+ // (e.g. ">> >> >> "). Only re-print the prompt for a genuine stdin close or input.
488
+ let lineRaw;
489
+ for (;;) {
490
+ lineRaw = await nextLine();
491
+ if (lineRaw === null || lineRaw.trim()) break;
492
+ }
417
493
  if (lineRaw === null) break; // stdin closed
418
494
  const line = lineRaw.trim();
419
- if (!line) continue;
420
495
  if (line === "/exit" || line === "/quit") break;
421
496
  if (line === "/help") {
422
497
  console.log(dim(" /exit — quit · /revert [runId] — undo the agent's file edits (latest run)"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.186",
3
+ "version": "1.0.188",
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",