@tiens.nguyen/gonext-local-worker 1.0.194 → 1.0.196

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
@@ -36,7 +36,9 @@ 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
38
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
39
- const blue = (s) => `\x1b[34m${s}\x1b[0m`;
39
+ // ANSI 90 ("bright black") a reliable neutral grey across terminal themes, unlike
40
+ // code 34 (blue) which many dark-theme palettes render as purple/indigo instead.
41
+ const codeColor = (s) => `\x1b[90m${s}\x1b[0m`;
40
42
  const CLEAR_LINE = "\r\x1b[K"; // carriage-return + erase-to-end-of-line
41
43
 
42
44
  // The worker's "still thinking" heartbeat lines look like "Caffeinating… (90s)" — we
@@ -45,7 +47,7 @@ const CLEAR_LINE = "\r\x1b[K"; // carriage-return + erase-to-end-of-line
45
47
  const isHeartbeatLine = (line) => /…\s?\(\d+s\)\s*$/.test(line);
46
48
  const isFenceLine = (line) => line.trim() === "~~~";
47
49
  // The model's raw Python code blob is delimited by <code>/</code> lines. We don't print
48
- // the tag lines themselves, but the code IN BETWEEN prints in a distinct color (blue)
50
+ // the tag lines themselves, but the code IN BETWEEN prints in a distinct grey (codeColor)
49
51
  // instead of dim prose, so it visually separates from the "Thought:" reasoning.
50
52
  const isCodeOpenLine = (line) => line.trim() === "<code>";
51
53
  const isCodeCloseLine = (line) => line.trim() === "</code>";
@@ -127,16 +129,26 @@ if (!workerKey || !apiBase) {
127
129
  // readline stops managing the line/cursor itself; the OS's own canonical-mode terminal
128
130
  // still echoes keystrokes normally, and 'line' events fire exactly the same.
129
131
  const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false });
