@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 +101 -3
- package/gonext_agent_chat.py +8 -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";
|
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
}
|
package/gonext_agent_chat.py
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|