@tiens.nguyen/gonext-local-worker 1.0.175 → 1.0.177

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.
Files changed (2) hide show
  1. package/gonext-repl.mjs +91 -109
  2. package/package.json +1 -1
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,86 @@ 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
+ body: JSON.stringify({ messages: history }),
203
+ });
204
+ const data = await res.json().catch(() => ({}));
205
+ if (!res.ok) {
206
+ if (res.status === 404) {
207
+ throw new Error(
208
+ "the API does not have POST /api/worker/agent-ask yet — deploy the latest API."
209
+ );
210
+ }
211
+ throw new Error(data?.error || `agent-ask failed (HTTP ${res.status})`);
228
212
  }
213
+ const jobId = data?.jobId;
214
+ if (!jobId) throw new Error("agent-ask returned no jobId.");
229
215
 
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
- }
216
+ following = true;
217
+ followAborted = false;
218
+ const startedAt = Date.now();
219
+ let shownChars = 0;
220
+ let warnedPending = false;
221
+ try {
222
+ for (;;) {
223
+ if (followAborted) {
224
+ process.stdout.write(
225
+ dim("\n(stopped following — the job keeps running on the worker)\n")
226
+ );
227
+ return "";
260
228
  }
261
- });
262
- child.stderr.on("data", (chunk) => {
263
- if (process.env.GONEXT_REPL_VERBOSE === "1") {
264
- process.stderr.write(dim(chunk.toString()));
229
+ const jr = await fetch(`${apiBase}/api/worker/jobs/${jobId}`, {
230
+ headers: { "X-Worker-Key": workerKey },
231
+ });
232
+ const job = await jr.json().catch(() => ({}));
233
+ if (!jr.ok) throw new Error(job?.error || `job poll failed (HTTP ${jr.status})`);
234
+ const text = typeof job.resultText === "string" ? job.resultText : "";
235
+ if (job.jobStatus === "completed") {
236
+ // Don't stream the final delta dim — the answer prints bright right after.
237
+ return answerFrom(text);
265
238
  }
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
- });
239
+ if (text.length > shownChars) {
240
+ process.stdout.write(dim(stripThinkTags(text.slice(shownChars))));
241
+ shownChars = text.length;
242
+ }
243
+ if (job.jobStatus === "failed" || job.jobStatus === "cancelled") {
244
+ throw new Error(job.errorMessage || `job ${job.jobStatus}`);
245
+ }
246
+ if (
247
+ job.jobStatus === "pending" &&
248
+ !warnedPending &&
249
+ Date.now() - startedAt > 6000
250
+ ) {
251
+ warnedPending = true;
252
+ process.stdout.write(
253
+ red("\n⚠ no worker has claimed the job — is `gonext-local-worker` running?\n")
254
+ );
255
+ }
256
+ await sleep(700);
257
+ }
258
+ } finally {
259
+ following = false;
260
+ }
277
261
  }
278
262
 
279
263
  // ---------- REPL ----------
@@ -299,10 +283,9 @@ async function main() {
299
283
 
300
284
  const history = [];
301
285
  rl.on("SIGINT", () => {
302
- if (child) {
303
- child.kill("SIGKILL");
304
- child = null;
305
- process.stdout.write(red("\n^C run aborted\n"));
286
+ if (following) {
287
+ followAborted = true;
288
+ process.stdout.write(red("\n^C "));
306
289
  } else {
307
290
  process.stdout.write(dim("\n(/exit to quit)\n") + cyan(">> "));
308
291
  }
@@ -334,14 +317,13 @@ async function main() {
334
317
  }
335
318
  history.push({ role: "user", content: line });
336
319
  try {
337
- const { payload } = await fetchAgentPayload(history);
338
- const answer = await runAgentTurn(payload);
320
+ const answer = await runAgentTurn(history);
339
321
  if (answer) {
340
322
  history.push({ role: "assistant", content: answer });
341
323
  console.log(`\n\n${answer}\n`);
342
324
  } 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"));
325
+ history.pop(); // aborted/failed turn — don't poison the next request's history
326
+ console.log(red("\n(no answer — run aborted or failed)\n"));
345
327
  }
346
328
  } catch (err) {
347
329
  history.pop();
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.177",
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",