@tiens.nguyen/gonext-local-worker 1.0.198 → 1.0.200

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
@@ -54,11 +54,16 @@ const CLEAR_LINE = "\r\x1b[K"; // carriage-return + erase-to-end-of-line
54
54
  // also drop the worker's ~~~ stream fences (they only matter to the web renderer).
55
55
  const isHeartbeatLine = (line) => /…\s?\(\d+s\)\s*$/.test(line);
56
56
  const isFenceLine = (line) => line.trim() === "~~~";
57
- // The model's raw Python code blob is delimited by <code>/</code> lines. We don't print
58
- // the tag lines themselves, but the code IN BETWEEN prints in a distinct grey (codeColor)
57
+ // The model's raw Python code blob is delimited by <code>/</code> tags. We don't print
58
+ // the tags themselves, but the code IN BETWEEN prints in a distinct grey (codeColor)
59
59
  // instead of dim prose, so it visually separates from the "Thought:" reasoning.
60
- const isCodeOpenLine = (line) => line.trim() === "<code>";
61
- const isCodeCloseLine = (line) => line.trim() === "</code>";
60
+ // Tolerant matching, NOT exact-line: the raw token stream often mangles the tags —
61
+ // glued to content ("<codecreate_folder(...)"), missing the ">", duplicated, or the
62
+ // close tag stuck on the code's last line — and exact matching printed those
63
+ // fragments literally in mixed colors (user-reported).
64
+ const CODE_OPEN_RE = /^<code(?:\s*>)?/; // applied to line.trim()
65
+ const CODE_CLOSE_RE = /<\/code>?\s*$/;
66
+ const isBareCloseTag = (t) => /^<\/code>?$/.test(t);
62
67
  // The step summary the worker emits right after a tool call finishes (e.g.
63
68
  // "unzip_file(...) | → Unzipped 256 files…") duplicates what's already visible in the
64
69
  // gonext-local-worker daemon's own terminal — swallow it here to keep the REPL terse.
@@ -142,16 +147,19 @@ if (!workerKey || !apiBase) {
142
147
  // for piped/scripted input, harmless for interactive TTYs. Both ask() and the main
143
148
  // loop consume from this queue (never rl.question, to avoid double-capture).
144
149
  //
145
- // terminal: false we draw our OWN prompt (cyan ">> ") and colorize output ourselves;
146
- // readline's default terminal:true (auto-enabled whenever output is a TTY) makes it ALSO
147
- // track cursor position and redraw the input line on every keystroke using ITS OWN
148
- // (empty, since we never call rl.setPrompt/rl.prompt) prompt that tracking goes stale
149
- // the moment we write anything readline doesn't know about (which is everything we print
150
- // between prompts), and its next redraw emits a stray leading "[" (a malformed/partial
151
- // cursor-position escape) right before typed input, e.g. "[>> hi". With terminal:false,
152
- // readline stops managing the line/cursor itself; the OS's own canonical-mode terminal
153
- // still echoes keystrokes normally, and 'line' events fire exactly the same.
154
- const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false });
150
+ // terminal: true (on real TTYs) with readline OWNING the prompt via setPrompt/prompt().
151
+ // History of this setting: plain terminal:true with a MANUALLY-written ">> " once caused
152
+ // a stray "[" artifact readline redrew the input line using its own (empty, never-set)
153
+ // prompt and cursor tracking that our un-announced writes had made stale. The interim
154
+ // fix (terminal:false) traded that for something worse: the OS's canonical line
155
+ // discipline echoes raw CSI bytes, so arrows/Home/End showed literal "^[[D" and mid-line
156
+ // editing didn't work at all (user-reported). Giving readline the prompt makes its
157
+ // redraw math correct — rl.prompt() resets the cursor state after every burst of our
158
+ // output and raw mode means backspace/arrows/↑-history all behave like a normal shell.
159
+ const IS_TTY = Boolean(process.stdin.isTTY && process.stdout.isTTY);
160
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: IS_TTY });
161
+ const PROMPT = cyan(">> ");
162
+ rl.setPrompt(PROMPT);
155
163
  // Side effect of terminal:false above: readline no longer intercepts arrow keys for
156
164
  // command-history recall, so pressing ↑/↓/→/← (or Home/End/Delete) sends the RAW escape
157
165
  // bytes straight through as literal input — the OS's own canonical line discipline
@@ -189,8 +197,13 @@ const nextLine = () =>
189
197
  ? Promise.resolve(null)
190
198
  : new Promise((res) => (_lineWaiter = res));
191
199
  const ask = async (q) => {
192
- process.stdout.write(q);
200
+ // Route the question through readline's own prompt so its redraw logic stays
201
+ // consistent (a raw stdout write here would re-create the stale-cursor artifact
202
+ // that terminal:true mode is designed to avoid). Restored to ">> " afterwards.
203
+ rl.setPrompt(q);
204
+ rl.prompt();
193
205
  const a = await nextLine();
206
+ rl.setPrompt(PROMPT);
194
207
  return (a ?? "").trim();
195
208
  };
