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

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";
@@ -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
  }
@@ -558,6 +558,96 @@ _AGENT_KEYWORDS = re.compile(
558
558
  re.IGNORECASE,
559
559
  )
560
560
 
561
+ # Pure conversational openers/closers that never need a tool — a greeting, a thank-you, a
562
+ # "who are you". Matching the WHOLE message (anchored) means "hi, fetch https://…" won't
563
+ # match. These skip BOTH the model router call AND the heavy tool preamble → an instant
564
+ # small-prompt plain reply, instead of a multi-minute prompt-eval on the coding model.
565
+ _TRIVIAL_CHAT = re.compile(
566
+ r"^\s*(?:"
567
+ r"h(?:i+|ello+|ey+|iya|owdy)|yo|sup|wass?up|"
568
+ r"good\s*(?:morning|afternoon|evening|night|day)|greetings|"
569
+ r"thanks?(?:\s*you)?|thank\s*you|thx|ty|cheers|much\s*appreciated|"
570
+ r"bye+|goodbye|see\s*(?:ya|you)|cya|later|good\s*night|"
571
+ r"ok(?:ay)?|k|cool|nice|great|awesome|perfect|sounds\s*good|got\s*it|"
572
+ r"how\s*(?:are|r)\s*(?:you|u|ya)(?:\s*doing)?|how'?s\s*it\s*going|what'?s\s*up|"
573
+ r"who\s*(?:are|r)\s*(?:you|u)|what\s*(?:can|do)\s*you\s*do|what\s*are\s*you|"
574
+ r"test(?:ing)?|ping"
575
+ r")"
576
+ r"(?:\s+(?:there|everyone|all|bot|assistant|gonext|man|dude|buddy))?"
577
+ r"[\s!.?,'\"]*$",
578
+ re.IGNORECASE,
579
+ )
580
+
581
+
582
+ def _is_trivial_chat(text: str) -> bool:
583
+ """True for a short, pure-conversational message (greeting, thanks, 'who are you')
584
+ that clearly needs no tools — so we can answer directly and fast."""
585
+ t = (text or "").strip()
586
+ if not t or len(t) > 60:
587
+ return False
588
+ return bool(_TRIVIAL_CHAT.match(t))
589
+
590
+
591
+ # Compact replacement for smolagents' default CodeAgent system prompt. The stock template
592
+ # is ~9.9k chars, dominated by ~6k chars of GENERIC few-shot examples (image captions,
593
+ # Wikipedia, etc.) that are irrelevant to our HTTP/file tools and cost real prompt-eval
594
+ # time every step on a slow coding model. This keeps everything FUNCTIONALLY required —
595
+ # the Thought→Code→Observation loop, the code-blob format + closing tag, the {{tools}}
596
+ # rendering (so exact signatures survive), authorized imports, custom_instructions — plus
597
+ # ONE short worked example, and trims the rest. Jinja vars must match the stock template.
598
+ _COMPACT_CODE_SYSTEM_PROMPT = (
599
+ "You are an expert assistant who solves the task by writing Python code that calls "
600
+ "tools. You work in a loop of Thought → Code → Observation.\n\n"
601
+ "At EACH step:\n"
602
+ "- Write one 'Thought:' line: what you'll do and which tool.\n"
603
+ "- Then a code block that OPENS with {{code_block_opening_tag}} and CLOSES with "
604
+ "{{code_block_closing_tag}}, containing simple Python that calls ONE tool and print()s "
605
+ "anything you need next.\n"
606
+ "- You then receive that tool's 'Observation:'. Use it to decide the next step. When "
607
+ "you have the answer, call final_answer(answer) inside a code block.\n\n"
608
+ "Example:\n"
609
+ "Thought: I'll read the page the user gave.\n"
610
+ "{{code_block_opening_tag}}\n"
611
+ 'text = fetch_url("https://example.com")\n'
612
+ "print(text)\n"
613
+ "{{code_block_closing_tag}}\n"
614
+ "Observation: \"Example Domain … \"\n"
615
+ "Thought: I have the content, so I'll answer.\n"
616
+ "{{code_block_opening_tag}}\n"
617
+ 'final_answer("The page is Example Domain, a placeholder site.")\n'
618
+ "{{code_block_closing_tag}}\n\n"
619
+ "You have access to these tools — call them as plain Python functions with the exact "
620
+ "signatures shown:\n"
621
+ "{{code_block_opening_tag}}\n"
622
+ "{%- for tool in tools.values() %}\n"
623
+ "{{ tool.to_code_prompt() }}\n"
624
+ "{% endfor %}\n"
625
+ "{{code_block_closing_tag}}\n"
626
+ "{%- if managed_agents and managed_agents.values() | list %}\n"
627
+ "You can also delegate to team members by calling them like a tool with a 'task' "
628
+ "string argument:\n"
629
+ "{{code_block_opening_tag}}\n"
630
+ "{%- for agent in managed_agents.values() %}\n"
631
+ "def {{ agent.name }}(task: str) -> str:\n"
632
+ ' """{{ agent.description }}"""\n'
633
+ "{% endfor %}\n"
634
+ "{{code_block_closing_tag}}\n"
635
+ "{%- endif %}\n\n"
636
+ "Rules:\n"
637
+ "1. ALWAYS write a 'Thought:' line, then a code block opening with "
638
+ "{{code_block_opening_tag}} and closing with {{code_block_closing_tag}} — or you fail.\n"
639
+ "2. Pass tool arguments directly: calculate(expression=\"2+2\"), NOT as a dict.\n"
640
+ "3. Use only variables you have defined; state persists between steps.\n"
641
+ "4. Never re-run a tool call with the exact same parameters.\n"
642
+ "5. Don't name a variable after a tool (e.g. 'final_answer').\n"
643
+ "6. Imports are allowed ONLY from: {{authorized_imports}}\n"
644
+ "7. Don't give up — you are in charge of solving the task.\n"
645
+ "{%- if custom_instructions %}\n"
646
+ "{{custom_instructions}}\n"
647
+ "{%- endif %}\n\n"
648
+ "Now Begin!"
649
+ )
650
+
561
651
 
