@tiens.nguyen/gonext-local-worker 1.0.180 → 1.0.181

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 +77 -24
  2. package/package.json +1 -1
package/gonext-repl.mjs CHANGED
@@ -19,7 +19,7 @@
19
19
  * Commands: /exit /revert [runId] /help Ctrl-C stops following the current run.
20
20
  */
21
21
  import { readFile, mkdir, writeFile } from "node:fs/promises";
22
- import { existsSync, statSync } from "node:fs";
22
+ import { existsSync, statSync, readFileSync } from "node:fs";
23
23
  import { spawn } from "node:child_process";
24
24
  import { homedir } from "node:os";
25
25
  import { dirname, join, resolve, basename } from "node:path";
@@ -38,11 +38,26 @@ const red = (s) => `\x1b[31m${s}\x1b[0m`;
38
38
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
39
39
  const CLEAR_LINE = "\r\x1b[K"; // carriage-return + erase-to-end-of-line
40
40
 
41
- // The worker's playful "still thinking" heartbeat lines look like "Caffeinating… (90s)"
42
- // (a word/phrase, an ellipsis, then the elapsed seconds). They're ephemeral progress
43
- // we render them on a single in-place status line (yellow) that the next one overwrites
44
- // and that gets erased once real output arrives, so they never pile up in the scrollback.
41
+ // The worker's "still thinking" heartbeat lines look like "Caffeinating… (90s)" — we
42
+ // SWALLOW them from the terminal (the local ticker below drives progress instead), and
43
+ // also drop the worker's ~~~ stream fences (they only matter to the web renderer).
45
44
  const isHeartbeatLine = (line) => /…\s?\(\d+s\)\s*$/.test(line);
45
+ const isFenceLine = (line) => line.trim() === "~~~";
46
+
47
+ // Playful words for the local "thinking…" ticker, reused verbatim from the worker's
48
+ // thinking_words.txt sibling (falls back to a tiny built-in list if it's missing).
49
+ const THINKING_WORDS = (() => {
50
+ try {
51
+ return readFileSync(join(DIR, "thinking_words.txt"), "utf8")
52
+ .split("\n")
53
+ .map((l) => l.trim())
54
+ .filter((l) => l && !l.startsWith("#"));
55
+ } catch {
56
+ return ["Thinking", "Percolating", "Cogitating", "Noodling", "Ruminating"];
57
+ }
58
+ })();
59
+ const pickWord = () =>
60
+ THINKING_WORDS[Math.floor(Math.random() * THINKING_WORDS.length)] || "Thinking";
46
61
 
47
62
  // ---------- flags ----------
48
63
  const argv = process.argv.slice(2);