196
209
 
@@ -510,14 +523,48 @@ async function runAgentTurn(history) {
510
523
  }
511
524
  inEditCard = false; // card ended — fall through to normal handling
512
525
  }
513
- if (!inCode && isCodeOpenLine(line)) {
514
- stampIfThinking(); // hold/refresh the stamp; the code body commits it below
515
- inCode = true;
516
- continue;
526
+ {
527
+ const t = line.trim();
528
+ const openM = !inCode || t.startsWith("<code") ? CODE_OPEN_RE.exec(t) : null;
529
+ if (openM) {
530
+ // Enter code mode (or stay in it on a duplicate tag). Anything glued after
531
+ // the tag on the same line is code — print it, minus a glued close tag.
532
+ stampIfThinking(); // hold/refresh the stamp; the code body commits it below
533
+ inCode = true;
534
+ let rest = t.slice(openM[0].length).trim();
535
+ if (CODE_CLOSE_RE.test(rest)) {
536
+ inCode = false;
537
+ rest = rest.replace(CODE_CLOSE_RE, "").trim();
538
+ }
539
+ if (rest) {
540
+ onContent();
541
+ process.stdout.write(codeColor(rest) + "\n");
542
+ lastWasBlank = false;
543
+ }
544
+ lastContentAt = Date.now();
545
+ continue;
546
+ }
547
+ if (!inCode && isBareCloseTag(t)) {
548
+ // Stray close tag with no open in sight — swallow, never show it.
549
+ lastContentAt = Date.now();
550
+ continue;
551
+ }
517
552
  }
518
553
  if (inCode) {
519
- if (isCodeCloseLine(line)) { inCode = false; lastContentAt = Date.now(); continue; }
520
- if (line.trim() === "") { lastContentAt = Date.now(); continue; } // skip blank lines in code
554
+ const t = line.trim();
555
+ if (CODE_CLOSE_RE.test(t)) {
556
+ // Close tag, possibly with the code's last line glued in front of it.
557
+ const before = t.replace(CODE_CLOSE_RE, "").trim();
558
+ if (before) {
559
+ onContent();
560
+ process.stdout.write(codeColor(before) + "\n");
561
+ lastWasBlank = false;
562
+ }
563
+ inCode = false;
564
+ lastContentAt = Date.now();
565
+ continue;
566
+ }
567
+ if (t === "") { lastContentAt = Date.now(); continue; } // skip blank lines in code
521
568
  onContent();
522
569
  process.stdout.write(codeColor(line) + "\n");
523
570
  lastWasBlank = false;
@@ -637,20 +684,29 @@ async function main() {
637
684
  dim(`Resumed session: ${turns} prior turn(s) from last time in this folder — /reset to start fresh.\n`)
638
685
  );
639
686
  }
640
- // With terminal:false, Ctrl-C is no longer intercepted by readline's raw-mode byte
641
- // scanning (rl.on("SIGINT", ) would never fire) it now delivers a real OS SIGINT to
642
- // the process instead, which we catch directly here. Same behavior as before.
643
- process.on("SIGINT", () => {
687
+ // Ctrl-C arrives on ONE of two paths depending on the readline mode: with
688
+ // terminal:true (real TTY) readline's raw-mode byte scanning intercepts \x03 and
689
+ // emits rl "SIGINT" (no OS signal is delivered); with terminal:false (piped input)
690
+ // the OS delivers a real SIGINT to the process. Attach the same handler to both —
691
+ // only one can ever fire for a given mode, so there's no double-handling.
692
+ const onInterrupt = () => {
644
693
  if (following) {
645
694
  followAborted = true;
646
695
  process.stdout.write(red("\n^C "));
647
696
  } else {
648
- process.stdout.write(dim("\n(/exit to quit)\n") + cyan(">> "));
697
+ process.stdout.write(dim("\n(/exit to quit)\n"));
698
+ // Discard anything half-typed (Ctrl-C at a prompt means "never mind") and
699
+ // redraw through readline so its cursor tracking stays right.
700
+ rl.line = "";
701
+ rl.cursor = 0;
702
+ rl.prompt();
649
703
  }
650
- });
704
+ };
705
+ process.on("SIGINT", onInterrupt);
706
+ rl.on("SIGINT", onInterrupt);
651
707
 
652
708
  for (;;) {
653
- process.stdout.write(cyan(">> "));
709
+ rl.prompt();
654
710
  // Absorb blank Enter-presses SILENTLY under the same prompt — without this, lines
655
711
  // typed while the agent was busy on a long turn (queued, not yet read) all drain at
656
712
  // once the moment the turn ends, each re-printing ">> " with no newline between them
@@ -659,6 +659,36 @@ def _is_trivial_chat(text: str) -> bool:
659
659
  return bool(_TRIVIAL_CHAT.match(t))
660
660
 
661
661
 
662
+ # The greeting/smalltalk set MINUS the coding-ambiguous words: in a registered
663
+ # workspace (the `gonext` terminal), a bare "test"/"testing"/"ping" leans toward a
664
+ # command ("run the tests"), so it should reach the agent, not be answered as chat.
665
+ # This is the ONLY escape from workspace-mode's default-to-agent routing, so it must
666
+ # stay tight — anything not obviously conversational is better handled as a task.
667
+ _GREETING_SMALLTALK = re.compile(
668
+ r"^\s*(?:"
669
+ r"h(?:i+|ello+|ey+|iya|owdy)|yo|sup|wass?up|"
670
+ r"good\s*(?:morning|afternoon|evening|night|day)|greetings|"
671
+ r"thanks?(?:\s*you)?|thank\s*you|thx|ty|cheers|much\s*appreciated|"
672
+ r"bye+|goodbye|see\s*(?:ya|you)|cya|later|good\s*night|"
673
+ r"ok(?:ay)?|k|cool|nice|great|awesome|perfect|sounds\s*good|got\s*it|"
674
+ r"how\s*(?:are|r)\s*(?:you|u|ya)(?:\s*doing)?|how'?s\s*it\s*going|what'?s\s*up|"
675
+ r"who\s*(?:are|r)\s*(?:you|u)|what\s*(?:can|do)\s*you\s*do|what\s*are\s*you"
676
+ r")"
677
+ r"(?:\s+(?:there|everyone|all|bot|assistant|gonext|man|dude|buddy))?"
678
+ r"[\s!.?,'\"]*$",
679
+ re.IGNORECASE,
680
+ )
681
+
682
+
683
+ def _is_greeting_smalltalk(text: str) -> bool:
684
+ """Tighter than _is_trivial_chat (no test/ping) — the single chat escape from
685
+ workspace-mode default-to-agent routing."""
686
+ t = (text or "").strip()
687
+ if not t or len(t) > 60:
688
+ return False
689
+ return bool(_GREETING_SMALLTALK.match(t))
690
+
691
+
662
692
  # A weak/confused coding model sometimes "thinks out loud" in plain prose instead of
663
693
  # emitting the next tool call — e.g. a rambling, self-correcting scratchpad ("Wait,
664
694
  # actually... Let's try... Actually, let's just...") that can run thousands of chars.
@@ -2514,14 +2544,31 @@ def run_agent_chat(cfg):
2514
2544
  and not _HOWTO_PREFIX_RE.match(t)
2515
2545
  and _WS_ACTION_RE.search(t))
2516
2546
 
2517
- # Route: does this task need HTTP tool use? A trivial greeting/thank-you/"who are you"
2518
- # is caught LOCALLY (no model classifier call, no tool preamble) → straight to a fast
2519
- # small-prompt reply. Otherwise ask the model classifier.
2520
- if _is_trivial_chat(latest_user_text) and not _AGENT_KEYWORDS.search(latest_user_text or ""):
2521
- _log("router NO (trivial greeting/smalltalklocal fast-path, no tool preamble)")
2522
- _emit({"type": "step", "text": "Routing your request…"})
2547
+ # Routing. Two fundamentally different contexts:
2548
+ #
2549
+ # (A) WORKSPACE MODE (a folder is registered — i.e. the `gonext` terminal, or web
2550
+ # with a workspace): DEFAULT TO THE AGENT. The user opened a code folder and
2551
+ # registered it for the agent to read/edit/runeverything they type is
2552
+ # presumptively a task to DO, not a question to classify. We do NOT ask a model
2553
+ # classifier "does this need tools?" here: on a weak local model that guess is
2554
+ # unreliable, and every wrong guess sends an action request ("create a reactjs
2555
+ # project and start it", "do it for me") to plain chat, which can only hand back
2556
+ # instructions — the exact instability the user rejected. The ONLY escape is the
2557
+ # deterministic greeting/thanks fast-path, so "hi"/"thanks" stay instant instead
2558
+ # of spinning up the coding model. Anything else → agent, which still just
2559
+ # answers via final_answer when no tool is needed.
2560
+ #
2561
+ # (B) NO WORKSPACE (plain web chat): keep the keyword + model classifier — there are
2562
+ # no file/run tools to act with, so chat-vs-tool routing still earns its keep.
2563
+ _emit({"type": "step", "text": "Routing your request…"})
2564
+ if _is_greeting_smalltalk(latest_user_text) and not _AGENT_KEYWORDS.search(latest_user_text or ""):
2565
+ _log("router → NO (greeting/smalltalk — local fast-path)")
2523
2566
  _emit({"type": "step", "text": "→ Chat reply"})
2524
2567
  needs_agent = False
2568
+ elif _WS_AVAILABLE:
2569
+ _log("router → YES (workspace registered → act by default, no classifier)")
2570
+ _emit({"type": "step", "text": "→ Agent mode (workspace)"})
2571
+ needs_agent = True
2525
2572
  else:
2526
2573
  needs_agent = _route(latest_user_text, agent_base_url, agent_api_key, agent_model_id)
2527
2574
  # A pending email awaiting 'confirm' must reach the agent even though a bare
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.198",
3
+ "version": "1.0.200",
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",