@tiens.nguyen/gonext-local-worker 1.0.175 → 1.0.178
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-local-worker.mjs +3 -0
- package/gonext-repl.mjs +93 -109
- package/gonext_agent_chat.py +53 -13
- package/package.json +1 -1
package/gonext-local-worker.mjs
CHANGED
|
@@ -1799,6 +1799,9 @@ async function runAgentChatJob(job) {
|
|
|
1799
1799
|
return Array.isArray(parsed?.workspaces) ? parsed.workspaces : [];
|
|
1800
1800
|
})
|
|
1801
1801
|
.catch(() => []),
|
|
1802
|
+
// The `gonext` terminal REPL's launch folder — download_file/unzip_file output
|
|
1803
|
+
// lands here (must be a registered workspace; the python side re-validates).
|
|
1804
|
+
activeWorkspace: payload?.activeWorkspace ?? "",
|
|
1802
1805
|
});
|
|
1803
1806
|
// 30 min max for an agent run: multi-step ReAct on a 14B/31B with a cold prompt
|
|
1804
1807
|
// cache — or a remote Ollama box on slow GPU cold-loading a big model — can run
|
package/gonext-repl.mjs
CHANGED
|
@@ -8,13 +8,15 @@
|
|
|
8
8
|
*
|
|
9
9
|
* It offers to register the CURRENT folder as an agent workspace (with a trust prompt
|
|
10
10
|
* that maps to --allow-run), authenticates with the worker key from ~/.gonext/worker.env
|
|
11
|
-
* (the same file `gonext-local-worker set` / the Wizard write),
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* (the same file `gonext-local-worker set` / the Wizard write), and loops on a `>> `
|
|
12
|
+
* prompt. Each question is ENQUEUED as a normal agent_chat job (models/budgets/RAG
|
|
13
|
+
* resolved from your user settings by POST /api/worker/agent-ask) and executed by the
|
|
14
|
+
* RUNNING gonext-local-worker daemon — so its terminal shows the full [gonext-agent]
|
|
15
|
+
* logs, exactly like a web question. The REPL polls the job and streams the persisted
|
|
16
|
+
* chunks live into the terminal.
|
|
16
17
|
*
|
|
17
|
-
*
|
|
18
|
+
* Requires the worker daemon to be running (it claims the job).
|
|
19
|
+
* Commands: /exit /revert [runId] /help Ctrl-C stops following the current run.
|
|
18
20
|
*/
|
|
19
21
|
import { readFile, mkdir, writeFile } from "node:fs/promises";
|
|
20
22
|
import { existsSync, statSync } from "node:fs";
|
|
@@ -43,9 +45,9 @@ if (argv.includes("--help") || argv.includes("-h")) {
|
|
|
43
45
|
" -v, --verbose show the agent's diagnostic logs ([gonext-agent] …) and python stderr\n" +
|
|
44
46
|
" -h, --help this help\n\n" +
|
|
45
47
|
"Auth comes from ~/.gonext/worker.env; models/budgets/RAG come from your web Settings.\n" +
|
|
46
|
-
"Inside the REPL: /exit · /revert [runId] · /help — Ctrl-C
|
|
47
|
-
"
|
|
48
|
-
"daemon
|
|
48
|
+
"Inside the REPL: /exit · /revert [runId] · /help — Ctrl-C stops following a turn.\n" +
|
|
49
|
+
"Questions are enqueued like web questions and executed by the RUNNING\n" +
|
|
50
|
+
"gonext-local-worker daemon — its terminal shows the full [gonext-agent] logs."
|
|
49
51
|
);
|
|
50
52
|
process.exit(0);
|
|
51
53
|
}
|
|
@@ -176,104 +178,88 @@ async function fetchAgentPayload(messages) {
|
|
|
176
178
|
return data;
|
|
177
179
|
}
|
|
178
180
|
|
|
179
|
-
// ---------- run one agent turn
|
|
180
|
-
|
|
181
|
+
// ---------- run one agent turn via the JOB QUEUE (the daemon executes it) ----------
|
|
182
|
+
// The question is enqueued exactly like a web question, so the RUNNING
|
|
183
|
+
// gonext-local-worker daemon claims + executes it — its terminal shows the full
|
|
184
|
+
// [gonext-agent] logs. We poll the job here and stream the persisted chunks live.
|
|
185
|
+
let following = false;
|
|
186
|
+
let followAborted = false;
|
|
181
187
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
codingModelId: payload?.codingModelId ?? "",
|
|
192
|
-
tools: payload?.tools ?? ["http_request"],
|
|
193
|
-
maxSteps: payload?.maxSteps ?? 0,
|
|
194
|
-
researchBudget: payload?.researchBudget ?? 10,
|
|
195
|
-
agentToolMode: payload?.agentToolMode ?? "code",
|
|
196
|
-
apiBaseURL: apiBase,
|
|
197
|
-
workerKey,
|
|
198
|
-
emailEnabled: payload?.emailEnabled ?? false,
|
|
199
|
-
emailApiUrl: payload?.emailApiUrl ?? "",
|
|
200
|
-
emailApiMethod: payload?.emailApiMethod ?? "POST",
|
|
201
|
-
emailApiHeaders: payload?.emailApiHeaders ?? "",
|
|
202
|
-
emailBodyTemplate: payload?.emailBodyTemplate ?? "",
|
|
203
|
-
emailFrom: payload?.emailFrom ?? "",
|
|
204
|
-
emailAllowList: payload?.emailAllowList ?? "",
|
|
205
|
-
ragEnabled: payload?.ragEnabled ?? false,
|
|
206
|
-
ragS3Location: payload?.ragS3Location ?? "",
|
|
207
|
-
ragAwsRegion: payload?.ragAwsRegion ?? "",
|
|
208
|
-
ragAwsAccessKeyId: payload?.ragAwsAccessKeyId ?? "",
|
|
209
|
-
ragAwsSecretAccessKey: payload?.ragAwsSecretAccessKey ?? "",
|
|
210
|
-
ragEmbedModel: payload?.ragEmbedModel ?? "",
|
|
211
|
-
ragEmbedUrl: payload?.ragEmbedUrl ?? "",
|
|
212
|
-
ragTopK: payload?.ragTopK ?? 6,
|
|
213
|
-
workspaces: await readWorkspaces(),
|
|
214
|
-
});
|
|
188
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
189
|
+
|
|
190
|
+
/** Strip <think> markers for live display, and whole think blocks for the answer. */
|
|
191
|
+
const stripThinkTags = (s) => s.replace(/<\/?think>/gi, "");
|
|
192
|
+
const answerFrom = (s) =>
|
|
193
|
+
s
|
|
194
|
+
.replace(/<think>[\s\S]*?<\/think>/gi, "")
|
|
195
|
+
.replace(/<think>[\s\S]*$/i, "")
|
|
196
|
+
.trim();
|
|
215
197
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
"
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
198
|
+
async function runAgentTurn(history) {
|
|
199
|
+
const res = await fetch(`${apiBase}/api/worker/agent-ask`, {
|
|
200
|
+
method: "POST",
|
|
201
|
+
headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
|
|
202
|
+
// cwd = this terminal's folder → agent puts download/unzip output here (when it's
|
|
203
|
+
// a registered workspace).
|
|
204
|
+
body: JSON.stringify({ messages: history, cwd: resolve(process.cwd()) }),
|
|
205
|
+
});
|
|
206
|
+
const data = await res.json().catch(() => ({}));
|
|
207
|
+
if (!res.ok) {
|
|
208
|
+
if (res.status === 404) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
"the API does not have POST /api/worker/agent-ask yet — deploy the latest API."
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
throw new Error(data?.error || `agent-ask failed (HTTP ${res.status})`);
|
|
228
214
|
}
|
|
215
|
+
const jobId = data?.jobId;
|
|
216
|
+
if (!jobId) throw new Error("agent-ask returned no jobId.");
|
|
229
217
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const line = buf.slice(0, nl);
|
|
243
|
-
buf = buf.slice(nl + 1);
|
|
244
|
-
if (!line.trim()) continue;
|
|
245
|
-
let ev;
|
|
246
|
-
try {
|
|
247
|
-
ev = JSON.parse(line);
|
|
248
|
-
} catch {
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
if (ev.type === "step" && typeof ev.text === "string") {
|
|
252
|
-
process.stdout.write(dim(`\n· ${ev.text}`));
|
|
253
|
-
} else if (ev.type === "stream" && typeof ev.text === "string") {
|
|
254
|
-
process.stdout.write(dim(ev.text));
|
|
255
|
-
} else if (ev.type === "final" && typeof ev.text === "string") {
|
|
256
|
-
finalText = ev.text;
|
|
257
|
-
} else if (ev.type === "log" && process.env.GONEXT_REPL_VERBOSE === "1") {
|
|
258
|
-
process.stdout.write(dim(`\n[log] ${ev.text}`));
|
|
259
|
-
}
|
|
218
|
+
following = true;
|
|
219
|
+
followAborted = false;
|
|
220
|
+
const startedAt = Date.now();
|
|
221
|
+
let shownChars = 0;
|
|
222
|
+
let warnedPending = false;
|
|
223
|
+
try {
|
|
224
|
+
for (;;) {
|
|
225
|
+
if (followAborted) {
|
|
226
|
+
process.stdout.write(
|
|
227
|
+
dim("\n(stopped following — the job keeps running on the worker)\n")
|
|
228
|
+
);
|
|
229
|
+
return "";
|
|
260
230
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
231
|
+
const jr = await fetch(`${apiBase}/api/worker/jobs/${jobId}`, {
|
|
232
|
+
headers: { "X-Worker-Key": workerKey },
|
|
233
|
+
});
|
|
234
|
+
const job = await jr.json().catch(() => ({}));
|
|
235
|
+
if (!jr.ok) throw new Error(job?.error || `job poll failed (HTTP ${jr.status})`);
|
|
236
|
+
const text = typeof job.resultText === "string" ? job.resultText : "";
|
|
237
|
+
if (job.jobStatus === "completed") {
|
|
238
|
+
// Don't stream the final delta dim — the answer prints bright right after.
|
|
239
|
+
return answerFrom(text);
|
|
265
240
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
241
|
+
if (text.length > shownChars) {
|
|
242
|
+
process.stdout.write(dim(stripThinkTags(text.slice(shownChars))));
|
|
243
|
+
shownChars = text.length;
|
|
244
|
+
}
|
|
245
|
+
if (job.jobStatus === "failed" || job.jobStatus === "cancelled") {
|
|
246
|
+
throw new Error(job.errorMessage || `job ${job.jobStatus}`);
|
|
247
|
+
}
|
|
248
|
+
if (
|
|
249
|
+
job.jobStatus === "pending" &&
|
|
250
|
+
!warnedPending &&
|
|
251
|
+
Date.now() - startedAt > 6000
|
|
252
|
+
) {
|
|
253
|
+
warnedPending = true;
|
|
254
|
+
process.stdout.write(
|
|
255
|
+
red("\n⚠ no worker has claimed the job — is `gonext-local-worker` running?\n")
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
await sleep(700);
|
|
259
|
+
}
|
|
260
|
+
} finally {
|
|
261
|
+
following = false;
|
|
262
|
+
}
|
|
277
263
|
}
|
|
278
264
|
|
|
279
265
|
// ---------- REPL ----------
|
|
@@ -299,10 +285,9 @@ async function main() {
|
|
|
299
285
|
|
|
300
286
|
const history = [];
|
|
301
287
|
rl.on("SIGINT", () => {
|
|
302
|
-
if (
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
process.stdout.write(red("\n^C run aborted\n"));
|
|
288
|
+
if (following) {
|
|
289
|
+
followAborted = true;
|
|
290
|
+
process.stdout.write(red("\n^C "));
|
|
306
291
|
} else {
|
|
307
292
|
process.stdout.write(dim("\n(/exit to quit)\n") + cyan(">> "));
|
|
308
293
|
}
|
|
@@ -334,14 +319,13 @@ async function main() {
|
|
|
334
319
|
}
|
|
335
320
|
history.push({ role: "user", content: line });
|
|
336
321
|
try {
|
|
337
|
-
const
|
|
338
|
-
const answer = await runAgentTurn(payload);
|
|
322
|
+
const answer = await runAgentTurn(history);
|
|
339
323
|
if (answer) {
|
|
340
324
|
history.push({ role: "assistant", content: answer });
|
|
341
325
|
console.log(`\n\n${answer}\n`);
|
|
342
326
|
} else {
|
|
343
|
-
history.pop(); // failed turn — don't poison the next request's history
|
|
344
|
-
console.log(red("\n(no answer — run
|
|
327
|
+
history.pop(); // aborted/failed turn — don't poison the next request's history
|
|
328
|
+
console.log(red("\n(no answer — run aborted or failed)\n"));
|
|
345
329
|
}
|
|
346
330
|
} catch (err) {
|
|
347
331
|
history.pop();
|
package/gonext_agent_chat.py
CHANGED
|
@@ -1158,6 +1158,17 @@ def _rag_workdir(source_key: str) -> str:
|
|
|
1158
1158
|
return base
|
|
1159
1159
|
|
|
1160
1160
|
|
|
1161
|
+
def _rag_base_dir(source_key: str) -> str:
|
|
1162
|
+
"""Where a URL's downloaded/unzipped files should live. Prefer the active terminal
|
|
1163
|
+
workspace (the folder the `gonext` REPL was opened from) so files land in the
|
|
1164
|
+
user's project; otherwise fall back to the per-URL cache dir under ~/.gonext."""
|
|
1165
|
+
import os
|
|
1166
|
+
if _WS_ACTIVE:
|
|
1167
|
+
os.makedirs(_WS_ACTIVE, exist_ok=True)
|
|
1168
|
+
return _WS_ACTIVE
|
|
1169
|
+
return _rag_workdir(source_key)
|
|
1170
|
+
|
|
1171
|
+
|
|
1161
1172
|
def _rag_download(url: str, dest_path: str = "") -> tuple:
|
|
1162
1173
|
"""Download a public URL with SSRF + size guards — IDEMPOTENT per URL. Every URL
|
|
1163
1174
|
maps to a deterministic work dir (~/.gonext/rag-work/<urlHash>/); relative
|
|
@@ -1168,7 +1179,7 @@ def _rag_download(url: str, dest_path: str = "") -> tuple:
|
|
|
1168
1179
|
src = _rag_gdrive_direct((url or "").strip())
|
|
1169
1180
|
_rag_assert_safe_url(src)
|
|
1170
1181
|
key = _rag_source_key(url)
|
|
1171
|
-
workdir =
|
|
1182
|
+
workdir = _rag_base_dir(key)
|
|
1172
1183
|
dest_path = os.path.expanduser((dest_path or "").strip())
|
|
1173
1184
|
if not dest_path:
|
|
1174
1185
|
name = os.path.basename(src.split("?")[0]) or "download.bin"
|
|
@@ -1176,21 +1187,23 @@ def _rag_download(url: str, dest_path: str = "") -> tuple:
|
|
|
1176
1187
|
name += ".zip"
|
|
1177
1188
|
dest_path = os.path.join(workdir, name)
|
|
1178
1189
|
elif not os.path.isabs(dest_path):
|
|
1179
|
-
# Contain relative paths in the
|
|
1190
|
+
# Contain relative paths in the work dir — models pass bare names like
|
|
1180
1191
|
# "project.zip" which would otherwise land in the worker's cwd (the home dir).
|
|
1181
1192
|
dest_path = os.path.join(workdir, dest_path)
|
|
1182
1193
|
# Cache hit 1: the exact target already exists.
|
|
1183
1194
|
if os.path.isfile(dest_path) and os.path.getsize(dest_path) > 0:
|
|
1184
1195
|
return dest_path, True
|
|
1185
|
-
# Cache hit 2: this URL was downloaded before under a different name —
|
|
1186
|
-
#
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1196
|
+
# Cache hit 2: this URL was downloaded before under a different name — ONLY safe in
|
|
1197
|
+
# the per-URL cache dir (one URL per dir). In an active workspace the folder holds
|
|
1198
|
+
# many unrelated files, so a bare "any zip = this URL" match would be wrong.
|
|
1199
|
+
if not _WS_ACTIVE:
|
|
1200
|
+
try:
|
|
1201
|
+
for fn in sorted(os.listdir(workdir)):
|
|
1202
|
+
p = os.path.join(workdir, fn)
|
|
1203
|
+
if os.path.isfile(p) and fn.lower().endswith(".zip") and os.path.getsize(p) > 0:
|
|
1204
|
+
return p, True
|
|
1205
|
+
except OSError:
|
|
1206
|
+
pass
|
|
1194
1207
|
os.makedirs(os.path.dirname(dest_path) or ".", exist_ok=True)
|
|
1195
1208
|
req = urllib.request.Request(src, headers={"User-Agent": "gonext-rag/1.0"})
|
|
1196
1209
|
total = 0
|
|
@@ -1220,8 +1233,15 @@ def _rag_resolve_zip(zip_path: str) -> str:
|
|
|
1220
1233
|
if os.path.isfile(zp):
|
|
1221
1234
|
return zp
|
|
1222
1235
|
if not os.path.isabs(zp):
|
|
1236
|
+
base = os.path.basename(zp)
|
|
1237
|
+
# Look in the active terminal workspace first (that's where downloads land now),
|
|
1238
|
+
# then fall back to the per-URL cache dirs.
|
|
1239
|
+
if _WS_ACTIVE:
|
|
1240
|
+
cand = os.path.join(_WS_ACTIVE, base)
|
|
1241
|
+
if os.path.isfile(cand):
|
|
1242
|
+
return cand
|
|
1223
1243
|
hits = _glob.glob(os.path.join(
|
|
1224
|
-
os.path.expanduser("~"), ".gonext", "rag-work", "*",
|
|
1244
|
+
os.path.expanduser("~"), ".gonext", "rag-work", "*", base))
|
|
1225
1245
|
if hits:
|
|
1226
1246
|
return hits[0]
|
|
1227
1247
|
return zp
|
|
@@ -1424,6 +1444,12 @@ def _rag_parse_location(loc: str):
|
|
|
1424
1444
|
# require _ws_write_allowed (workspace root + not inside .git/).
|
|
1425
1445
|
_WS_ROOTS: list = [] # [{"name": str, "path": realpath str, "allowRun": bool}]
|
|
1426
1446
|
|
|
1447
|
+
# The active terminal workspace: the folder the `gonext` REPL was launched from
|
|
1448
|
+
# (passed through as payload.activeWorkspace). When set, download_file/unzip_file put
|
|
1449
|
+
# their output HERE instead of the shared ~/.gonext/rag-work cache, so files land in
|
|
1450
|
+
# the user's actual project folder. Only honoured if it's a registered workspace root.
|
|
1451
|
+
_WS_ACTIVE: str = ""
|
|
1452
|
+
|
|
1427
1453
|
|
|
1428
1454
|
def _rag_read_allowed(path: str) -> str:
|
|
1429
1455
|
"""Resolve `path` and ensure it stays within a safe root (worker cwd, temp dir,
|
|
@@ -1741,7 +1767,7 @@ def run_agent_chat(cfg):
|
|
|
1741
1767
|
# Registered on the Mac via `gonext-local-worker workspace add <path>`; the worker
|
|
1742
1768
|
# passes the registry through cfg. Only these roots are writable — the security
|
|
1743
1769
|
# boundary for the coding tools.
|
|
1744
|
-
global _WS_ROOTS
|
|
1770
|
+
global _WS_ROOTS, _WS_ACTIVE
|
|
1745
1771
|
_WS_ROOTS = []
|
|
1746
1772
|
for w in (cfg.get("workspaces") or []):
|
|
1747
1773
|
try:
|
|
@@ -1756,6 +1782,20 @@ def run_agent_chat(cfg):
|
|
|
1756
1782
|
except Exception: # noqa: BLE001
|
|
1757
1783
|
continue
|
|
1758
1784
|
_WS_AVAILABLE = bool(_WS_ROOTS)
|
|
1785
|
+
# Active terminal workspace (the `gonext` REPL's launch folder) — download_file /
|
|
1786
|
+
# unzip_file put their output here. Only honoured if it's a registered workspace
|
|
1787
|
+
# root, so it can't redirect writes to an arbitrary folder.
|
|
1788
|
+
_WS_ACTIVE = ""
|
|
1789
|
+
try:
|
|
1790
|
+
import os as _os
|
|
1791
|
+
aw = _os.path.realpath(_os.path.expanduser(str(cfg.get("activeWorkspace") or "")))
|
|
1792
|
+
if aw and _os.path.isdir(aw) and any(
|
|
1793
|
+
aw == w["path"] or aw.startswith(w["path"] + _os.sep) for w in _WS_ROOTS
|
|
1794
|
+
):
|
|
1795
|
+
_WS_ACTIVE = aw
|
|
1796
|
+
_log(f"active workspace: {aw} (download/unzip output → here)")
|
|
1797
|
+
except Exception: # noqa: BLE001
|
|
1798
|
+
_WS_ACTIVE = ""
|
|
1759
1799
|
if _WS_AVAILABLE:
|
|
1760
1800
|
_log(f"workspaces: {[(w['name'], w['path'], w['allowRun']) for w in _WS_ROOTS]}")
|
|
1761
1801
|
# Coding tasks are edit→test→fix loops — 5 steps is too tight. Bump the default
|
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.178",
|
|
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",
|