@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.
@@ -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), pulls your agent/coding
12
- * model + budgets + RAG config FROM YOUR USER SETTINGS via POST /api/worker/agent-payload,
13
- * then loops on a `>> ` prompt. Each question executes gonext_agent_chat.py DIRECTLY
14
- * (no job queueno race with a polling worker daemon), streaming the agent's steps
15
- * and thinking into the terminal.
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 daemonso 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
- * Commands: /exit /revert [runId] /help Ctrl-C aborts the current run.
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 aborts the running turn.\n" +
47
- "Note: REPL turns run in THIS process (no job queue), so the gonext-local-worker\n" +
48
- "daemon shows no logs for them use -v to see the agent diagnostics here."
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 by executing gonext_agent_chat.py directly ----------
180
- let child = null;
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
- async function runAgentTurn(payload) {
183
- // Map the job payload to the python cfg the same way the worker daemon does in
184
- // runAgentChatJob (gonext-local-worker.mjs) KEEP IN SYNC with that block.
185
- const input = JSON.stringify({
186
- messages: payload?.messages ?? [],
187
- agentBaseURL: payload?.agentBaseURL ?? "",
188
- agentApiKey: payload?.agentApiKey ?? "",
189
- agentModelId: payload?.agentModelId ?? "",
190
- codingBaseURL: payload?.codingBaseURL ?? "",
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
- const python =
217
- (process.env.GONEXT_PROBE_PYTHON ?? process.env.GONEXT_MLX_LM_PYTHON ?? "").trim() ||
218
- "python3";
219
- // WeasyPrint (create_pdf) needs Homebrew libs on macOS — same env the worker sets.
220
- let extraEnv;
221
- if (process.platform === "darwin") {
222
- const existing = process.env.DYLD_FALLBACK_LIBRARY_PATH ?? "";
223
- extraEnv = {
224
- DYLD_FALLBACK_LIBRARY_PATH: [existing, "/opt/homebrew/lib", "/usr/local/lib"]
225
- .filter(Boolean)
226
- .join(":"),
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
- return new Promise((resolveTurn) => {
231
- let finalText = "";
232
- let buf = "";
233
- child = spawn(python, [join(DIR, "gonext_agent_chat.py")], {
234
- env: { ...process.env, ...(extraEnv ?? {}) },
235
- });
236
- child.stdin.write(input);
237
- child.stdin.end();
238
- child.stdout.on("data", (chunk) => {
239
- buf += chunk.toString();
240
- let nl;
241
- while ((nl = buf.indexOf("\n")) >= 0) {
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
- child.stderr.on("data", (chunk) => {
263
- if (process.env.GONEXT_REPL_VERBOSE === "1") {
264
- process.stderr.write(dim(chunk.toString()));
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
- child.on("error", (err) => {
268
- console.error(red(`\ngonext: failed to start ${python}: ${err.message}`));
269
- child = null;
270
- resolveTurn("");
271
- });
272
- child.on("exit", () => {
273
- child = null;
274
- resolveTurn(finalText);
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 (child) {
303
- child.kill("SIGKILL");
304
- child = null;
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 { payload } = await fetchAgentPayload(history);
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 failed or was aborted)\n"));
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();
@@ -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 = _rag_workdir(key)
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 per-URL work dir — models pass bare names like
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 — the work dir
1186
- # is per-URL, so any archive already in it IS this URL's file.
1187
- try:
1188
- for fn in sorted(os.listdir(workdir)):
1189
- p = os.path.join(workdir, fn)
1190
- if os.path.isfile(p) and fn.lower().endswith(".zip") and os.path.getsize(p) > 0:
1191
- return p, True
1192
- except OSError:
1193
- pass
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", "*", os.path.basename(zp)))
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.175",
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",