@tiens.nguyen/gonext-local-worker 1.0.179 → 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.
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";
@@ -35,6 +35,29 @@ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
35
35
  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
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
39
+ const CLEAR_LINE = "\r\x1b[K"; // carriage-return + erase-to-end-of-line
40
+
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).
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";
38
61
 
39
62
  // ---------- flags ----------
40
63
  const argv = process.argv.slice(2);
@@ -219,10 +242,78 @@ async function runAgentTurn(history) {
219
242
  followAborted = false;
220
243
  const startedAt = Date.now();
221
244
  let shownChars = 0;
245
+ let carry = ""; // trailing partial content line held until its newline arrives
246
+ let statusShown = false; // a transient status line is currently on screen
222
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));
262
+
263
+ const clearStatus = () => {
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;
285
+ statusShown = false;
286
+ } else {
287
+ clearStatus();
288
+ }
289
+ lastContentAt = Date.now();
290
+ };
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.
294
+ const consume = (chunk) => {
295
+ carry += stripThinkTags(chunk);
296
+ let nl;
297
+ while ((nl = carry.indexOf("\n")) >= 0) {
298
+ const line = carry.slice(0, nl);
299
+ carry = carry.slice(nl + 1);
300
+ if (isHeartbeatLine(line) || isFenceLine(line)) continue; // swallow
301
+ if (line.trim() === "") {
302
+ if (!thinking && !statusShown) process.stdout.write("\n");
303
+ continue;
304
+ }
305
+ onContent();
306
+ process.stdout.write(dim(line) + "\n");
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();
311
+ };
312
+
223
313
  try {
224
314
  for (;;) {
225
315
  if (followAborted) {
316
+ clearStatus();
226
317
  process.stdout.write(
227
318
  dim("\n(stopped following — the job keeps running on the worker)\n")
228
319
  );
@@ -233,16 +324,20 @@ async function runAgentTurn(history) {
233
324
  });
234
325
  const job = await jr.json().catch(() => ({}));
235
326
  if (!jr.ok) throw new Error(job?.error || `job poll failed (HTTP ${jr.status})`);
327
+ jobRunning = job.jobStatus === "running";
236
328
  const text = typeof job.resultText === "string" ? job.resultText : "";
237
329
  if (job.jobStatus === "completed") {
238
- // Don't stream the final delta dim the answer prints bright right after.
330
+ // Drop the transient status line and any partial reasoning tail; the bright answer
331
+ // prints right after via the caller.
332
+ clearStatus();
239
333
  return answerFrom(text);
240
334
  }
241
335
  if (text.length > shownChars) {
242
- process.stdout.write(dim(stripThinkTags(text.slice(shownChars))));
336
+ consume(text.slice(shownChars));
243
337
  shownChars = text.length;
244
338
  }
245
339
  if (job.jobStatus === "failed" || job.jobStatus === "cancelled") {
340
+ clearStatus();
246
341
  throw new Error(job.errorMessage || `job ${job.jobStatus}`);
247
342
  }
248
343
  if (
@@ -251,6 +346,7 @@ async function runAgentTurn(history) {
251
346
  Date.now() - startedAt > 6000
252
347
  ) {
253
348
  warnedPending = true;
349
+ clearStatus();
254
350
  process.stdout.write(
255
351
  red("\n⚠ no worker has claimed the job — is `gonext-local-worker` running?\n")
256
352
  );
@@ -258,6 +354,8 @@ async function runAgentTurn(history) {
258
354
  await sleep(700);
259
355
  }
260
356
  } finally {
357
+ clearInterval(ticker);
358
+ clearStatus();
261
359
  following = false;
262
360
  }
263
361
  }
@@ -1481,10 +1481,16 @@ _WS_ACTIVE: str = ""
1481
1481
  def _rag_read_allowed(path: str) -> str:
1482
1482
  """Resolve `path` and ensure it stays within a safe root (worker cwd, temp dir,
1483
1483
  ~/.gonext, or a registered workspace) — so the agent's file tools can't read
1484
- arbitrary files like ~/.ssh."""
1484
+ arbitrary files like ~/.ssh. Relative paths anchor to the terminal workspace (where
1485
+ downloads/unzips land), NOT the worker's process cwd (the home dir), because the
1486
+ model routinely passes bare names like "tools-hook" or "tools-hook/README.md"."""
1485
1487
  import os
1486
1488
  import tempfile
1487
- rp = os.path.realpath(path)
1489
+ expanded = os.path.expanduser((path or "").strip())
1490
+ if not os.path.isabs(expanded):
1491
+ base = _WS_ACTIVE or (_WS_ROOTS[0]["path"] if _WS_ROOTS else os.getcwd())
1492
+ expanded = os.path.join(base, expanded)
1493
+ rp = os.path.realpath(expanded)
1488
1494
  roots = [
1489
1495
  os.path.realpath(os.getcwd()),
1490
1496
  os.path.realpath(tempfile.gettempdir()),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.179",
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",