562
652
  def _route(task_text: str, base_url: str, api_key: str, model_id: str) -> bool:
563
653
  """Decide if the task needs the HTTP agent (True) or a plain chat reply (False).
@@ -603,6 +693,16 @@ def _route(task_text: str, base_url: str, api_key: str, model_id: str) -> bool:
603
693
  _emit({"type": "step", "text": "→ Agent mode (needs tools)" if is_agent else "→ Chat reply"})
604
694
  return is_agent
605
695
  except Exception as e: # noqa: BLE001
696
+ # Classifier unreachable (e.g. the local chat server is down). No agent keyword
697
+ # matched above, so a SHORT message is almost certainly plain conversation — send
698
+ # it to a fast small-prompt reply instead of the heavy tool loop (which would
699
+ # prompt-eval a huge preamble for minutes). Only longer/ambiguous tasks fall back
700
+ # to the agent.
701
+ short = len((task_text or "").split()) <= 12
702
+ if short:
703
+ _log(f"router error: {e} — short message, defaulting to plain chat reply")
704
+ _emit({"type": "step", "text": "→ Chat reply"})
705
+ return False
606
706
  _log(f"router error: {e} — defaulting to agent")
607
707
  _emit({"type": "step", "text": "→ Agent mode (needs tools)"})
608
708
  return True
@@ -641,8 +741,12 @@ def _summarize_result(task_text: str, agent_output: str,
641
741
  return agent_output
642
742
 
643
743
 
644
- def _plain_reply(messages: list, base_url: str, api_key: str, model_id: str) -> str:
645
- """Plain chat completion using the full conversation history."""
744
+ def _plain_reply(messages: list, base_url: str, api_key: str, model_id: str,
745
+ fallback_base_url: str = "", fallback_model_id: str = "") -> str:
746
+ """Plain chat completion using the full conversation history — a SMALL prompt (no tool
747
+ preamble), so it returns fast. If the primary model is unreachable (e.g. the local MLX
748
+ server is down) and a distinct fallback model is given (the coding model), retry there
749
+ so a greeting still gets answered instead of erroring."""
646
750
  _THINK_RE_LOCAL = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
647
751
  chat_messages = [{"role": "system", "content": "You are a helpful assistant."}]
648
752
  for m in messages:
@@ -655,19 +759,33 @@ def _plain_reply(messages: list, base_url: str, api_key: str, model_id: str) ->
655
759
  if not content:
656
760
  continue
657
761
  chat_messages.append({"role": role, "content": content})
658
- try:
659
- from openai import OpenAI
660
- client = OpenAI(base_url=base_url, api_key=api_key or "local",
661
- max_retries=0, timeout=60)
662
- resp = client.chat.completions.create(
663
- model=model_id,
664
- messages=chat_messages,
665
- temperature=0.7,
666
- max_tokens=512,
667
- )
668
- return (resp.choices[0].message.content or "").strip()
669
- except Exception as e: # noqa: BLE001
670
- return f"[Error: {e}]"
762
+
763
+ targets = [(base_url, model_id)]
764
+ if fallback_base_url and fallback_model_id and (
765
+ fallback_base_url.rstrip("/") != (base_url or "").rstrip("/")
766
+ or fallback_model_id != model_id
767
+ ):
768
+ targets.append((fallback_base_url, fallback_model_id))
769
+
770
+ last_err = None
771
+ for i, (b, mid) in enumerate(targets):
772
+ try:
773
+ from openai import OpenAI
774
+ client = OpenAI(base_url=b, api_key=api_key or "local",
775
+ max_retries=0, timeout=60)
776
+ resp = client.chat.completions.create(
777
+ model=mid,
778
+ messages=chat_messages,
779
+ temperature=0.7,
780
+ max_tokens=512,
781
+ )
782
+ return (resp.choices[0].message.content or "").strip()
783
+ except Exception as e: # noqa: BLE001
784
+ last_err = e
785
+ if i + 1 < len(targets):
786
+ _log(f"plain reply: primary model failed ({e}); retrying on fallback "
787
+ f"{mid} @ {b}")
788
+ return f"[Error: {last_err}]"
671
789
 
672
790
 
673
791
  def _synthesize_document(gathered: list, task: str, base_url: str, api_key: str,
@@ -1903,8 +2021,16 @@ def run_agent_chat(cfg):
1903
2021
  return bool(_EMAIL_AVAILABLE and _EMAIL_CONFIRM.search(latest_user_text or "")
1904
2022
  and _prior_email_preview())
1905
2023
 
1906
- # Route: ask the model if this task needs HTTP tool use.
1907
- needs_agent = _route(latest_user_text, agent_base_url, agent_api_key, agent_model_id)
2024
+ # Route: does this task need HTTP tool use? A trivial greeting/thank-you/"who are you"
2025
+ # is caught LOCALLY (no model classifier call, no tool preamble) → straight to a fast
2026
+ # small-prompt reply. Otherwise ask the model classifier.
2027
+ if _is_trivial_chat(latest_user_text) and not _AGENT_KEYWORDS.search(latest_user_text or ""):
2028
+ _log("router → NO (trivial greeting/smalltalk — local fast-path, no tool preamble)")
2029
+ _emit({"type": "step", "text": "Routing your request…"})
2030
+ _emit({"type": "step", "text": "→ Chat reply"})
2031
+ needs_agent = False
2032
+ else:
2033
+ needs_agent = _route(latest_user_text, agent_base_url, agent_api_key, agent_model_id)
1908
2034
  # A pending email awaiting 'confirm' must reach the agent even though a bare
1909
2035
  # 'confirm' / 'send it' is not a network keyword — otherwise the send never fires.
1910
2036
  if not needs_agent and _email_confirm_pending():
@@ -1915,7 +2041,9 @@ def run_agent_chat(cfg):
1915
2041
  if not needs_agent:
1916
2042
  _log("router: plain chat (no HTTP needed)")
1917
2043
  _emit({"type": "step", "text": "Composing a reply…"})
1918
- answer = _plain_reply(messages, agent_base_url, agent_api_key, agent_model_id)
2044
+ # Small prompt fast. Fall back to the coding model if the chat model is down.
2045
+ answer = _plain_reply(messages, agent_base_url, agent_api_key, agent_model_id,
2046
+ coding_base_url, coding_model_id)
1919
2047
  _log(f"plain reply: {len(answer)} chars")
1920
2048
  _emit({"type": "final", "text": answer})
1921
2049
  return
@@ -3478,6 +3606,21 @@ def run_agent_chat(cfg):
3478
3606
  agent_kwargs["additional_authorized_imports"] = [
3479
3607
  "json", "base64", "urllib", "urllib.request", "urllib.error"
3480
3608
  ]
3609
+ # Trim smolagents' ~6k-char generic few-shot examples from the system prompt
3610
+ # (they dominate prompt-eval on a slow coding model) while keeping the tool
3611
+ # rendering + format rules intact. Load the stock templates and swap only the
3612
+ # system_prompt; fall back to the default on any error.
3613
+ try:
3614
+ import yaml as _yaml
3615
+ import os as _os
3616
+ import smolagents as _sm
3617
+ _tpl = _yaml.safe_load(open(_os.path.join(
3618
+ _os.path.dirname(_sm.__file__), "prompts", "code_agent.yaml")))
3619
+ _tpl["system_prompt"] = _COMPACT_CODE_SYSTEM_PROMPT
3620
+ agent_kwargs["prompt_templates"] = _tpl
3621
+ _log("using compact CodeAgent system prompt (trimmed few-shot examples)")
3622
+ except Exception as _e: # noqa: BLE001
3623
+ _log(f"compact system prompt unavailable ({_e}); using smolagents default")
3481
3624
  agent = _ToolAgent(**agent_kwargs)
3482
3625
  with contextlib.redirect_stdout(sys.stderr):
3483
3626
  result = agent.run(task_with_hint)
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.182",
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",