@tiens.nguyen/gonext-local-worker 1.0.206 → 1.0.207
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 +100 -7
- package/gonext_agent_chat.py +38 -6
- package/package.json +1 -1
package/gonext-repl.mjs
CHANGED
|
@@ -172,6 +172,14 @@ rl.setPrompt(PROMPT);
|
|
|
172
172
|
// final-byte) from every submitted line so stray arrow-key presses can't corrupt task
|
|
173
173
|
// text sent to the model.
|
|
174
174
|
const stripAnsiEscapes = (s) => String(s ?? "").replace(/\x1b\[[0-9;]*[A-Za-z~]/g, "");
|
|
175
|
+
// A fetch() failure (DNS, connection reset, timeout) throws rather than returning a
|
|
176
|
+
// response — recognize it so the poll loop can treat it as a transient blip, not a hard
|
|
177
|
+
// stop. Node's fetch throws a TypeError ("fetch failed") wrapping the socket error.
|
|
178
|
+
const isNetworkError = (e) =>
|
|
179
|
+
e instanceof TypeError ||
|
|
180
|
+
/fetch failed|network|ECONN|ETIMEDOUT|EAI_AGAIN|socket hang up|und_err|terminated/i.test(
|
|
181
|
+
String(e?.message ?? e)
|
|
182
|
+
);
|
|
175
183
|
const _lineQueue = [];
|
|
176
184
|
let _lineWaiter = null;
|
|
177
185
|
let _stdinClosed = false;
|
|
@@ -259,8 +267,20 @@ async function ensureWorkspace() {
|
|
|
259
267
|
const list = await readWorkspaces();
|
|
260
268
|
const hit = list.find((w) => cwd === w.path || cwd.startsWith(w.path + "/"));
|
|
261
269
|
if (hit) {
|
|
270
|
+
// Registered before cloud sync existed → ask once and persist. Otherwise honor the
|
|
271
|
+
// stored choice. (undefined = old record that never opted in → ask.)
|
|
272
|
+
if (hit.allowSync === undefined) {
|
|
273
|
+
hit.allowSync = await askYesNo(
|
|
274
|
+
"Allow syncing this workspace's chat history to the cloud?", true
|
|
275
|
+
);
|
|
276
|
+
await writeFile(WS_FILE, JSON.stringify({ workspaces: list }, null, 2) + "\n");
|
|
277
|
+
}
|
|
278
|
+
wsSync = { enabled: hit.allowSync !== false, name: hit.name, path: hit.path };
|
|
262
279
|
console.log(
|
|
263
|
-
dim(
|
|
280
|
+
dim(
|
|
281
|
+
`workspace: ${hit.name} → ${hit.path} (run ${hit.allowRun ? "enabled" : "disabled"}` +
|
|
282
|
+
`${hit.allowSync !== false ? ", cloud sync on" : ""})`
|
|
283
|
+
)
|
|
264
284
|
);
|
|
265
285
|
return;
|
|
266
286
|
}
|
|
@@ -281,18 +301,26 @@ async function ensureWorkspace() {
|
|
|
281
301
|
still allowlisted (npm/mvn/pytest/…) and run with a scrubbed env. Type n to
|
|
282
302
|
keep a workspace read/edit-only. */
|
|
283
303
|
);
|
|
304
|
+
const allowSync = await askYesNo(
|
|
305
|
+
"Allow syncing this workspace's chat history to the cloud?\n" +
|
|
306
|
+
"(your conversation is saved to your account so you can see it on the web app)",
|
|
307
|
+
true /* default Yes — RAG is NOT synced (it stays on your S3); only chat history. */
|
|
308
|
+
);
|
|
284
309
|
const next = list.filter((w) => w.path !== cwd);
|
|
285
310
|
next.push({
|
|
286
311
|
name: basename(cwd),
|
|
287
312
|
path: cwd,
|
|
288
313
|
allowRun,
|
|
314
|
+
allowSync,
|
|
289
315
|
addedAt: new Date().toISOString(),
|
|
290
316
|
});
|
|
291
317
|
await mkdir(dirname(WS_FILE), { recursive: true });
|
|
292
318
|
await writeFile(WS_FILE, JSON.stringify({ workspaces: next }, null, 2) + "\n");
|
|
319
|
+
wsSync = { enabled: allowSync, name: basename(cwd), path: cwd };
|
|
293
320
|
console.log(
|
|
294
321
|
green(`✓ workspace registered: ${basename(cwd)}`) +
|
|
295
|
-
(allowRun ? green(" (run-commands enabled)") : dim(" (run-commands disabled)"))
|
|
322
|
+
(allowRun ? green(" (run-commands enabled)") : dim(" (run-commands disabled)")) +
|
|
323
|
+
(allowSync ? green(" · cloud sync on") : dim(" · cloud sync off"))
|
|
296
324
|
);
|
|
297
325
|
}
|
|
298
326
|
|
|
@@ -315,6 +343,34 @@ function sessionFilePath(cwd) {
|
|
|
315
343
|
// for a long-lived folder with no benefit.
|
|
316
344
|
const MAX_PERSISTED_MESSAGES = 40;
|
|
317
345
|
|
|
346
|
+
// Cloud sync (task #47), opt-in per workspace. Set by ensureWorkspace() for the current
|
|
347
|
+
// folder; when enabled, saveSession/clearSession also mirror the chat history to Mongo
|
|
348
|
+
// via the worker-authed /api/worker/workspace-sync. RAG is NOT synced (stays on S3).
|
|
349
|
+
let wsSync = { enabled: false, name: "", path: "" };
|
|
350
|
+
const workspaceKeyFor = (cwd) =>
|
|
351
|
+
createHash("sha256").update(cwd).digest("hex").slice(0, 32);
|
|
352
|
+
|
|
353
|
+
async function pushWorkspaceSync(cwd, history, { del = false } = {}) {
|
|
354
|
+
if (!wsSync.enabled) return;
|
|
355
|
+
const body = del
|
|
356
|
+
? { workspaceKey: workspaceKeyFor(cwd), delete: true }
|
|
357
|
+
: {
|
|
358
|
+
workspaceKey: workspaceKeyFor(cwd),
|
|
359
|
+
name: wsSync.name || basename(cwd),
|
|
360
|
+
path: cwd,
|
|
361
|
+
history: history.slice(-MAX_PERSISTED_MESSAGES),
|
|
362
|
+
};
|
|
363
|
+
try {
|
|
364
|
+
await fetch(`${apiBase}/api/worker/workspace-sync`, {
|
|
365
|
+
method: "POST",
|
|
366
|
+
headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
|
|
367
|
+
body: JSON.stringify(body),
|
|
368
|
+
});
|
|
369
|
+
} catch {
|
|
370
|
+
// Best-effort — a cloud-sync blip must never block the REPL (local save already done).
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
318
374
|
async function loadSession(cwd) {
|
|
319
375
|
try {
|
|
320
376
|
const raw = JSON.parse(await readFile(sessionFilePath(cwd), "utf8"));
|
|
@@ -340,6 +396,8 @@ async function saveSession(cwd, history) {
|
|
|
340
396
|
// Non-fatal — losing session persistence should never crash or block the REPL.
|
|
341
397
|
console.error(dim(`(session save failed: ${err.message})`));
|
|
342
398
|
}
|
|
399
|
+
// Mirror to the cloud AFTER the local write (fire-and-forget; opt-in via wsSync).
|
|
400
|
+
void pushWorkspaceSync(cwd, history);
|
|
343
401
|
}
|
|
344
402
|
|
|
345
403
|
async function clearSession(cwd) {
|
|
@@ -348,6 +406,7 @@ async function clearSession(cwd) {
|
|
|
348
406
|
} catch {
|
|
349
407
|
// Nothing on disk to clear — fine.
|
|
350
408
|
}
|
|
409
|
+
void pushWorkspaceSync(cwd, [], { del: true }); // /reset removes the cloud copy too
|
|
351
410
|
}
|
|
352
411
|
|
|
353
412
|
// ---------- fetch the ready-to-run agent payload from user settings ----------
|
|
@@ -497,6 +556,11 @@ async function runAgentTurn(history) {
|
|
|
497
556
|
let carry = ""; // trailing partial content line held until its newline arrives
|
|
498
557
|
let statusShown = false; // a transient status line is currently on screen
|
|
499
558
|
let warnedPending = false;
|
|
559
|
+
// Transient poll failures (a network blip or a 5xx like the API's momentary "Worker
|
|
560
|
+
// key lookup failed." — a cold Mongo connection, NOT a bad key) must NOT kill a
|
|
561
|
+
// long-running turn. Tolerate a run of them; only give up after too many in a row.
|
|
562
|
+
let pollFailures = 0;
|
|
563
|
+
const MAX_POLL_FAILURES = 6; // ~6 tries with backoff before conceding the follow
|
|
500
564
|
let jobRunning = false; // only tick "thinking…" once the worker has claimed the job
|
|
501
565
|
let answerShownLive = false; // plain-reply content already printed — don't re-print it
|
|
502
566
|
// True once we've SPECIFICALLY seen "→ Chat reply" (the plain-reply/trivial-chat
|
|
@@ -749,11 +813,40 @@ async function runAgentTurn(history) {
|
|
|
749
813
|
);
|
|
750
814
|
return { text: "", shown: false };
|
|
751
815
|
}
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
816
|
+
let job;
|
|
817
|
+
try {
|
|
818
|
+
const jr = await fetch(`${apiBase}/api/worker/jobs/${jobId}`, {
|
|
819
|
+
headers: { "X-Worker-Key": workerKey },
|
|
820
|
+
});
|
|
821
|
+
const parsed = await jr.json().catch(() => ({}));
|
|
822
|
+
if (jr.ok) {
|
|
823
|
+
job = parsed;
|
|
824
|
+
pollFailures = 0; // a clean poll resets the tolerance window
|
|
825
|
+
} else if (jr.status >= 400 && jr.status < 500) {
|
|
826
|
+
// Client error (bad/expired key, job genuinely gone) — NOT transient, fail fast.
|
|
827
|
+
throw new Error(parsed?.error || `job poll failed (HTTP ${jr.status})`);
|
|
828
|
+
} else {
|
|
829
|
+
// 5xx — a momentary server/DB blip (e.g. "Worker key lookup failed."). Transient.
|
|
830
|
+
throw Object.assign(
|
|
831
|
+
new Error(parsed?.error || `job poll failed (HTTP ${jr.status})`),
|
|
832
|
+
{ transient: true }
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
} catch (err) {
|
|
836
|
+
// A fetch/network exception has no HTTP status — treat it as transient too.
|
|
837
|
+
const transient = err?.transient === true || isNetworkError(err);
|
|
838
|
+
if (!transient) throw err;
|
|
839
|
+
pollFailures += 1;
|
|
840
|
+
if (pollFailures >= MAX_POLL_FAILURES) {
|
|
841
|
+
clearStatus();
|
|
842
|
+
throw new Error(
|
|
843
|
+
`lost contact with the API after ${pollFailures} tries (${String(err.message).slice(0, 80)}). ` +
|
|
844
|
+
"The turn may still be running on the worker — try 'continue'."
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
await sleep(Math.min(700 * pollFailures, 3000)); // linear backoff, capped at 3s
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
757
850
|
jobRunning = job.jobStatus === "running";
|
|
758
851
|
const text = typeof job.resultText === "string" ? job.resultText : "";
|
|
759
852
|
if (job.jobStatus === "completed") {
|
package/gonext_agent_chat.py
CHANGED
|
@@ -1891,6 +1891,19 @@ def _ws_for_path(path: str):
|
|
|
1891
1891
|
return None
|
|
1892
1892
|
|
|
1893
1893
|
|
|
1894
|
+
def _default_ws_root() -> str:
|
|
1895
|
+
"""The folder a tool defaults to when the model passes NO path/workdir: the terminal's
|
|
1896
|
+
ACTIVE folder (where `gonext` was launched), NOT the first-registered workspace.
|
|
1897
|
+
Registered workspaces accumulate across sessions, so falling back to _WS_ROOTS[0]
|
|
1898
|
+
made a no-workdir command land in an OLD workspace (bug #46: launched in t9, created
|
|
1899
|
+
the project in t1). Falls back to the first workspace only when there is no active
|
|
1900
|
+
folder at all."""
|
|
1901
|
+
import os
|
|
1902
|
+
if _WS_ACTIVE and os.path.isdir(_WS_ACTIVE):
|
|
1903
|
+
return _WS_ACTIVE
|
|
1904
|
+
return _WS_ROOTS[0]["path"] if _WS_ROOTS else "."
|
|
1905
|
+
|
|
1906
|
+
|
|
1894
1907
|
def _workspace_overview(roots, max_entries: int = 40) -> str:
|
|
1895
1908
|
"""Cheap, LOCAL top-level listing of each registered workspace root — a plain
|
|
1896
1909
|
os.listdir(), no recursion, no network call, no model call. Used two ways: (1)
|
|
@@ -2744,14 +2757,33 @@ def run_agent_chat(cfg):
|
|
|
2744
2757
|
"If the user adds information, rag_add(source_url=url, text=...).\n"
|
|
2745
2758
|
) if _RAG_AVAILABLE else ""
|
|
2746
2759
|
_ws_names = ", ".join(f"{w['name']} = {w['path']}" for w in _WS_ROOTS)
|
|
2760
|
+
# The folder the terminal is OPEN in (where `gonext` was launched). This is where the
|
|
2761
|
+
# user means "here"/"this workspace" and where a no-path/no-workdir tool call defaults
|
|
2762
|
+
# (see _default_ws_root). Registered workspaces accumulate over time, so WITHOUT this
|
|
2763
|
+
# the model would pick an arbitrary/old one (bug #46: opened in t9, built in t1).
|
|
2764
|
+
_active_ws = _ws_for_path(_WS_ACTIVE) if _WS_ACTIVE else None
|
|
2765
|
+
_active_label = (
|
|
2766
|
+
f"{_active_ws['name']} = {_WS_ACTIVE}" if _active_ws else (_WS_ACTIVE or "")
|
|
2767
|
+
)
|
|
2768
|
+
_ws_current_line = (
|
|
2769
|
+
f" CURRENT folder — the terminal is open HERE; default ALL create/edit/run/"
|
|
2770
|
+
f"download operations to THIS folder unless the user EXPLICITLY names another "
|
|
2771
|
+
f"workspace: {_active_label}\n"
|
|
2772
|
+
if _active_label else ""
|
|
2773
|
+
)
|
|
2747
2774
|
# Upfront, ZERO-COST (no tool call, no model round-trip) top-level listing — so the
|
|
2748
2775
|
# agent already knows the workspace is non-empty and roughly what's in it before its
|
|
2749
2776
|
# first step, instead of having to spend (and risk losing to a timeout) a whole
|
|
2750
|
-
# round-trip just calling list_dir to discover that.
|
|
2751
|
-
|
|
2777
|
+
# round-trip just calling list_dir to discover that. Only the CURRENT folder (not every
|
|
2778
|
+
# accumulated workspace) — to keep the model anchored on where it should act.
|
|
2779
|
+
_overview_roots = (
|
|
2780
|
+
[_active_ws] if _active_ws else _WS_ROOTS
|
|
2781
|
+
) if _WS_AVAILABLE else []
|
|
2782
|
+
_ws_overview = _workspace_overview(_overview_roots) if _overview_roots else ""
|
|
2752
2783
|
_ws_tool_block = (
|
|
2753
|
-
|
|
2754
|
-
f"
|
|
2784
|
+
_ws_current_line
|
|
2785
|
+
+ f" Other registered workspaces (use only if the user names them): {_ws_names}\n"
|
|
2786
|
+
f" Current folder contents:\n"
|
|
2755
2787
|
+ "\n".join(f" {line}" for line in _ws_overview.splitlines()) + "\n"
|
|
2756
2788
|
" - list_dir(path) / read_text_file(path) — browse and read files.\n"
|
|
2757
2789
|
" - grep_repo(pattern, path='', glob='') — search code, returns file:line hits. "
|
|
@@ -4085,7 +4117,7 @@ def run_agent_chat(cfg):
|
|
|
4085
4117
|
try:
|
|
4086
4118
|
import fnmatch
|
|
4087
4119
|
import os
|
|
4088
|
-
root = _rag_read_allowed((path or "").strip() or (
|
|
4120
|
+
root = _rag_read_allowed((path or "").strip() or _default_ws_root())
|
|
4089
4121
|
try:
|
|
4090
4122
|
rx = re.compile(pattern)
|
|
4091
4123
|
except re.error:
|
|
@@ -4330,7 +4362,7 @@ def run_agent_chat(cfg):
|
|
|
4330
4362
|
import signal
|
|
4331
4363
|
import subprocess
|
|
4332
4364
|
import time as _t
|
|
4333
|
-
wd = _rag_read_allowed((workdir or "").strip() or (
|
|
4365
|
+
wd = _rag_read_allowed((workdir or "").strip() or _default_ws_root())
|
|
4334
4366
|
w = _ws_for_path(wd)
|
|
4335
4367
|
if w is None or not w.get("allowRun"):
|
|
4336
4368
|
msg = ("Error: running commands is not enabled for this workspace. "
|
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.207",
|
|
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",
|