@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 +19 -7
- package/gonext_agent_chat.py +90 -4
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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(
|
|
149
|
+
w(clean);
|
|
138
150
|
} else {
|
|
139
|
-
_lineQueue.push(
|
|
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
|
|
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(
|
|
415
|
+
process.stdout.write(codeColor(line) + "\n");
|
|
404
416
|
lastWasBlank = false;
|
|
405
417
|
continue;
|
|
406
418
|
}
|
package/gonext_agent_chat.py
CHANGED
|
@@ -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
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
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.
|
|
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",
|