132
+ // Side effect of terminal:false above: readline no longer intercepts arrow keys for
133
+ // command-history recall, so pressing ↑/↓/→/← (or Home/End/Delete) sends the RAW escape
134
+ // bytes straight through as literal input — the OS's own canonical line discipline
135
+ // doesn't understand them either, so they get echoed and end up IN the submitted line
136
+ // once Enter is pressed (seen live: a task sent to the model started with a literal
137
+ // "\x1b[A" from an accidental up-arrow press). Strip CSI escape sequences (ESC '[' …
138
+ // final-byte) from every submitted line so stray arrow-key presses can't corrupt task
139
+ // text sent to the model.
140
+ const stripAnsiEscapes = (s) => String(s ?? "").replace(/\x1b\[[0-9;]*[A-Za-z~]/g, "");
130
141
  const _lineQueue = [];
131
142
  let _lineWaiter = null;
132
143
  let _stdinClosed = false;
133
144
  rl.on("line", (l) => {
145
+ const clean = stripAnsiEscapes(l);
134
146
  if (_lineWaiter) {
135
147
  const w = _lineWaiter;
136
148
  _lineWaiter = null;
137
- w(l);
149
+ w(clean);
138
150
  } else {
139
- _lineQueue.push(l);
151
+ _lineQueue.push(clean);
140
152
  }
141
153
  });
142
154
  rl.on("close", () => {
@@ -359,8 +371,8 @@ async function runAgentTurn(history) {
359
371
  // Split newly-arrived text into lines: heartbeats + ~~~ fences + the post-tool-call step
360
372
  // summary ("tool(...) | → result", redundant with the daemon's own terminal) are
361
373
  // swallowed; the model's <code>/</code> TAG lines are hidden but the code body between
362
- // them prints in blue (not dim, so it stands out from "Thought:" prose); blank lines
363
- // keep spacing; everything else commits dim to scrollback.
374
+ // them prints in a distinct grey (not dim, so it stands out from "Thought:" prose);
375
+ // blank lines keep spacing; everything else commits dim to scrollback.
364
376
  let inCode = false;
365
377
  let lastWasBlank = true; // suppresses a leading blank line and collapses repeats
366
378
  // Chars of the CURRENT trailing (newline-less) `carry` line already flushed live — lets
@@ -400,7 +412,7 @@ async function runAgentTurn(history) {
400
412
  if (isCodeCloseLine(line)) { inCode = false; lastContentAt = Date.now(); continue; }
401
413
  if (line.trim() === "") { lastContentAt = Date.now(); continue; } // skip blank lines in code
402
414
  onContent();
403
- process.stdout.write(blue(line) + "\n");
415
+ process.stdout.write(codeColor(line) + "\n");
404
416
  lastWasBlank = false;
405
417
  continue;
406
418
  }
@@ -297,6 +297,15 @@ _EMAIL_CONFIRM = re.compile(
297
297
  re.IGNORECASE,
298
298
  )
299
299
 
300
+ # A bare "continue"-style nudge — matched WHOLE-MESSAGE (anchored) so it only fires on a
301
+ # short, unambiguous continuation cue, never a longer sentence that happens to contain
302
+ # one of these words. See _continuation_pending().
303
+ _CONTINUE_CUE = re.compile(
304
+ r"^(please\s+)?(continue|keep\s+going|go\s+on|resume|carry\s+on|try\s+again)"
305
+ r"[\s.!?]*$",
306
+ re.IGNORECASE,
307
+ )
308
+
300
309
 
301
310
  def _email_allowed(addr, allow):
302
311
  """True if addr matches an allow-list entry — an exact address or a bare domain."""
@@ -1800,6 +1809,41 @@ def _ws_for_path(path: str):
1800
1809
  return None
1801
1810
 
1802
1811
 
1812
+ def _workspace_overview(roots, max_entries: int = 40) -> str:
1813
+ """Cheap, LOCAL top-level listing of each registered workspace root — a plain
1814
+ os.listdir(), no recursion, no network call, no model call. Used two ways: (1)
1815
+ injected into the task prompt so the agent has upfront grounding that the workspace
1816
+ is non-empty WITHOUT spending its first (possibly failing) tool-call round-trip just
1817
+ discovering that; (2) appended to the degrade-to-plain-reply message so even a TOTAL
1818
+ coding-model outage still shows the user their files are actually there — this is
1819
+ the one piece of workspace context that stays available no matter how badly the
1820
+ model-calling side of the agent is failing, since it never touches a model at all."""
1821
+ import os
1822
+ parts = []
1823
+ for w in roots:
1824
+ root = w["path"]
1825
+ try:
1826
+ entries = sorted(
1827
+ e for e in os.listdir(root) if e not in _RAG_SKIP_DIRS
1828
+ )
1829
+ except OSError as e:
1830
+ parts.append(f"{w['name']} ({root}): could not list ({e})")
1831
+ continue
1832
+ if not entries:
1833
+ parts.append(f"{w['name']} ({root}): EMPTY — no files or folders found.")
1834
+ continue
1835
+ shown = entries[:max_entries]
1836
+ labeled = [
1837
+ f"{e}/" if os.path.isdir(os.path.join(root, e)) else e for e in shown
1838
+ ]
1839
+ more = f" …(+{len(entries) - max_entries} more)" if len(entries) > max_entries else ""
1840
+ parts.append(
1841
+ f"{w['name']} ({root}) — {len(entries)} top-level item(s): "
1842
+ + ", ".join(labeled) + more
1843
+ )
1844
+ return "\n".join(parts)
1845
+
1846
+
1803
1847
  def _ws_write_allowed(path: str) -> str:
1804
1848
  """Resolve `path` for WRITING: must be inside a registered workspace and never
1805
1849
  inside its .git/ internals (agents must not corrupt version control)."""
@@ -2185,6 +2229,29 @@ def run_agent_chat(cfg):
2185
2229
  return bool(_EMAIL_AVAILABLE and _EMAIL_CONFIRM.search(latest_user_text or "")
2186
2230
  and _prior_email_preview())
2187
2231
 
2232
+ def _prior_turn_was_interrupted() -> bool:
2233
+ """True if the immediately preceding assistant turn is one of OUR OWN
2234
+ interrupted-investigation markers (the honest degrade-fallback note, or the
2235
+ max-steps 'ran out of steps' note) — i.e. a genuine tool-use attempt started but
2236
+ didn't finish, as opposed to a normal completed answer."""
2237
+ for m in reversed(messages[:last_user_idx]):
2238
+ if m.get("role") == "assistant":
2239
+ c = m.get("content") or ""
2240
+ return ("ran out of steps before finishing" in c
2241
+ or "couldn't finish the full investigation" in c)
2242
+ return False
2243
+
2244
+ def _continuation_pending() -> bool:
2245
+ """A bare 'continue'/'keep going' after an interrupted investigation must reach
2246
+ the AGENT, not plain chat — plain chat has zero tool access and can never
2247
+ actually continue a stalled grep_repo/read_file_lines investigation, which is
2248
+ exactly the reported bug: 'continue' silently degraded to a tool-less reply that
2249
+ could only apologize, never resume. Gated tightly (short continuation phrase +
2250
+ genuinely-interrupted prior turn) so it doesn't misfire on an unrelated 'continue'
2251
+ after a normal, already-complete answer."""
2252
+ return bool(_CONTINUE_CUE.match((latest_user_text or "").strip())
2253
+ and _prior_turn_was_interrupted())
2254
+
2188
2255
  # Route: does this task need HTTP tool use? A trivial greeting/thank-you/"who are you"
2189
2256
  # is caught LOCALLY (no model classifier call, no tool preamble) → straight to a fast
2190
2257
  # small-prompt reply. Otherwise ask the model classifier.
@@ -2201,6 +2268,13 @@ def run_agent_chat(cfg):
2201
2268
  _log("router → YES (email confirm pending)")
2202
2269
  _emit({"type": "step", "text": "→ Agent mode (email confirm)"})
2203
2270
  needs_agent = True
2271
+ # A bare "continue" after WE reported an interrupted investigation must reach the
2272
+ # agent too — plain chat has zero tool access, so it can only apologize again, never
2273
+ # actually resume the grep_repo/read_file_lines work (the reported bug).
2274
+ if not needs_agent and _continuation_pending():
2275
+ _log("router → YES (continuation of an interrupted investigation)")
2276
+ _emit({"type": "step", "text": "→ Agent mode (continuing)"})
2277
+ needs_agent = True
2204
2278
 
2205
2279
  if not needs_agent:
2206
2280
  _log("router: plain chat (no HTTP needed)")
@@ -2347,8 +2421,15 @@ def run_agent_chat(cfg):
2347
2421
  "If the user adds information, rag_add(source_url=url, text=...).\n"
2348
2422
  ) if _RAG_AVAILABLE else ""
2349
2423
  _ws_names = ", ".join(f"{w['name']} = {w['path']}" for w in _WS_ROOTS)
2424
+ # Upfront, ZERO-COST (no tool call, no model round-trip) top-level listing — so the
2425
+ # agent already knows the workspace is non-empty and roughly what's in it before its
2426
+ # first step, instead of having to spend (and risk losing to a timeout) a whole
2427
+ # round-trip just calling list_dir to discover that.
2428
+ _ws_overview = _workspace_overview(_WS_ROOTS) if _WS_AVAILABLE else ""
2350
2429
  _ws_tool_block = (
2351
2430
  f" WORKSPACES (local code folders you may READ and EDIT): {_ws_names}\n"
2431
+ f" Current contents:\n"
2432
+ + "\n".join(f" {line}" for line in _ws_overview.splitlines()) + "\n"
2352
2433
  " - list_dir(path) / read_text_file(path) — browse and read files.\n"
2353
2434
  " - grep_repo(pattern, path='', glob='') — search code, returns file:line hits. "
2354
2435
  "Use this to find the EXACT lines to change.\n"
@@ -3938,10 +4019,15 @@ def run_agent_chat(cfg):
3938
4019
  # "the real attempt never got to run" (seen live: workspace summarize request →
3939
4020
  # coding model timed out 3x on a slow/unreachable remote Ollama box → silent
3940
4021
  # fallback claimed "no access to your workspace").
3941
- _emit({"type": "final", "text": (
3942
- f"⚠️ I couldn't finish the full investigation ({_clip(str(e), 120)}) — here's "
3943
- f"what I can say without it:\n\n{fallback}"
3944
- )})
4022
+ note = f"⚠️ I couldn't finish the full investigation ({_clip(str(e), 120)}) — here's what I can say without it:\n\n{fallback}"
4023
+ # Append the workspace's ACTUAL top-level contents (computed locally, no model
4024
+ # call see _workspace_overview) so the user sees their files are really there
4025
+ # even during a total coding-model outage, instead of trusting a plain-chat
4026
+ # model's guess that "there's no code or project" (seen live — false: the
4027
+ # workspace had real content the whole time, the model just never reached it).
4028
+ if _WS_AVAILABLE:
4029
+ note += f"\n\n(Your registered workspace:\n{_workspace_overview(_WS_ROOTS)})"
4030
+ _emit({"type": "final", "text": note})
3945
4031
 
3946
4032
 
3947
4033
  def _log(text: str):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.194",
3
+ "version": "1.0.196",
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",