@tiens.nguyen/gonext-local-worker 1.0.205 → 1.0.206

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.
@@ -2020,6 +2020,71 @@ function normalizeBaseUrl(raw) {
2020
2020
  return typeof raw === "string" ? raw.trim().replace(/\/+$/, "") : "";
2021
2021
  }
2022
2022
 
2023
+ /**
2024
+ * "Load Models" (task #44): probe the agent coding-model server's model list. Tries the
2025
+ * OpenAI-compatible /v1/models (MLX + Ollama both serve it) and falls back to Ollama's
2026
+ * /api/tags. The server is on the user's network, so only this worker can reach it. The
2027
+ * job's resultText is JSON {models:[...]} the web parses into checkboxes.
2028
+ */
2029
+ async function runListModelsJob(job) {
2030
+ const { jobId, payload } = job;
2031
+ const start = Date.now();
2032
+ const runRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
2033
+ method: "PATCH",
2034
+ body: JSON.stringify({ jobStatus: "running" }),
2035
+ });
2036
+ await ensureWorkerOk(runRes, `mark running list_models jobId=${jobId}`);
2037
+ try {
2038
+ const base = normalizeBaseUrl(payload?.url).replace(/\/v1$/i, "");
2039
+ if (!base) throw new Error("No coding-model URL provided.");
2040
+ const models = new Set();
2041
+ // 1) OpenAI-compatible /v1/models → { data: [{ id }] }
2042
+ try {
2043
+ const r = await fetch(`${base}/v1/models`, { method: "GET" });
2044
+ if (r.ok) {
2045
+ const j = await r.json().catch(() => ({}));
2046
+ for (const m of Array.isArray(j?.data) ? j.data : []) {
2047
+ if (typeof m?.id === "string" && m.id.trim()) models.add(m.id.trim());
2048
+ }
2049
+ }
2050
+ } catch {
2051
+ /* try the Ollama shape next */
2052
+ }
2053
+ // 2) Ollama /api/tags → { models: [{ name }] }
2054
+ if (models.size === 0) {
2055
+ const r = await fetch(`${base}/api/tags`, { method: "GET" });
2056
+ if (r.ok) {
2057
+ const j = await r.json().catch(() => ({}));
2058
+ for (const m of Array.isArray(j?.models) ? j.models : []) {
2059
+ if (typeof m?.name === "string" && m.name.trim()) models.add(m.name.trim());
2060
+ }
2061
+ }
2062
+ }
2063
+ const list = Array.from(models).sort();
2064
+ const doneRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
2065
+ method: "PATCH",
2066
+ body: JSON.stringify({
2067
+ jobStatus: "completed",
2068
+ resultText: JSON.stringify({ models: list }),
2069
+ totalTimeSeconds: (Date.now() - start) / 1000,
2070
+ }),
2071
+ });
2072
+ await ensureWorkerOk(doneRes, `complete list_models jobId=${jobId}`);
2073
+ console.log(`[gonext-worker] list_models ${jobId}: ${list.length} model(s) from ${base}`);
2074
+ } catch (e) {
2075
+ const message = e instanceof Error ? e.message : String(e);
2076
+ await workerFetch(`/api/worker/jobs/${jobId}`, {
2077
+ method: "PATCH",
2078
+ body: JSON.stringify({
2079
+ jobStatus: "failed",
2080
+ errorMessage: `Could not list models: ${message}`,
2081
+ totalTimeSeconds: (Date.now() - start) / 1000,
2082
+ }),
2083
+ }).catch(() => {});
2084
+ console.error(`[gonext-worker] failed list_models ${jobId}:`, message);
2085
+ }
2086
+ }
2087
+
2023
2088
  function normalizeOpenAiV1Root(raw) {
2024
2089
  const base = normalizeBaseUrl(raw);
2025
2090
  if (!base) return "";
@@ -2601,6 +2666,10 @@ async function pollOnce() {
2601
2666
  await runHttpProbeJob(job);
2602
2667
  return;
2603
2668
  }
2669
+ if (job.jobType === "list_models") {
2670
+ await runListModelsJob(job);
2671
+ return;
2672
+ }
2604
2673
  if (job.jobType === "agent_chat") {
2605
2674
  await runAgentChatJob(job);
2606
2675
  return;
package/gonext-repl.mjs CHANGED
@@ -21,7 +21,7 @@
21
21
  * interrupted investigation actually resumes it). /reset clears it.
22
22
  *
23
23
  * Requires the worker daemon to be running (it claims the job).
24
- * Commands: /exit /reset /revert [runId] /help Ctrl-C stops following the current run.
24
+ * Commands: /exit /model /reset /revert [runId] /help Ctrl-C stops following the current run.
25
25
  */
26
26
  import { readFile, mkdir, writeFile, unlink } from "node:fs/promises";
27
27
  import { existsSync, statSync, readFileSync } from "node:fs";
@@ -119,7 +119,7 @@ if (argv.includes("--help") || argv.includes("-h")) {
119
119
  " -v, --verbose show the agent's diagnostic logs ([gonext-agent] …) and python stderr\n" +
120
120
  " -h, --help this help\n\n" +
121
121
  "Auth comes from ~/.gonext/worker.env; models/budgets/RAG come from your web Settings.\n" +
122
- "Inside the REPL: /exit · /reset · /revert [runId] · /help — Ctrl-C stops following a turn.\n" +
122
+ "Inside the REPL: /exit · /model · /reset · /revert [runId] · /help — Ctrl-C stops following a turn.\n" +
123
123
  "Questions are enqueued like web questions and executed by the RUNNING\n" +
124
124
  "gonext-local-worker daemon — its terminal shows the full [gonext-agent] logs.\n" +
125
125
  "Conversation history is saved per-folder in ~/.gonext/sessions and resumed next time\n" +
@@ -193,6 +193,19 @@ rl.on("line", (l) => {
193
193
  _lineQueue.push(clean);
194
194
  }
195
195
  });
196
+ // Neutralize navigation keys (↑/↓ history recall, ←/→ cursor, Home/End, etc.) during a
197
+ // turn: echo is already muted, but readline still recalls history into the invisible
198
+ // buffer on ↑/↓ — reset the line state after every keypress so nothing accumulates or
199
+ // moves. Ctrl+C is unaffected (readline emits its SIGINT before this handler runs).
200
+ if (process.stdin.isTTY) {
201
+ process.stdin.on("keypress", () => {
202
+ if (following) {
203
+ rl.line = "";
204
+ rl.cursor = 0;
205
+ rl.historyIndex = -1;
206
+ }
207
+ });
208
+ }
196
209
  rl.on("close", () => {
197
210
  _stdinClosed = true;
198
211
  if (_lineWaiter) {
@@ -338,6 +351,53 @@ async function clearSession(cwd) {
338
351
  }
339
352
 
340
353
  // ---------- fetch the ready-to-run agent payload from user settings ----------
354
+ // /model command: fetch the allowed coding models (default + web-curated whitelist),
355
+ // show a numbered picker, and set sessionCodingModel to the choice for this session.
356
+ async function chooseModel() {
357
+ let probe;
358
+ try {
359
+ probe = await fetchAgentPayload([{ role: "user", content: "ping" }]);
360
+ } catch (err) {
361
+ console.log(red(` couldn't load models: ${err.message}\n`));
362
+ return;
363
+ }
364
+ const allowed = Array.isArray(probe?.codingAllowed) ? probe.codingAllowed : [];
365
+ const def = (probe?.codingDefault ?? "").trim();
366
+ if (allowed.length === 0) {
367
+ console.log(dim(" No coding model configured. Set one in the web app → Settings → Agent.\n"));
368
+ return;
369
+ }
370
+ if (allowed.length === 1) {
371
+ console.log(
372
+ dim(` Only one coding model is available: ${allowed[0]}.\n`) +
373
+ dim(" Add more in the web app → Settings → Agent → Load Models (checkboxes).\n")
374
+ );
375
+ return;
376
+ }
377
+ const active = sessionCodingModel || def;
378
+ console.log(dim(" Choose the coding model for this session:"));
379
+ allowed.forEach((m, i) => {
380
+ const mark = m === active ? green(" ●") : " ";
381
+ const tag = m === def ? dim(" (default)") : "";
382
+ console.log(` ${mark} ${i + 1}. ${m}${tag}`);
383
+ });
384
+ const pick = (await ask(dim(" number (or Enter to keep current): "))).trim();
385
+ if (!pick) {
386
+ console.log("");
387
+ return;
388
+ }
389
+ const idx = Number.parseInt(pick, 10) - 1;
390
+ if (!Number.isInteger(idx) || idx < 0 || idx >= allowed.length) {
391
+ console.log(red(" invalid choice.\n"));
392
+ return;
393
+ }
394
+ const chosen = allowed[idx];
395
+ // Empty override means "use the account default" — so don't send an override when the
396
+ // user picks the default (keeps the payload clean and lets a later default change win).
397
+ sessionCodingModel = chosen === def ? "" : chosen;
398
+ console.log(green(` ✓ coding model → ${chosen}${chosen === def ? " (default)" : ""}\n`));
399
+ }
400
+
341
401
  async function fetchAgentPayload(messages) {
342
402
  const res = await fetch(`${apiBase}/api/worker/agent-payload`, {
343
403
  method: "POST",
@@ -370,6 +430,9 @@ let following = false;
370
430
  let followAborted = false;
371
431
  let currentJobId = null; // the running turn's jobId — so Ctrl+C can cancel it server-side
372
432
  let cancelRequested = false; // a cancel POST has been sent for the current turn
433
+ // Per-session coding-model override chosen via /model (empty = use the account default).
434
+ // Sent to /agent-ask as codingModelOverride; the API validates it against the whitelist.
435
+ let sessionCodingModel = "";
373
436
 
374
437
  // While a turn runs, mute readline's own echo/redraws so keystrokes can't corrupt the
375
438
  // live status display and there's no way to type a new question — only Ctrl+C works.
@@ -381,6 +444,15 @@ rl._writeToOutput = (s) => {
381
444
  if (_origWriteToOutput) _origWriteToOutput(s);
382
445
  else process.stdout.write(s);
383
446
  };
447
+ // _writeToOutput only mutes echoed TEXT; readline's line refresh writes cursor-position
448
+ // + erase escapes (e.g. ESC[1G, ESC[0J) straight to output on every keypress, which
449
+ // would corrupt our live status display. No-op it during a turn so a keypress produces
450
+ // ZERO terminal output.
451
+ const _origRefreshLine = rl._refreshLine ? rl._refreshLine.bind(rl) : null;
452
+ rl._refreshLine = () => {
453
+ if (following) return;
454
+ if (_origRefreshLine) _origRefreshLine();
455
+ };
384
456
 
385
457
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
386
458
 
@@ -397,8 +469,12 @@ async function runAgentTurn(history) {
397
469
  method: "POST",
398
470
  headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
399
471
  // cwd = this terminal's folder → agent puts download/unzip output here (when it's
400
- // a registered workspace).
401
- body: JSON.stringify({ messages: history, cwd: resolve(process.cwd()) }),
472
+ // a registered workspace). codingModelOverride = the /model choice for this session.
473
+ body: JSON.stringify({
474
+ messages: history,
475
+ cwd: resolve(process.cwd()),
476
+ ...(sessionCodingModel ? { codingModelOverride: sessionCodingModel } : {}),
477
+ }),
402
478
  });
403
479
  const data = await res.json().catch(() => ({}));
404
480
  if (!res.ok) {
@@ -813,12 +889,17 @@ async function main() {
813
889
  if (line === "/help") {
814
890
  console.log(
815
891
  dim(
816
- " /exit — quit · /revert [runId] undo the agent's file edits (latest run) · " +
892
+ " /exit — quit · /modelswitch the coding model for this session · " +
893
+ "/revert [runId] — undo the agent's file edits (latest run) · " +
817
894
  "/reset — forget this folder's saved conversation and start fresh"
818
895
  )
819
896
  );
820
897
  continue;
821
898
  }
899
+ if (line === "/model") {
900
+ await chooseModel();
901
+ continue;
902
+ }
822
903
  if (line === "/reset" || line === "/new") {
823
904
  history.length = 0;
824
905
  await clearSession(cwd);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.205",
3
+ "version": "1.0.206",
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",