@@ -227,38 +242,74 @@ async function runAgentTurn(history) {
227
242
  followAborted = false;
228
243
  const startedAt = Date.now();
229
244
  let shownChars = 0;
230
- let carry = ""; // trailing partial line held until its newline arrives
231
- let statusShown = false; // a transient heartbeat status line is on screen
245
+ let carry = ""; // trailing partial content line held until its newline arrives
246
+ let statusShown = false; // a transient status line is currently on screen
232
247
  let warnedPending = false;
248
+ let jobRunning = false; // only tick "thinking…" once the worker has claimed the job
249
+
250
+ // --- Live "thinking…" ticker ---------------------------------------------------
251
+ // Between visible tokens (prompt-eval / awaiting the first token) we show ONE in-place
252
+ // status line that counts UP every second — "Percolating thinking… (7s)" — so the wait
253
+ // feels alive. When output resumes we stamp a dim "✔ completed thought (Ns)" and let the
254
+ // tokens stream. The worker's own 45s heartbeat lines (and ~~~ fences) are swallowed;
255
+ // this local 1-second ticker owns the progress display.
256
+ const QUIET_MS = 700; // stream idle this long ⇒ we're waiting on the model
257
+ let lastContentAt = Date.now();
258
+ let thinking = false;
259
+ let thinkingSince = 0;
260
+ let thinkWord = pickWord();
261
+ const thinkSecs = () => Math.max(1, Math.round((Date.now() - thinkingSince) / 1000));
233
262
 
234
- // Erase the transient heartbeat status line (if any) before printing real output.
235
263
  const clearStatus = () => {
236
- if (statusShown) {
237
- process.stdout.write(CLEAR_LINE);
264
+ if (statusShown) { process.stdout.write(CLEAR_LINE); statusShown = false; }
265
+ };
266
+
267
+ const tick = () => {
268
+ if (!following || followAborted || !jobRunning) return;
269
+ if (Date.now() - lastContentAt < QUIET_MS) return; // tokens still flowing
270
+ // Count from when the stream actually went quiet, so the seconds reflect the true
271
+ // wait for the first token (1s, 2s, 3s…) rather than from the first tick.
272
+ if (!thinking) { thinking = true; thinkingSince = lastContentAt; thinkWord = pickWord(); }
273
+ const secs = thinkSecs();
274
+ if (secs % 12 === 0) thinkWord = pickWord(); // rotate the word on long waits
275
+ process.stdout.write(CLEAR_LINE + yellow(`${thinkWord} thinking… (${secs}s)`));
276
+ statusShown = true;
277
+ };
278
+ const ticker = setInterval(tick, 1000);
279
+
280
+ // Close out a "thinking…" phase right before real output prints.
281
+ const onContent = () => {
282
+ if (thinking) {
283
+ process.stdout.write(CLEAR_LINE + dim(`✔ completed thought (${thinkSecs()}s)`) + "\n");
284
+ thinking = false;
238
285
  statusShown = false;
286
+ } else {
287
+ clearStatus();
239
288
  }
289
+ lastContentAt = Date.now();
240
290
  };
241
- // Feed newly-arrived stream text through the line splitter: heartbeat lines become an
242
- // in-place status line (never committed to scrollback); everything else commits dim.
291
+
292
+ // Split newly-arrived text into lines: heartbeats + ~~~ fences are swallowed (the ticker
293
+ // shows progress); blank lines keep spacing; everything else commits dim to scrollback.
243
294
  const consume = (chunk) => {
244
295
  carry += stripThinkTags(chunk);
245
296
  let nl;
246
297
  while ((nl = carry.indexOf("\n")) >= 0) {
247
298
  const line = carry.slice(0, nl);
248
299
  carry = carry.slice(nl + 1);
249
- if (isHeartbeatLine(line)) {
250
- // Overwrite the previous heartbeat in place — no newline, so it stays transient.
251
- process.stdout.write(CLEAR_LINE + yellow(line));
252
- statusShown = true;
253
- } else if (line.trim() === "") {
254
- // Swallow the blank line the worker appends after a heartbeat; keep real spacing.
255
- if (!statusShown) process.stdout.write("\n");
256
- } else {
257
- clearStatus();
258
- process.stdout.write(dim(line) + "\n");
300
+ if (isHeartbeatLine(line) || isFenceLine(line)) continue; // swallow
301
+ if (line.trim() === "") {
302
+ if (!thinking && !statusShown) process.stdout.write("\n");
303
+ continue;
259
304
  }
305
+ onContent();
306
+ process.stdout.write(dim(line) + "\n");
260
307
  }
308
+ // A non-empty remainder is a partial CONTENT line (heartbeats always arrive whole with
309
+ // a newline), so tokens are actively flowing — keep the ticker asleep.
310
+ if (carry.trim() !== "") lastContentAt = Date.now();
261
311
  };
312
+
262
313
  try {
263
314
  for (;;) {
264
315
  if (followAborted) {
@@ -273,10 +324,11 @@ async function runAgentTurn(history) {
273
324
  });
274
325
  const job = await jr.json().catch(() => ({}));
275
326
  if (!jr.ok) throw new Error(job?.error || `job poll failed (HTTP ${jr.status})`);
327
+ jobRunning = job.jobStatus === "running";
276
328
  const text = typeof job.resultText === "string" ? job.resultText : "";
277
329
  if (job.jobStatus === "completed") {
278
- // Drop the transient heartbeat line and any partial reasoning tail; the bright
279
- // answer prints right after via the caller.
330
+ // Drop the transient status line and any partial reasoning tail; the bright answer
331
+ // prints right after via the caller.
280
332
  clearStatus();
281
333
  return answerFrom(text);
282
334
  }
@@ -302,6 +354,7 @@ async function runAgentTurn(history) {
302
354
  await sleep(700);
303
355
  }
304
356
  } finally {
357
+ clearInterval(ticker);
305
358
  clearStatus();
306
359
  following = false;
307
360
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.180",
3
+ "version": "1.0.181",
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",