@tiens.nguyen/gonext-local-worker 1.0.337 → 1.0.346
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/daemon-control.mjs +228 -0
- package/device-login.mjs +141 -0
- package/gonext-local-worker.mjs +132 -0
- package/gonext-repl.mjs +165 -9
- package/model-doctor.mjs +571 -0
- package/package.json +6 -2
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Worker-daemon lifecycle for the gonext CLI (task #127, Phase 2). Lets a plain terminal
|
|
2
|
+
// keep the polling worker running WITHOUT the GoTerminal desktop app: `gonext-local-worker
|
|
3
|
+
// start|stop|status|logs`, and `gonext` (the REPL) auto-ensures it on startup.
|
|
4
|
+
//
|
|
5
|
+
// BC4 (task #127): NEVER double-spawn. A second polling daemon would race the first for
|
|
6
|
+
// jobs. We detect an already-running daemon two ways — our own pidfile AND a `ps` scan that
|
|
7
|
+
// also catches a daemon started by GoTerminal or by hand (which never wrote our pidfile) —
|
|
8
|
+
// and refuse to start when either says one is alive.
|
|
9
|
+
import { spawn, execFile } from "node:child_process";
|
|
10
|
+
import { readFile, writeFile, unlink, mkdir, open, stat } from "node:fs/promises";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
const DIR = join(homedir(), ".gonext");
|
|
16
|
+
export const PID_FILE = join(DIR, "worker.pid");
|
|
17
|
+
export const LOG_FILE = join(DIR, "worker.log");
|
|
18
|
+
const LOCK_FILE = join(DIR, "worker.start.lock");
|
|
19
|
+
const LOCK_STALE_MS = 30_000; // a lock older than this is from a crashed start → steal it
|
|
20
|
+
const DAEMON_SCRIPT = fileURLToPath(new URL("./gonext-local-worker.mjs", import.meta.url));
|
|
21
|
+
|
|
22
|
+
// Any cmdline containing one of these is a SUBCOMMAND invocation (login, start, …), not the
|
|
23
|
+
// polling daemon — so it must not be mistaken for a running worker in the ps scan.
|
|
24
|
+
const SUBCOMMANDS = [
|
|
25
|
+
"login", "set", "start", "stop", "status", "logs", "restart",
|
|
26
|
+
"ws-ping-test", "simulate-chat", "embed", "workspace",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function pidAlive(pid) {
|
|
30
|
+
if (!pid || !Number.isFinite(pid)) return false;
|
|
31
|
+
try {
|
|
32
|
+
process.kill(pid, 0);
|
|
33
|
+
return true;
|
|
34
|
+
} catch (e) {
|
|
35
|
+
// EPERM = the process exists but we can't signal it (still "alive" for our purposes).
|
|
36
|
+
return e && e.code === "EPERM";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function psList() {
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
execFile(
|
|
43
|
+
"ps",
|
|
44
|
+
["-A", "-o", "pid=,command="],
|
|
45
|
+
{ maxBuffer: 8 * 1024 * 1024 },
|
|
46
|
+
(err, stdout) => {
|
|
47
|
+
if (err) return resolve([]);
|
|
48
|
+
const rows = [];
|
|
49
|
+
for (const line of String(stdout).split("\n")) {
|
|
50
|
+
const m = line.trim().match(/^(\d+)\s+(.*)$/);
|
|
51
|
+
if (m) rows.push({ pid: Number(m[1]), cmd: m[2] });
|
|
52
|
+
}
|
|
53
|
+
resolve(rows);
|
|
54
|
+
}
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* PIDs of polling worker daemons currently running (excluding THIS process and any
|
|
61
|
+
* subcommand invocation). Catches a daemon started by GoTerminal / by hand, not just ours.
|
|
62
|
+
*/
|
|
63
|
+
export async function findRunningDaemonPids() {
|
|
64
|
+
const rows = await psList();
|
|
65
|
+
const self = process.pid;
|
|
66
|
+
return rows
|
|
67
|
+
.filter((r) => r.pid !== self)
|
|
68
|
+
.filter((r) => /gonext-local-worker\.mjs(\s|$)/.test(r.cmd))
|
|
69
|
+
.filter((r) => !SUBCOMMANDS.some((sc) => new RegExp(`(\\s|/)${sc}(\\s|$)`).test(r.cmd)))
|
|
70
|
+
.map((r) => r.pid);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* True when `pid` is actually a gonext polling daemon (not an unrelated process that reused
|
|
75
|
+
* the PID). Reads that one pid's command line — cheap and exact (review M1).
|
|
76
|
+
*/
|
|
77
|
+
function pidIsGonextDaemon(pid) {
|
|
78
|
+
if (!pid || !Number.isFinite(pid)) return Promise.resolve(false);
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
execFile("ps", ["-p", String(pid), "-o", "command="], (err, stdout) => {
|
|
81
|
+
const cmd = String(stdout || "").trim();
|
|
82
|
+
resolve(
|
|
83
|
+
!err &&
|
|
84
|
+
/gonext-local-worker\.mjs(\s|$)/.test(cmd) &&
|
|
85
|
+
!SUBCOMMANDS.some((sc) => new RegExp(`(\\s|/)${sc}(\\s|$)`).test(cmd))
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function readPidFile() {
|
|
92
|
+
try {
|
|
93
|
+
const n = Number(String(await readFile(PID_FILE, "utf8")).trim());
|
|
94
|
+
return Number.isFinite(n) ? n : null;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* { running, pids, pidFilePid }. The `ps` scan is the primary signal. The pidfile is trusted
|
|
102
|
+
* ONLY after VERIFYING that its PID is genuinely a gonext daemon (review M1) — this both
|
|
103
|
+
* rescues the case where the full-table scan missed it and rejects a stale pidfile whose PID
|
|
104
|
+
* was reused by an unrelated process (which would otherwise wrongly suppress auto-start).
|
|
105
|
+
*/
|
|
106
|
+
export async function daemonStatus() {
|
|
107
|
+
const [pids, pidFilePid] = await Promise.all([findRunningDaemonPids(), readPidFile()]);
|
|
108
|
+
const pidFileIsDaemon =
|
|
109
|
+
pidFilePid != null && (pids.includes(pidFilePid) || (await pidIsGonextDaemon(pidFilePid)));
|
|
110
|
+
const allPids = pidFileIsDaemon && !pids.includes(pidFilePid) ? [...pids, pidFilePid] : pids;
|
|
111
|
+
return {
|
|
112
|
+
running: allPids.length > 0,
|
|
113
|
+
pids: allPids,
|
|
114
|
+
pidFilePid: pidFileIsDaemon ? pidFilePid : null,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Acquire an exclusive single-flight lock over the check→spawn window (review L2). O_EXCL
|
|
122
|
+
* (`wx`) is atomic, so of two concurrent starts exactly one wins. A lock left by a crashed
|
|
123
|
+
* start is stolen once it's older than LOCK_STALE_MS so it can never wedge startup.
|
|
124
|
+
* Returns a handle whose .release() removes the lock, or null if another start holds it.
|
|
125
|
+
*/
|
|
126
|
+
async function acquireStartLock() {
|
|
127
|
+
await mkdir(DIR, { recursive: true });
|
|
128
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
129
|
+
try {
|
|
130
|
+
const fh = await open(LOCK_FILE, "wx");
|
|
131
|
+
await fh.writeFile(String(process.pid), "utf8");
|
|
132
|
+
return {
|
|
133
|
+
release: async () => {
|
|
134
|
+
await fh.close().catch(() => {});
|
|
135
|
+
await unlink(LOCK_FILE).catch(() => {});
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
} catch (e) {
|
|
139
|
+
if (!e || e.code !== "EEXIST") throw e;
|
|
140
|
+
// Held by someone else — steal it only if it's stale (a crashed start), then retry.
|
|
141
|
+
let age = 0;
|
|
142
|
+
try {
|
|
143
|
+
age = Date.now() - (await stat(LOCK_FILE)).mtimeMs;
|
|
144
|
+
} catch {
|
|
145
|
+
age = LOCK_STALE_MS + 1; // vanished between EEXIST and stat → treat as free
|
|
146
|
+
}
|
|
147
|
+
if (age > LOCK_STALE_MS) {
|
|
148
|
+
await unlink(LOCK_FILE).catch(() => {});
|
|
149
|
+
continue; // retry the atomic create
|
|
150
|
+
}
|
|
151
|
+
return null; // a fresh lock → another start is genuinely in progress
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Start the polling daemon DETACHED (survives the launching shell, logs to ~/.gonext/
|
|
159
|
+
* worker.log). No-op when one is already running (BC4). Concurrency-safe (L2): a lock
|
|
160
|
+
* serializes the check→spawn window so two simultaneous starts can't both spawn.
|
|
161
|
+
* Returns { started, pid?, pids }.
|
|
162
|
+
*/
|
|
163
|
+
export async function startDaemon() {
|
|
164
|
+
// Fast path: already running → no lock needed.
|
|
165
|
+
const pre = await daemonStatus();
|
|
166
|
+
if (pre.running) return { started: false, ...pre };
|
|
167
|
+
const lock = await acquireStartLock();
|
|
168
|
+
if (!lock) {
|
|
169
|
+
// A concurrent start holds the lock — let it win, then report the resulting state.
|
|
170
|
+
await sleep(700);
|
|
171
|
+
const s = await daemonStatus();
|
|
172
|
+
return { started: false, ...s };
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
// Re-check UNDER the lock — the winner of a race must see the loser's daemon if it
|
|
176
|
+
// spawned in the gap (and vice-versa this is where the second caller no-ops).
|
|
177
|
+
const status = await daemonStatus();
|
|
178
|
+
if (status.running) return { started: false, ...status };
|
|
179
|
+
const out = await open(LOG_FILE, "a");
|
|
180
|
+
try {
|
|
181
|
+
const child = spawn(process.execPath, [DAEMON_SCRIPT], {
|
|
182
|
+
detached: true,
|
|
183
|
+
stdio: ["ignore", out.fd, out.fd],
|
|
184
|
+
env: process.env,
|
|
185
|
+
});
|
|
186
|
+
await writeFile(PID_FILE, String(child.pid ?? ""), "utf8");
|
|
187
|
+
child.unref();
|
|
188
|
+
return { started: true, pid: child.pid, pids: [] };
|
|
189
|
+
} finally {
|
|
190
|
+
await out.close();
|
|
191
|
+
}
|
|
192
|
+
} finally {
|
|
193
|
+
await lock.release();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Stop every running daemon (ours + any ps-found) and clear the pidfile. */
|
|
198
|
+
export async function stopDaemon() {
|
|
199
|
+
const status = await daemonStatus();
|
|
200
|
+
const targets = new Set(status.pids);
|
|
201
|
+
if (status.pidFilePid) targets.add(status.pidFilePid);
|
|
202
|
+
let killed = 0;
|
|
203
|
+
for (const pid of targets) {
|
|
204
|
+
try {
|
|
205
|
+
process.kill(pid, "SIGTERM");
|
|
206
|
+
killed++;
|
|
207
|
+
} catch {
|
|
208
|
+
/* already gone */
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
await unlink(PID_FILE);
|
|
213
|
+
} catch {
|
|
214
|
+
/* no pidfile */
|
|
215
|
+
}
|
|
216
|
+
return { killed };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Last `n` lines of the daemon log (empty string when none yet). */
|
|
220
|
+
export async function tailLog(n = 40) {
|
|
221
|
+
try {
|
|
222
|
+
const txt = await readFile(LOG_FILE, "utf8");
|
|
223
|
+
const lines = txt.split("\n");
|
|
224
|
+
return lines.slice(Math.max(0, lines.length - n)).join("\n");
|
|
225
|
+
} catch {
|
|
226
|
+
return "";
|
|
227
|
+
}
|
|
228
|
+
}
|
package/device-login.mjs
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Device-pairing login for the gonext CLI (task #127, Phase 1). Shared by both bins
|
|
2
|
+
// (gonext-local-worker.mjs and gonext-repl.mjs) so `gonext login` and the first-run
|
|
3
|
+
// auto-prompt use the exact same flow. No secrets live here — the CLI obtains a worker key
|
|
4
|
+
// only after an authenticated human approves in the browser.
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { mkdir, writeFile, readFile } from "node:fs/promises";
|
|
7
|
+
import { homedir, platform } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import dotenv from "dotenv";
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_API_BASE = "https://chat-api-v2.gomarsic.cc";
|
|
12
|
+
const ENV_FILE = join(homedir(), ".gonext", "worker.env");
|
|
13
|
+
const POLL_INTERVAL_MS = 2500;
|
|
14
|
+
const POLL_TIMEOUT_MS = 10 * 60_000; // matches the server's pairing TTL
|
|
15
|
+
|
|
16
|
+
/** True when worker.env already has BOTH a key and an API base (task #127 BC1a). */
|
|
17
|
+
export async function hasWorkerConfig() {
|
|
18
|
+
try {
|
|
19
|
+
const parsed = dotenv.parse(await readFile(ENV_FILE, "utf8"));
|
|
20
|
+
return Boolean(
|
|
21
|
+
String(parsed.GONEXT_WORKER_KEY ?? "").trim() &&
|
|
22
|
+
String(parsed.GONEXT_API_BASE ?? "").trim()
|
|
23
|
+
);
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Write ~/.gonext/worker.env, preserving any existing GONEXT_POLL_MS. */
|
|
30
|
+
async function writeWorkerEnv(apiBase, workerKey) {
|
|
31
|
+
let pollMs = "500";
|
|
32
|
+
try {
|
|
33
|
+
const prev = dotenv.parse(await readFile(ENV_FILE, "utf8"));
|
|
34
|
+
if (String(prev.GONEXT_POLL_MS ?? "").trim()) pollMs = String(prev.GONEXT_POLL_MS).trim();
|
|
35
|
+
} catch {
|
|
36
|
+
/* first-time write */
|
|
37
|
+
}
|
|
38
|
+
await mkdir(join(homedir(), ".gonext"), { recursive: true });
|
|
39
|
+
await writeFile(
|
|
40
|
+
ENV_FILE,
|
|
41
|
+
`GONEXT_API_BASE=${apiBase.replace(/\/+$/, "")}\nGONEXT_WORKER_KEY=${workerKey}\nGONEXT_POLL_MS=${pollMs}\n`,
|
|
42
|
+
{ encoding: "utf8", mode: 0o600 }
|
|
43
|
+
);
|
|
44
|
+
return ENV_FILE;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function openBrowser(url) {
|
|
48
|
+
const cmd =
|
|
49
|
+
platform() === "darwin" ? "open" : platform() === "win32" ? "start" : "xdg-open";
|
|
50
|
+
try {
|
|
51
|
+
execFile(cmd, [url], () => {}); // best-effort; the URL is also printed
|
|
52
|
+
} catch {
|
|
53
|
+
/* the user can open it manually */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Run the browser device-pairing flow and write worker.env on success.
|
|
61
|
+
*
|
|
62
|
+
* Returns { apiBase, workerKey, envFile } on success.
|
|
63
|
+
* Throws Error with .code === "PAIRING_UNSUPPORTED" when the API has no /pair routes
|
|
64
|
+
* (404), so callers can fall back to the manual `set` path (task #127 BC7).
|
|
65
|
+
* Throws Error with .code === "PAIRING_TIMEOUT" / "PAIRING_EXPIRED" otherwise.
|
|
66
|
+
*
|
|
67
|
+
* `log` is an injectable printer (defaults to console.log) so the REPL can style it.
|
|
68
|
+
*/
|
|
69
|
+
export async function deviceLogin({ apiBase = DEFAULT_API_BASE, log = console.log } = {}) {
|
|
70
|
+
const base = String(apiBase || DEFAULT_API_BASE).trim().replace(/\/+$/, "");
|
|
71
|
+
let start;
|
|
72
|
+
try {
|
|
73
|
+
const res = await fetch(`${base}/api/pair/start`, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: { "Content-Type": "application/json" },
|
|
76
|
+
body: JSON.stringify({ apiBase: base }),
|
|
77
|
+
});
|
|
78
|
+
if (res.status === 404) {
|
|
79
|
+
const err = new Error("This API does not support CLI login (device pairing).");
|
|
80
|
+
err.code = "PAIRING_UNSUPPORTED";
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
if (!res.ok) {
|
|
84
|
+
throw new Error(`pairing start failed (HTTP ${res.status})`);
|
|
85
|
+
}
|
|
86
|
+
start = await res.json();
|
|
87
|
+
} catch (e) {
|
|
88
|
+
if (e && e.code === "PAIRING_UNSUPPORTED") throw e;
|
|
89
|
+
// A network failure to a base that has no /pair yet also can't pair — surface as
|
|
90
|
+
// unsupported so the caller offers manual setup rather than looping.
|
|
91
|
+
const err = new Error(
|
|
92
|
+
`Could not reach ${base} to start login: ${e instanceof Error ? e.message : e}`
|
|
93
|
+
);
|
|
94
|
+
err.code = "PAIRING_UNSUPPORTED";
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const { pairId, pairCode, verificationUrlBase } = start;
|
|
99
|
+
const url = `${verificationUrlBase || `${base}/link`}?code=${encodeURIComponent(pairCode)}`;
|
|
100
|
+
log("");
|
|
101
|
+
log(" To connect this machine, sign in and approve this code in your browser:");
|
|
102
|
+
log("");
|
|
103
|
+
log(` code: ${pairCode}`);
|
|
104
|
+
log(` url: ${url}`);
|
|
105
|
+
log("");
|
|
106
|
+
log(" (opening your browser… if it doesn't, paste the url above)");
|
|
107
|
+
log("");
|
|
108
|
+
openBrowser(url);
|
|
109
|
+
|
|
110
|
+
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
|
111
|
+
while (Date.now() < deadline) {
|
|
112
|
+
await sleep(POLL_INTERVAL_MS);
|
|
113
|
+
let poll;
|
|
114
|
+
try {
|
|
115
|
+
const res = await fetch(`${base}/api/pair/poll`, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: { "Content-Type": "application/json" },
|
|
118
|
+
body: JSON.stringify({ pairId }),
|
|
119
|
+
});
|
|
120
|
+
if (!res.ok) continue; // transient — keep polling
|
|
121
|
+
poll = await res.json();
|
|
122
|
+
} catch {
|
|
123
|
+
continue; // network blip — keep polling
|
|
124
|
+
}
|
|
125
|
+
if (poll.status === "approved" && poll.workerApiKey) {
|
|
126
|
+
const finalBase = String(poll.apiBase || base).trim().replace(/\/+$/, "");
|
|
127
|
+
const envFile = await writeWorkerEnv(finalBase, poll.workerApiKey);
|
|
128
|
+
log(` ✓ Linked. Saved ${envFile}`);
|
|
129
|
+
return { apiBase: finalBase, workerKey: poll.workerApiKey, envFile };
|
|
130
|
+
}
|
|
131
|
+
if (poll.status === "expired") {
|
|
132
|
+
const err = new Error("Login code expired before it was approved. Run `gonext login` again.");
|
|
133
|
+
err.code = "PAIRING_EXPIRED";
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
// "pending" / "consumed" → keep waiting (consumed by a racing poll is unlikely).
|
|
137
|
+
}
|
|
138
|
+
const err = new Error("Login timed out waiting for browser approval.");
|
|
139
|
+
err.code = "PAIRING_TIMEOUT";
|
|
140
|
+
throw err;
|
|
141
|
+
}
|
package/gonext-local-worker.mjs
CHANGED
|
@@ -61,7 +61,11 @@ gonext-local-worker
|
|
|
61
61
|
|
|
62
62
|
Usage:
|
|
63
63
|
gonext-local-worker
|
|
64
|
+
gonext-local-worker login [--api-base <url>] # browser sign-in → writes ~/.gonext/worker.env
|
|
64
65
|
gonext-local-worker set <workerKey> [--api-base <url>]
|
|
66
|
+
gonext-local-worker start | stop | status | restart # run the polling daemon in the background
|
|
67
|
+
gonext-local-worker logs [--lines N] # tail the background daemon log
|
|
68
|
+
gonext-local-worker doctor # download + start the base chat model if needed
|
|
65
69
|
gonext-local-worker ws-ping-test # POST /api/worker/ws-ping-test (needs GONEXT_* env)
|
|
66
70
|
gonext-local-worker simulate-chat [text...] # claim next chat job, fake reply (stop normal worker first)
|
|
67
71
|
|
|
@@ -127,6 +131,108 @@ if (args[0] === "set") {
|
|
|
127
131
|
await setConfig();
|
|
128
132
|
process.exit(0);
|
|
129
133
|
}
|
|
134
|
+
if (args[0] === "login") {
|
|
135
|
+
// Task #127, Phase 1: browser device-pairing → writes worker.env. `--api-base` overrides
|
|
136
|
+
// which API to pair against (defaults to the production API).
|
|
137
|
+
const { deviceLogin, DEFAULT_API_BASE } = await import("./device-login.mjs");
|
|
138
|
+
const apiBaseFlag = parseFlag("--api-base");
|
|
139
|
+
try {
|
|
140
|
+
await deviceLogin({
|
|
141
|
+
apiBase:
|
|
142
|
+
apiBaseFlag ||
|
|
143
|
+
(process.env.GONEXT_API_BASE ?? "").trim() ||
|
|
144
|
+
DEFAULT_API_BASE,
|
|
145
|
+
});
|
|
146
|
+
process.exit(0);
|
|
147
|
+
} catch (e) {
|
|
148
|
+
console.error(`[gonext-worker] login failed: ${e instanceof Error ? e.message : e}`);
|
|
149
|
+
if (e && e.code === "PAIRING_UNSUPPORTED") {
|
|
150
|
+
console.error(
|
|
151
|
+
"Set it manually instead: gonext-local-worker set <workerKey> --api-base <url>"
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// Task #127, Phase 2: daemon lifecycle from a plain terminal (no GoTerminal). start runs
|
|
158
|
+
// the polling worker DETACHED; stop/status/logs manage it. All no-op-safe (BC4: never
|
|
159
|
+
// double-spawn a second poller).
|
|
160
|
+
if (["start", "stop", "status", "logs", "restart"].includes(args[0])) {
|
|
161
|
+
const ctl = await import("./daemon-control.mjs");
|
|
162
|
+
if (args[0] === "status") {
|
|
163
|
+
const s = await ctl.daemonStatus();
|
|
164
|
+
if (s.running) {
|
|
165
|
+
const who = s.pidFilePid ? `pid ${s.pidFilePid}` : `pid ${s.pids.join(", ")}`;
|
|
166
|
+
console.log(`[gonext-worker] daemon RUNNING (${who}). Logs: ${ctl.LOG_FILE}`);
|
|
167
|
+
} else {
|
|
168
|
+
console.log("[gonext-worker] daemon is NOT running. Start it with: gonext-local-worker start");
|
|
169
|
+
}
|
|
170
|
+
process.exit(0);
|
|
171
|
+
}
|
|
172
|
+
if (args[0] === "logs") {
|
|
173
|
+
const n = Number(parseFlag("--lines")) || 60;
|
|
174
|
+
const txt = await ctl.tailLog(n);
|
|
175
|
+
console.log(txt || `(no daemon log yet at ${ctl.LOG_FILE})`);
|
|
176
|
+
process.exit(0);
|
|
177
|
+
}
|
|
178
|
+
if (args[0] === "stop") {
|
|
179
|
+
const { killed } = await ctl.stopDaemon();
|
|
180
|
+
console.log(killed ? `[gonext-worker] stopped ${killed} daemon process(es).` : "[gonext-worker] no daemon was running.");
|
|
181
|
+
process.exit(0);
|
|
182
|
+
}
|
|
183
|
+
// start / restart both ensure exactly one daemon is running, config permitting.
|
|
184
|
+
if (args[0] === "restart") await ctl.stopDaemon();
|
|
185
|
+
// Don't spawn a daemon that would just exit(1): require a complete worker.env first.
|
|
186
|
+
const { hasWorkerConfig } = await import("./device-login.mjs");
|
|
187
|
+
if (!(await hasWorkerConfig())) {
|
|
188
|
+
console.error(
|
|
189
|
+
"[gonext-worker] not configured — run `gonext-local-worker login` (or `set`) first."
|
|
190
|
+
);
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
const r = await ctl.startDaemon();
|
|
194
|
+
if (r.started) {
|
|
195
|
+
console.log(`[gonext-worker] daemon started (pid ${r.pid}). Logs: ${ctl.LOG_FILE}`);
|
|
196
|
+
} else {
|
|
197
|
+
console.log("[gonext-worker] daemon already running — nothing to do.");
|
|
198
|
+
}
|
|
199
|
+
process.exit(0);
|
|
200
|
+
}
|
|
201
|
+
// Task #127, Phase 3: ensure the base chat model is downloaded + running (no GoTerminal).
|
|
202
|
+
if (args[0] === "doctor") {
|
|
203
|
+
const { hasWorkerConfig } = await import("./device-login.mjs");
|
|
204
|
+
if (!(await hasWorkerConfig())) {
|
|
205
|
+
console.error("[gonext-worker] not configured — run `gonext-local-worker login` first.");
|
|
206
|
+
process.exit(1);
|
|
207
|
+
}
|
|
208
|
+
const { runDoctor } = await import("./model-doctor.mjs");
|
|
209
|
+
const rl0 = (await import("node:readline")).createInterface({
|
|
210
|
+
input: process.stdin,
|
|
211
|
+
output: process.stdout,
|
|
212
|
+
});
|
|
213
|
+
const interactive = process.stdin.isTTY && process.stdout.isTTY;
|
|
214
|
+
const confirm = (q, defYes) =>
|
|
215
|
+
// Non-interactive: never download GBs / start a server unattended — probe + advise only.
|
|
216
|
+
interactive
|
|
217
|
+
? new Promise((res) =>
|
|
218
|
+
rl0.question(` ${q} [${defYes ? "Y/n" : "y/N"}] `, (a) => {
|
|
219
|
+
const s = a.trim().toLowerCase();
|
|
220
|
+
res(s ? s === "y" || s === "yes" : Boolean(defYes));
|
|
221
|
+
})
|
|
222
|
+
)
|
|
223
|
+
: Promise.resolve(false);
|
|
224
|
+
try {
|
|
225
|
+
await runDoctor({
|
|
226
|
+
apiBase: String(process.env.GONEXT_API_BASE ?? "").trim(),
|
|
227
|
+
workerKey: String(process.env.GONEXT_WORKER_KEY ?? "").trim(),
|
|
228
|
+
log: (s) => console.log(s),
|
|
229
|
+
confirm,
|
|
230
|
+
});
|
|
231
|
+
} finally {
|
|
232
|
+
rl0.close();
|
|
233
|
+
}
|
|
234
|
+
process.exit(0);
|
|
235
|
+
}
|
|
130
236
|
|
|
131
237
|
const apiBase = String(process.env.GONEXT_API_BASE ?? "")
|
|
132
238
|
.trim()
|
|
@@ -3068,6 +3174,32 @@ async function pollOnce() {
|
|
|
3068
3174
|
}
|
|
3069
3175
|
|
|
3070
3176
|
async function main() {
|
|
3177
|
+
// Task #127, Phase 2: record our own pidfile so `status`/`stop` find us whether we were
|
|
3178
|
+
// launched by `start` (detached), by the REPL's auto-ensure, or run in the foreground.
|
|
3179
|
+
// Best-effort; a failure here must not stop the worker from polling.
|
|
3180
|
+
try {
|
|
3181
|
+
const { PID_FILE } = await import("./daemon-control.mjs");
|
|
3182
|
+
const { unlinkSync } = await import("node:fs");
|
|
3183
|
+
await writeFile(PID_FILE, String(process.pid), "utf8");
|
|
3184
|
+
const cleanup = () => {
|
|
3185
|
+
try {
|
|
3186
|
+
unlinkSync(PID_FILE);
|
|
3187
|
+
} catch {
|
|
3188
|
+
/* already gone */
|
|
3189
|
+
}
|
|
3190
|
+
};
|
|
3191
|
+
process.on("SIGTERM", () => {
|
|
3192
|
+
cleanup();
|
|
3193
|
+
process.exit(0);
|
|
3194
|
+
});
|
|
3195
|
+
process.on("SIGINT", () => {
|
|
3196
|
+
cleanup();
|
|
3197
|
+
process.exit(0);
|
|
3198
|
+
});
|
|
3199
|
+
process.on("exit", cleanup);
|
|
3200
|
+
} catch {
|
|
3201
|
+
/* pidfile is a convenience; polling works without it */
|
|
3202
|
+
}
|
|
3071
3203
|
console.log(
|
|
3072
3204
|
`[gonext-worker] polling ${apiBase} every ${pollMs}ms (local_health concurrency=${localHealthConcurrency})`
|
|
3073
3205
|
);
|
package/gonext-repl.mjs
CHANGED
|
@@ -170,15 +170,42 @@ if (argv.includes("-v") || argv.includes("--verbose")) {
|
|
|
170
170
|
// ---------- auth: worker key + API base from ~/.gonext/worker.env ----------
|
|
171
171
|
dotenv.config({ path: ENV_FILE });
|
|
172
172
|
dotenv.config();
|
|
173
|
-
|
|
174
|
-
|
|
173
|
+
let workerKey = String(process.env.GONEXT_WORKER_KEY ?? "").trim();
|
|
174
|
+
let apiBase = String(process.env.GONEXT_API_BASE ?? "").trim().replace(/\/+$/, "");
|
|
175
|
+
// Task #127 (BC1a): "configured" = BOTH key AND api-base present. When either is missing,
|
|
176
|
+
// first-run login instead of a dead end — but ONLY interactively; a piped/CI invocation
|
|
177
|
+
// still fails fast with guidance (so scripts don't hang on a browser prompt). An EXISTING
|
|
178
|
+
// user with a full worker.env skips all of this and behaves exactly as before (BC1).
|
|
175
179
|
if (!workerKey || !apiBase) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
180
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
181
|
+
try {
|
|
182
|
+
const { deviceLogin, DEFAULT_API_BASE } = await import("./device-login.mjs");
|
|
183
|
+
console.log(
|
|
184
|
+
dim("gonext: first-time setup — let's connect this machine to your account.")
|
|
185
|
+
);
|
|
186
|
+
const r = await deviceLogin({
|
|
187
|
+
apiBase: apiBase || DEFAULT_API_BASE,
|
|
188
|
+
log: (s) => console.log(s),
|
|
189
|
+
});
|
|
190
|
+
workerKey = r.workerKey;
|
|
191
|
+
apiBase = r.apiBase;
|
|
192
|
+
} catch (e) {
|
|
193
|
+
console.error(red(`gonext: login failed: ${e instanceof Error ? e.message : e}`));
|
|
194
|
+
if (e && e.code === "PAIRING_UNSUPPORTED") {
|
|
195
|
+
console.error(
|
|
196
|
+
"Configure manually: gonext-local-worker set <workerKey> --api-base https://chat-api-v2.gomarsic.cc"
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
process.exit(1);
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
console.error(
|
|
203
|
+
red("gonext: no worker key / API base found.") +
|
|
204
|
+
`\nRun ${cyan("gonext-local-worker login")} to sign in, or configure manually:` +
|
|
205
|
+
"\n gonext-local-worker set <workerKey> --api-base https://chat-api-v2.gomarsic.cc"
|
|
206
|
+
);
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
182
209
|
}
|
|
183
210
|
|
|
184
211
|
// Line intake: attach the listener IMMEDIATELY so lines that arrive while startup
|
|
@@ -350,6 +377,9 @@ function slashRestoreHistory() {
|
|
|
350
377
|
// which the earlier relative-cursor overlay hit whenever readline refreshed or the
|
|
351
378
|
// menu was near the bottom of the screen and scrolled).
|
|
352
379
|
let slashOverlayActive = false;
|
|
380
|
+
// While true, readline's echo + line-refresh are muted so a typed secret (an API key) never
|
|
381
|
+
// reaches the terminal or scrollback (task #127 — see askSecret + the _writeToOutput guards).
|
|
382
|
+
let secretInput = false;
|
|
353
383
|
|
|
354
384
|
// Visible width of PROMPT (">> ") — used to put the caret back on the input line by
|
|
355
385
|
// COLUMN, since the string itself carries color escapes that don't occupy cells.
|
|
@@ -581,6 +611,20 @@ const ask = async (q) => {
|
|
|
581
611
|
// re-uses the default. This replaces a fragile `(await ask()) || "y"` idiom where any
|
|
582
612
|
// leftover character (a stray escape byte, a stray space) silently defeated the
|
|
583
613
|
// default — the reported "[Y/n] + Enter registered as No" bug.
|
|
614
|
+
// Masked prompt for a secret (an API key). Mutes readline's echo/refresh (secretInput)
|
|
615
|
+
// so the typed value never hits the terminal or scrollback (task #127). Returns "" on a
|
|
616
|
+
// bare Enter (skip).
|
|
617
|
+
const askSecret = async (q) => {
|
|
618
|
+
process.stdout.write(q);
|
|
619
|
+
secretInput = true;
|
|
620
|
+
try {
|
|
621
|
+
const a = await nextLine();
|
|
622
|
+
return (a ?? "").trim();
|
|
623
|
+
} finally {
|
|
624
|
+
secretInput = false;
|
|
625
|
+
process.stdout.write("\n"); // the muted Enter left the cursor mid-line
|
|
626
|
+
}
|
|
627
|
+
};
|
|
584
628
|
const askYesNo = async (question, defaultYes) => {
|
|
585
629
|
const suffix = defaultYes ? " [Y/n] " : " [y/N] ";
|
|
586
630
|
const raw = (await ask(question + suffix)).trim().toLowerCase();
|
|
@@ -741,6 +785,22 @@ async function addOutputTokens(cwd, delta) {
|
|
|
741
785
|
}
|
|
742
786
|
}
|
|
743
787
|
|
|
788
|
+
// Save the agent coding-model API key (task #127): the terminal prompts for the Kimi K3 key
|
|
789
|
+
// when the default cloud coder has none, and POSTs it here (worker-key authed). Returns true
|
|
790
|
+
// on success. Best-effort — a failure just leaves the reminder for next startup.
|
|
791
|
+
async function saveCodingKey(apiKey) {
|
|
792
|
+
try {
|
|
793
|
+
const r = await fetch(`${apiBase}/api/worker/coding-key`, {
|
|
794
|
+
method: "POST",
|
|
795
|
+
headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
|
|
796
|
+
body: JSON.stringify({ apiKey }),
|
|
797
|
+
});
|
|
798
|
+
return r.ok;
|
|
799
|
+
} catch {
|
|
800
|
+
return false;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
744
804
|
async function resetGlobalOutputTokens() {
|
|
745
805
|
try {
|
|
746
806
|
const r = await fetch(`${apiBase}/api/worker/output-tokens/reset-global`, {
|
|
@@ -1053,6 +1113,17 @@ async function fetchAgentPayload(messages) {
|
|
|
1053
1113
|
"the API does not have POST /api/worker/agent-payload yet — deploy the latest API (sam build && sam deploy)."
|
|
1054
1114
|
);
|
|
1055
1115
|
}
|
|
1116
|
+
// Task #127 (BC1b): a present-but-INVALID worker key (rotated/revoked while worker.env
|
|
1117
|
+
// kept the stale one) shows up as 401/403. Point the user at re-login rather than a
|
|
1118
|
+
// generic error — we don't proactively re-validate the key, so the first rejection is
|
|
1119
|
+
// where this surfaces.
|
|
1120
|
+
if (res.status === 401 || res.status === 403) {
|
|
1121
|
+
throw new Error(
|
|
1122
|
+
"your worker key was rejected (it may have been rotated or revoked). " +
|
|
1123
|
+
"Run `gonext-local-worker login` to sign in again, or update it with " +
|
|
1124
|
+
"`gonext-local-worker set <workerKey> --api-base <url>`."
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1056
1127
|
throw new Error(data?.error || `agent-payload failed (HTTP ${res.status})`);
|
|
1057
1128
|
}
|
|
1058
1129
|
return data;
|
|
@@ -1357,6 +1428,9 @@ rl._writeToOutput = (s) => {
|
|
|
1357
1428
|
// slashOverlayActive: the slash-command overlay owns drawing (input line + menu), so
|
|
1358
1429
|
// readline's own echo must be swallowed or it fights the overlay (bug #109).
|
|
1359
1430
|
if (following || listPickerActive || slashOverlayActive) return;
|
|
1431
|
+
// secretInput (task #127): the user is typing an API key — swallow the echo so it never
|
|
1432
|
+
// appears in the terminal or scrollback.
|
|
1433
|
+
if (secretInput) return;
|
|
1360
1434
|
if (_origWriteToOutput) _origWriteToOutput(s);
|
|
1361
1435
|
else process.stdout.write(s);
|
|
1362
1436
|
};
|
|
@@ -1369,6 +1443,7 @@ rl._refreshLine = () => {
|
|
|
1369
1443
|
// Suppress readline's line refresh (which does clearScreenDown → would wipe our menu)
|
|
1370
1444
|
// while the slash overlay owns the screen. We redraw the input line ourselves (#109).
|
|
1371
1445
|
if (following || listPickerActive || slashOverlayActive) return;
|
|
1446
|
+
if (secretInput) return; // don't redraw the typed key (task #127)
|
|
1372
1447
|
if (_origRefreshLine) _origRefreshLine();
|
|
1373
1448
|
};
|
|
1374
1449
|
|
|
@@ -2198,6 +2273,19 @@ async function runAgentTurn(history) {
|
|
|
2198
2273
|
// ---------- REPL ----------
|
|
2199
2274
|
async function main() {
|
|
2200
2275
|
console.log(cyan("GoNext agent REPL") + dim(` v${REPL_VERSION} · API ${apiBase} · key ${workerKey.slice(0, 8)}…`));
|
|
2276
|
+
// Task #127, Phase 2: the REPL enqueues jobs that the polling DAEMON executes, so ensure
|
|
2277
|
+
// one is running — no GoTerminal needed. Idempotent + BC4-safe: startDaemon() no-ops when
|
|
2278
|
+
// a daemon (ours, GoTerminal's, or a manual one) is already alive. Best-effort; a failure
|
|
2279
|
+
// here shouldn't block the REPL (the user can `gonext-local-worker start` manually).
|
|
2280
|
+
try {
|
|
2281
|
+
const ctl = await import("./daemon-control.mjs");
|
|
2282
|
+
const r = await ctl.startDaemon();
|
|
2283
|
+
if (r.started) {
|
|
2284
|
+
console.log(dim(` started background worker (pid ${r.pid}) · logs ${ctl.LOG_FILE}`));
|
|
2285
|
+
}
|
|
2286
|
+
} catch {
|
|
2287
|
+
/* non-fatal — jobs just won't run until a daemon is started */
|
|
2288
|
+
}
|
|
2201
2289
|
await ensureWorkspace();
|
|
2202
2290
|
// Restore this folder's remembered auto-test + deploy-target so the banner + agent-ask
|
|
2203
2291
|
// reflect them (both survive a fresh terminal in the same project).
|
|
@@ -2239,8 +2327,76 @@ async function main() {
|
|
|
2239
2327
|
(selectedServer ? ` · deploy: ${selectedServer.name} (${selectedServer.user}@${selectedServer.host})` : "")
|
|
2240
2328
|
) + (sessionTestAuto ? "" : dim(" (/test-auto to enable)"))
|
|
2241
2329
|
);
|
|
2330
|
+
// Task #127, Phase 3: if the base chat model server isn't answering, offer to set it up
|
|
2331
|
+
// (download + start mlx_lm.server). BC3: only runs when NOTHING answers — an already-up
|
|
2332
|
+
// model is left untouched, and this is skipped entirely. Interactive TTYs only.
|
|
2333
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
2334
|
+
try {
|
|
2335
|
+
const md = await import("./model-doctor.mjs");
|
|
2336
|
+
const chatUrl = String(p.agentBaseURL || "").trim() || `http://127.0.0.1:${md.DEFAULT_CHAT_PORT}`;
|
|
2337
|
+
if (!(await md.probeModelServer(chatUrl))) {
|
|
2338
|
+
console.log(
|
|
2339
|
+
dim(`\ngonext: the chat model server (${md.modelRoot(chatUrl)}) isn't responding.`)
|
|
2340
|
+
);
|
|
2341
|
+
const go = await askYesNo("Set up the base model now (download if needed + start)?", true);
|
|
2342
|
+
if (go) {
|
|
2343
|
+
await md.runDoctor({
|
|
2344
|
+
apiBase,
|
|
2345
|
+
workerKey,
|
|
2346
|
+
log: (s) => console.log(s),
|
|
2347
|
+
confirm: (q, defYes) => askYesNo(q, defYes),
|
|
2348
|
+
});
|
|
2349
|
+
} else {
|
|
2350
|
+
console.log(dim(" Skipped. Run `gonext-local-worker doctor` anytime to set it up.\n"));
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
} catch {
|
|
2354
|
+
/* doctor is best-effort — never block the REPL from starting */
|
|
2355
|
+
}
|
|
2356
|
+
// Task #127: the default coding model is a cloud coder (Kimi K3) with no API key yet.
|
|
2357
|
+
// Ask for it. If skipped, the account stays in this "waiting" state (server-side:
|
|
2358
|
+
// coder configured, no key) and we ASK AGAIN on every startup until it's set. Until
|
|
2359
|
+
// then the agent falls back to the chat model for coding, so the terminal still works.
|
|
2360
|
+
if (p.codingKeyMissing) {
|
|
2361
|
+
const model = String(p.codingKeyModel || "the coding model").trim();
|
|
2362
|
+
console.log(
|
|
2363
|
+
dim(`\ngonext: your agent coding model (`) + cyan(model) + dim(`) needs an API key.`)
|
|
2364
|
+
);
|
|
2365
|
+
console.log(
|
|
2366
|
+
dim(" Until it's set, coding falls back to your local model. Enter the key now,")
|
|
2367
|
+
);
|
|
2368
|
+
console.log(dim(" or press Enter to skip (I'll ask again next time).\n"));
|
|
2369
|
+
const key = await askSecret(` ${model} API key: `);
|
|
2370
|
+
if (key) {
|
|
2371
|
+
const ok = await saveCodingKey(key);
|
|
2372
|
+
console.log(
|
|
2373
|
+
ok
|
|
2374
|
+
? green(` ✓ saved — ${model} is now your coding model.\n`)
|
|
2375
|
+
: red(" couldn't save the key — try again next start, or set it in web Settings → Agent.\n")
|
|
2376
|
+
);
|
|
2377
|
+
} else {
|
|
2378
|
+
console.log(
|
|
2379
|
+
dim(" ⏳ waiting for your ") + cyan(model) + dim(" API key — I'll ask again next time.\n")
|
|
2380
|
+
);
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2242
2384
|
} catch (err) {
|
|
2243
|
-
|
|
2385
|
+
// Task #127 (zero-config): a freshly-paired account is seeded with a default local
|
|
2386
|
+
// agent model, so this "no model" path is normally unreachable. An account paired
|
|
2387
|
+
// BEFORE that change has none — point them at re-login, which now seeds it, rather than
|
|
2388
|
+
// only the raw web-Settings instruction.
|
|
2389
|
+
if (/no agent model configured/i.test(err?.message ?? "")) {
|
|
2390
|
+
console.error(
|
|
2391
|
+
red("gonext: no agent model is configured for your account yet.") +
|
|
2392
|
+
dim(
|
|
2393
|
+
"\n Run `gonext-local-worker login` to re-connect — new logins set up a local" +
|
|
2394
|
+
"\n default automatically. Or set the Agent model URL in the web app → Settings → Agent."
|
|
2395
|
+
)
|
|
2396
|
+
);
|
|
2397
|
+
} else {
|
|
2398
|
+
console.error(red(`gonext: ${err.message}`));
|
|
2399
|
+
}
|
|
2244
2400
|
process.exit(1);
|
|
2245
2401
|
}
|
|
2246
2402
|
console.log(dim("Ask about this repo. Type / to see commands (Tab completes). Ctrl-C aborts a running turn.\n"));
|
package/model-doctor.mjs
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
// Model bootstrap for the gonext CLI (task #127, Phase 3). Ensures the base CHAT model
|
|
2
|
+
// (mlx_lm.server) is downloaded and RUNNING so a fresh Mac can answer without GoTerminal or
|
|
3
|
+
// the Wizard doing it. The agent CODE model (Ollama) is probed + guided, not auto-pulled.
|
|
4
|
+
//
|
|
5
|
+
// BC3 (task #127): NEVER touch a model that is already running. The doctor probes first and
|
|
6
|
+
// does nothing when a server answers — it will not kill, restart, re-port, or re-download a
|
|
7
|
+
// model the user already runs (many run mlx_lm.server themselves on 8090, or point at a
|
|
8
|
+
// remote box). Downloads happen only when the model dir is genuinely absent AND the user
|
|
9
|
+
// opts in.
|
|
10
|
+
import { spawn, execFile } from "node:child_process";
|
|
11
|
+
import { open, mkdir, readdir, writeFile } from "node:fs/promises";
|
|
12
|
+
import { homedir, totalmem } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
// Minimum RAM to run the default 14B-4bit chat model comfortably on Apple Silicon.
|
|
17
|
+
const MIN_CHAT_RAM_GB = 16;
|
|
18
|
+
const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
19
|
+
|
|
20
|
+
// The RAG embedding model + server (task #127 — parity with the Wizard's model-embed step).
|
|
21
|
+
// EMBED_PID_FILE / EMBED_LOG_FILE (which need DIR) are defined below, after DIR.
|
|
22
|
+
export const EMBED_REPO = "mlx-community/Qwen3-Embedding-8B-4bit-DWQ";
|
|
23
|
+
export const EMBED_DIR = join(homedir(), "mlx-models", "Qwen3-Embedding-8B-4bit-DWQ");
|
|
24
|
+
export const EMBED_PORT = 8085;
|
|
25
|
+
const EMBED_SCRIPT = fileURLToPath(new URL("./gonext_mlx_embed.py", import.meta.url));
|
|
26
|
+
|
|
27
|
+
/** The python3 the worker uses to run its agent / model tooling (mirrors the daemon). */
|
|
28
|
+
function python3Bin() {
|
|
29
|
+
return (
|
|
30
|
+
(process.env.GONEXT_PROBE_PYTHON ?? process.env.GONEXT_MLX_LM_PYTHON ?? "").trim() ||
|
|
31
|
+
"python3"
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** True when `python3 -c "import <mod>"` succeeds — i.e. the module is installed. */
|
|
36
|
+
function pyHasModule(mod) {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
execFile(python3Bin(), ["-c", `import ${mod}`], (err) => resolve(!err));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** pip install -U <pkgs> with live progress (inherit stdio). Rejects on non-zero exit. */
|
|
43
|
+
function pipInstall(pkgs, log) {
|
|
44
|
+
log(` installing ${pkgs.join(" ")} … (pip, this can take a minute)`);
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const child = spawn(python3Bin(), ["-m", "pip", "install", "-U", ...pkgs], {
|
|
47
|
+
stdio: "inherit",
|
|
48
|
+
env: process.env,
|
|
49
|
+
});
|
|
50
|
+
child.on("exit", (code) =>
|
|
51
|
+
code === 0 ? resolve() : reject(new Error(`pip install ${pkgs.join(" ")} exited ${code}`))
|
|
52
|
+
);
|
|
53
|
+
child.on("error", reject);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The yellow RAM notice shown before the multi-GB model download (user request): the
|
|
59
|
+
* default 14B-4bit model wants a Mac with ≥16 GB. When the machine has less, add a plain
|
|
60
|
+
* line stating its detected amount. Pure (RAM passed in) so it's unit-testable.
|
|
61
|
+
* Returns already-colored lines; empty array only if `repo` is falsy.
|
|
62
|
+
*/
|
|
63
|
+
export function chatRamWarningLines(repo, totalBytes) {
|
|
64
|
+
if (!repo) return [];
|
|
65
|
+
const ramGb = Math.round((Number(totalBytes) || 0) / 1024 ** 3);
|
|
66
|
+
const name = String(repo).split("/").pop();
|
|
67
|
+
const out = [
|
|
68
|
+
yellow(` ⚠ ${name} needs a Mac with at least ${MIN_CHAT_RAM_GB} GB of RAM to run well.`),
|
|
69
|
+
];
|
|
70
|
+
if (ramGb > 0 && ramGb < MIN_CHAT_RAM_GB) {
|
|
71
|
+
out.push(
|
|
72
|
+
yellow(
|
|
73
|
+
` This machine has about ${ramGb} GB — the model may run very slowly or fail to load.`
|
|
74
|
+
)
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const DIR = join(homedir(), ".gonext");
|
|
81
|
+
export const MODEL_PID_FILE = join(DIR, "model.pid");
|
|
82
|
+
export const MODEL_LOG_FILE = join(DIR, "model.log");
|
|
83
|
+
const EMBED_PID_FILE = join(DIR, "embed.pid");
|
|
84
|
+
const EMBED_LOG_FILE = join(DIR, "embed.log");
|
|
85
|
+
|
|
86
|
+
// The default base chat model (task #127 decision): matches the Wizard's model-chat step.
|
|
87
|
+
export const DEFAULT_CHAT_REPO = "mlx-community/Qwen3-14B-4bit";
|
|
88
|
+
export const DEFAULT_CHAT_DIR = join(homedir(), "mlx-models", "Qwen3-14B-4bit");
|
|
89
|
+
export const DEFAULT_CHAT_PORT = 8082;
|
|
90
|
+
|
|
91
|
+
// ---- pure helpers (unit-tested offline) -------------------------------------------------
|
|
92
|
+
|
|
93
|
+
/** The port from a base URL, or null. "http://127.0.0.1:8082/v1" → 8082. */
|
|
94
|
+
export function parsePort(baseUrl) {
|
|
95
|
+
try {
|
|
96
|
+
const u = new URL(String(baseUrl || "").includes("://") ? baseUrl : `http://${baseUrl}`);
|
|
97
|
+
if (u.port) return Number(u.port);
|
|
98
|
+
return u.protocol === "https:" ? 443 : 80;
|
|
99
|
+
} catch {
|
|
100
|
+
const m = String(baseUrl || "").match(/:(\d{2,5})(?:\/|$)/);
|
|
101
|
+
return m ? Number(m[1]) : null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** True when the URL points at this machine (loopback/LAN) — only then can we START it. */
|
|
106
|
+
export function isLocalModelUrl(baseUrl) {
|
|
107
|
+
let host = "";
|
|
108
|
+
try {
|
|
109
|
+
host = new URL(String(baseUrl || "").includes("://") ? baseUrl : `http://${baseUrl}`)
|
|
110
|
+
.hostname.toLowerCase()
|
|
111
|
+
.replace(/\.$/, "");
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
|
|
116
|
+
if (!host) return false;
|
|
117
|
+
if (["localhost", "0.0.0.0", "::", "::1"].includes(host)) return true;
|
|
118
|
+
if (/^127(\.\d{1,3}){3}$/.test(host)) return true;
|
|
119
|
+
if (
|
|
120
|
+
/^(10(\.\d{1,3}){3}|192\.168(\.\d{1,3}){2}|172\.(1[6-9]|2\d|3[01])(\.\d{1,3}){2}|169\.254(\.\d{1,3}){2})$/.test(
|
|
121
|
+
host
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
return true;
|
|
125
|
+
if (host.includes(":") && (host.startsWith("fe80:") || /^f[cd]/.test(host))) return true;
|
|
126
|
+
if (/\.(local|localdomain|lan|home|internal)$/.test(host)) return true;
|
|
127
|
+
return !host.includes(".") && !host.includes(":");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Normalize a base URL to its root (strip a trailing /v1 and slashes). */
|
|
131
|
+
export function modelRoot(baseUrl) {
|
|
132
|
+
return String(baseUrl || "").replace(/\/+$/, "").replace(/\/v1$/i, "");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---- probing + process control ----------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
138
|
+
|
|
139
|
+
/** True when an OpenAI-compatible model server answers GET {base}/v1/models. */
|
|
140
|
+
export async function probeModelServer(baseUrl, { timeoutMs = 3000 } = {}) {
|
|
141
|
+
const root = modelRoot(baseUrl);
|
|
142
|
+
if (!root) return false;
|
|
143
|
+
try {
|
|
144
|
+
const res = await fetch(`${root}/v1/models`, {
|
|
145
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
146
|
+
});
|
|
147
|
+
return res.ok;
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function which(cmd) {
|
|
154
|
+
return new Promise((resolve) => {
|
|
155
|
+
execFile("command", ["-v", cmd], { shell: "/bin/sh" }, (err, stdout) =>
|
|
156
|
+
resolve(!err && Boolean(String(stdout).trim()))
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** True when the model directory exists and is non-empty. */
|
|
162
|
+
export async function modelDirReady(dir) {
|
|
163
|
+
try {
|
|
164
|
+
const entries = await readdir(dir);
|
|
165
|
+
return entries.length > 0;
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Start mlx_lm.server for `modelDir` on `port`, DETACHED (logs to model.log, pidfile). Waits
|
|
173
|
+
* until the server answers /v1/models (up to waitMs). No-op + { started:false } when a
|
|
174
|
+
* server already answers on that port (BC3). Returns { started, ready, pid?, reason? }.
|
|
175
|
+
*/
|
|
176
|
+
export async function startChatModelServer({ modelDir, port, waitMs = 120_000, log = () => {} }) {
|
|
177
|
+
const base = `http://127.0.0.1:${port}`;
|
|
178
|
+
if (await probeModelServer(base, { timeoutMs: 1500 })) {
|
|
179
|
+
return { started: false, ready: true, reason: "already-running" };
|
|
180
|
+
}
|
|
181
|
+
if (!(await which("mlx_lm.server"))) {
|
|
182
|
+
return {
|
|
183
|
+
started: false,
|
|
184
|
+
ready: false,
|
|
185
|
+
reason: "mlx_lm.server not found — install it: pip install mlx-lm",
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
await mkdir(DIR, { recursive: true });
|
|
189
|
+
const out = await open(MODEL_LOG_FILE, "a");
|
|
190
|
+
let pid;
|
|
191
|
+
try {
|
|
192
|
+
const child = spawn("mlx_lm.server", ["--model", modelDir, "--port", String(port)], {
|
|
193
|
+
detached: true,
|
|
194
|
+
stdio: ["ignore", out.fd, out.fd],
|
|
195
|
+
env: process.env,
|
|
196
|
+
});
|
|
197
|
+
pid = child.pid;
|
|
198
|
+
const { writeFile } = await import("node:fs/promises");
|
|
199
|
+
await writeFile(MODEL_PID_FILE, String(pid ?? ""), "utf8");
|
|
200
|
+
child.unref();
|
|
201
|
+
} finally {
|
|
202
|
+
await out.close();
|
|
203
|
+
}
|
|
204
|
+
log(` starting mlx_lm.server (pid ${pid}) on port ${port} — waiting for it to load…`);
|
|
205
|
+
const deadline = Date.now() + waitMs;
|
|
206
|
+
while (Date.now() < deadline) {
|
|
207
|
+
await sleep(2500);
|
|
208
|
+
if (await probeModelServer(base, { timeoutMs: 2000 })) {
|
|
209
|
+
return { started: true, ready: true, pid };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
started: true,
|
|
214
|
+
ready: false,
|
|
215
|
+
pid,
|
|
216
|
+
reason: `model server did not answer within ${Math.round(waitMs / 1000)}s (still loading? see ${MODEL_LOG_FILE})`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Download an MLX model repo to a local dir via the Hugging Face CLI. Streams progress to
|
|
222
|
+
* the terminal (inherit stdio) because it's a multi-GB, several-minute step. Resolves
|
|
223
|
+
* { ok } / rejects with guidance when `hf` is missing.
|
|
224
|
+
*/
|
|
225
|
+
export async function downloadModel({ repo, dir, log = () => {} }) {
|
|
226
|
+
if (!(await which("hf"))) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
"the Hugging Face CLI (hf) is required to download the model. Install it with: pip install huggingface_hub"
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
log(` downloading ${repo} → ${dir} (multi-GB, keep this open)…`);
|
|
232
|
+
return new Promise((resolve, reject) => {
|
|
233
|
+
const child = spawn("hf", ["download", repo, "--local-dir", dir], {
|
|
234
|
+
stdio: "inherit",
|
|
235
|
+
env: process.env,
|
|
236
|
+
});
|
|
237
|
+
child.on("exit", (code) =>
|
|
238
|
+
code === 0 ? resolve({ ok: true }) : reject(new Error(`hf download exited ${code}`))
|
|
239
|
+
);
|
|
240
|
+
child.on("error", (e) => reject(e));
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Fetch the account's resolved model config from /agent-payload (best-effort). */
|
|
245
|
+
async function fetchModelConfig(apiBase, workerKey) {
|
|
246
|
+
try {
|
|
247
|
+
const res = await fetch(`${apiBase.replace(/\/+$/, "")}/api/worker/agent-payload`, {
|
|
248
|
+
method: "POST",
|
|
249
|
+
headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
|
|
250
|
+
body: JSON.stringify({ messages: [{ role: "user", content: "ping" }] }),
|
|
251
|
+
});
|
|
252
|
+
if (!res.ok) return null;
|
|
253
|
+
const data = await res.json();
|
|
254
|
+
return data?.payload ?? null;
|
|
255
|
+
} catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The Phase-3 orchestration (task #127). Ensures the base CHAT model is running (starting a
|
|
262
|
+
* local mlx_lm.server, downloading first if the dir is missing and the user opts in), and
|
|
263
|
+
* probes the CODE model, giving guidance for a remote one. BC3-safe throughout: an
|
|
264
|
+
* already-answering server is left untouched.
|
|
265
|
+
*
|
|
266
|
+
* `log(str)` prints; `confirm(question, defaultYes)→bool` gates the multi-GB download and
|
|
267
|
+
* the model-server start (non-interactive callers pass a confirm that returns false, so the
|
|
268
|
+
* doctor only PROBES + advises and never downloads/starts unattended).
|
|
269
|
+
*/
|
|
270
|
+
export async function runDoctor({ apiBase, workerKey, log, confirm }) {
|
|
271
|
+
const p = (await fetchModelConfig(apiBase, workerKey)) ?? {};
|
|
272
|
+
|
|
273
|
+
// Step 0: the agent's PYTHON deps must be present or every agent turn crashes at import
|
|
274
|
+
// — and the model download/start needs `hf`/`mlx-lm`. Ensure these FIRST (task #127:
|
|
275
|
+
// parity with the Wizard's pip-mlx-lm / pip-agent-libs / hf-cli steps). Fatal-ish: if the
|
|
276
|
+
// core framework is missing, say so, but keep going so the model steps still run.
|
|
277
|
+
const deps = await ensureAgentDeps({ log, confirm });
|
|
278
|
+
|
|
279
|
+
// Chat model: prefer the account's configured URL; fall back to the default local one.
|
|
280
|
+
const configuredChat = String(p.agentBaseURL || "").trim();
|
|
281
|
+
const chatUrl = configuredChat || `http://127.0.0.1:${DEFAULT_CHAT_PORT}`;
|
|
282
|
+
const chatPort = parsePort(chatUrl) || DEFAULT_CHAT_PORT;
|
|
283
|
+
// Review H2: never serve a model the account didn't configure. Only the KNOWN default is
|
|
284
|
+
// auto-downloadable; for a specifically-named other model we start its dir if present but
|
|
285
|
+
// won't substitute Qwen3-14B. "default_model" is the direct-URL sentinel (the loaded
|
|
286
|
+
// model), which we treat as the default. modelId empty (fresh account) → default too.
|
|
287
|
+
const modelId = String(p.agentModelId || "").trim();
|
|
288
|
+
const isDefaultModel =
|
|
289
|
+
!modelId || modelId === "default_model" || /qwen3-?14b/i.test(modelId);
|
|
290
|
+
const chatDir = isDefaultModel
|
|
291
|
+
? DEFAULT_CHAT_DIR
|
|
292
|
+
: join(homedir(), "mlx-models", modelId.replace(/[^A-Za-z0-9._-]/g, "-"));
|
|
293
|
+
const chatRepo = isDefaultModel ? DEFAULT_CHAT_REPO : null; // null = not auto-downloadable
|
|
294
|
+
|
|
295
|
+
// The chat handling sets `chat` but never returns early — the code-model check below is
|
|
296
|
+
// independent and must run even when the user declines the chat-model setup.
|
|
297
|
+
let chat = "checked";
|
|
298
|
+
log("");
|
|
299
|
+
log(" Checking the base chat model…");
|
|
300
|
+
if (await probeModelServer(chatUrl)) {
|
|
301
|
+
log(` ✓ chat model already running at ${modelRoot(chatUrl)} — leaving it alone.`);
|
|
302
|
+
chat = "already-running";
|
|
303
|
+
} else if (configuredChat && !isLocalModelUrl(chatUrl)) {
|
|
304
|
+
// Configured but REMOTE and not answering — we can't start a model on another machine.
|
|
305
|
+
log(` ⚠ your chat model server (${modelRoot(chatUrl)}) isn't answering.`);
|
|
306
|
+
log(" It's remote, so start it there — this machine can't launch it.");
|
|
307
|
+
chat = "remote";
|
|
308
|
+
} else {
|
|
309
|
+
// Local (or unconfigured → default local): ensure the RIGHT model, then offer to start.
|
|
310
|
+
const dir = chatDir;
|
|
311
|
+
let dirReady = await modelDirReady(dir);
|
|
312
|
+
if (!dirReady && !chatRepo) {
|
|
313
|
+
// A specifically-named, non-default model we don't know how to fetch — DON'T
|
|
314
|
+
// substitute the default. Tell the user how to bring their own (review H2).
|
|
315
|
+
log(` ⚠ your account uses the chat model "${modelId}", which isn't the built-in`);
|
|
316
|
+
log(` default, so I can't auto-download it. Put it at ${dir} (or any dir) and run:`);
|
|
317
|
+
log(` mlx_lm.server --model <that dir> --port ${chatPort}`);
|
|
318
|
+
chat = "unknown-model";
|
|
319
|
+
} else if (!dirReady) {
|
|
320
|
+
// Yellow RAM notice before the multi-GB download, THEN the confirm (user request).
|
|
321
|
+
for (const line of chatRamWarningLines(chatRepo, totalmem())) log(line);
|
|
322
|
+
const yes = await confirm(
|
|
323
|
+
`Download the base chat model ${chatRepo} (~8 GB) to ${dir}?`,
|
|
324
|
+
true
|
|
325
|
+
);
|
|
326
|
+
if (!yes) {
|
|
327
|
+
log(" Skipped. To do it yourself:");
|
|
328
|
+
log(` hf download ${chatRepo} --local-dir ${dir}`);
|
|
329
|
+
log(` mlx_lm.server --model ${dir} --port ${chatPort}`);
|
|
330
|
+
chat = "skipped";
|
|
331
|
+
} else {
|
|
332
|
+
try {
|
|
333
|
+
await downloadModel({ repo: chatRepo, dir, log });
|
|
334
|
+
dirReady = true;
|
|
335
|
+
} catch (e) {
|
|
336
|
+
log(` ⚠ ${e instanceof Error ? e.message : e}`);
|
|
337
|
+
chat = "download-failed";
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (dirReady && chat === "checked") {
|
|
342
|
+
const yes = await confirm(`Start the chat model server on port ${chatPort} now?`, true);
|
|
343
|
+
if (!yes) {
|
|
344
|
+
log(` Skipped. Start it later: mlx_lm.server --model ${dir} --port ${chatPort}`);
|
|
345
|
+
chat = "not-started";
|
|
346
|
+
} else {
|
|
347
|
+
// Review M2: don't block the caller (the REPL) for a full cold model load — that's
|
|
348
|
+
// minutes. Wait only long enough to confirm the server launched and didn't crash
|
|
349
|
+
// (~20s); if it's still loading, say so and return. The first agent turn will wait
|
|
350
|
+
// for it. A missing binary / immediate exit still surfaces as an error.
|
|
351
|
+
const r = await startChatModelServer({
|
|
352
|
+
modelDir: dir,
|
|
353
|
+
port: chatPort,
|
|
354
|
+
waitMs: 20_000,
|
|
355
|
+
log,
|
|
356
|
+
});
|
|
357
|
+
if (r.ready) {
|
|
358
|
+
log(` ✓ chat model is up on port ${chatPort}.`);
|
|
359
|
+
chat = "started";
|
|
360
|
+
} else if (r.started) {
|
|
361
|
+
log(` … chat model is loading in the background on port ${chatPort} (large model —`);
|
|
362
|
+
log(` the first answer may take a minute). Watch it: gonext-local-worker logs`);
|
|
363
|
+
chat = "loading";
|
|
364
|
+
} else {
|
|
365
|
+
log(` ⚠ ${r.reason}`);
|
|
366
|
+
chat = "start-failed";
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Code model (Ollama): probe + guide only — never auto-pull (BC3 + bounded scope).
|
|
373
|
+
const codeUrl = String(p.codingBaseURL || "").trim();
|
|
374
|
+
const codeModel = String(p.codingModelId || "").trim();
|
|
375
|
+
if (codeUrl) {
|
|
376
|
+
log("");
|
|
377
|
+
log(" Checking the agent code model…");
|
|
378
|
+
if (await probeModelServer(codeUrl)) {
|
|
379
|
+
log(` ✓ code model server is up at ${modelRoot(codeUrl)}.`);
|
|
380
|
+
} else if (isLocalModelUrl(codeUrl)) {
|
|
381
|
+
log(` ⚠ local code model server (${modelRoot(codeUrl)}) isn't answering.`);
|
|
382
|
+
if (codeModel) log(` Start Ollama and pull it: ollama serve & ollama pull ${codeModel}`);
|
|
383
|
+
} else {
|
|
384
|
+
log(` ⚠ your code model runs on a remote server (${modelRoot(codeUrl)}) that isn't`);
|
|
385
|
+
log(` answering. Start it there${codeModel ? ` (ollama pull ${codeModel})` : ""}.`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// RAG (task #127: the user wants the terminal RAG-ready). Ensure the embedding model +
|
|
390
|
+
// server so rag_index / rag_search work. Uses the account's configured embed URL when
|
|
391
|
+
// local, else the default local one on EMBED_PORT.
|
|
392
|
+
const rag = await ensureRag({ p, log, confirm });
|
|
393
|
+
|
|
394
|
+
return { chat, deps, rag };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Ensure the agent's required runtime deps (task #127 — parity with the Wizard's pip steps).
|
|
399
|
+
* python3 can't be auto-installed (guidance only); hf / mlx-lm / smolagents+openai are
|
|
400
|
+
* offered via pip. Each install is confirmed. Returns a status map; missing deps are
|
|
401
|
+
* reported, never silently ignored — the agent hard-fails without smolagents+openai.
|
|
402
|
+
*/
|
|
403
|
+
export async function ensureAgentDeps({ log, confirm }) {
|
|
404
|
+
const out = {};
|
|
405
|
+
log("");
|
|
406
|
+
log(" Checking the agent's dependencies…");
|
|
407
|
+
// python3 — the whole agent runs on it; we can't install it for the user.
|
|
408
|
+
if (!(await pyHasModule("sys"))) {
|
|
409
|
+
log(` ⚠ ${python3Bin()} not found. Install Python 3 (Xcode Command Line Tools or`);
|
|
410
|
+
log(" python.org), then run `gonext-local-worker doctor` again.");
|
|
411
|
+
out.python3 = "missing";
|
|
412
|
+
return out; // nothing else can be installed without python
|
|
413
|
+
}
|
|
414
|
+
out.python3 = "ok";
|
|
415
|
+
|
|
416
|
+
const ensurePkg = async (key, probeMod, pkgs, question) => {
|
|
417
|
+
if (await probeMod()) {
|
|
418
|
+
out[key] = "present";
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (!(await confirm(question, true))) {
|
|
422
|
+
out[key] = "skipped";
|
|
423
|
+
log(` Skipped ${pkgs.join(" ")} — install later: ${python3Bin()} -m pip install -U ${pkgs.join(" ")}`);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
try {
|
|
427
|
+
await pipInstall(pkgs, log);
|
|
428
|
+
out[key] = "installed";
|
|
429
|
+
} catch (e) {
|
|
430
|
+
log(` ⚠ ${e instanceof Error ? e.message : e}`);
|
|
431
|
+
out[key] = "failed";
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
// hf CLI (model downloads). `huggingface_hub` provides the `hf` command.
|
|
436
|
+
await ensurePkg(
|
|
437
|
+
"hf",
|
|
438
|
+
() => which("hf"),
|
|
439
|
+
["huggingface_hub"],
|
|
440
|
+
"Install the Hugging Face CLI (needed to download models)?"
|
|
441
|
+
);
|
|
442
|
+
// mlx-lm — runs the local chat + embedding model servers.
|
|
443
|
+
await ensurePkg("mlxLm", () => pyHasModule("mlx_lm"), ["mlx-lm"], "Install mlx-lm (runs the local model servers)?");
|
|
444
|
+
// The agent framework itself — WITHOUT this every agent turn crashes at import.
|
|
445
|
+
await ensurePkg(
|
|
446
|
+
"agentLibs",
|
|
447
|
+
async () => (await pyHasModule("smolagents")) && (await pyHasModule("openai")),
|
|
448
|
+
["smolagents", "openai"],
|
|
449
|
+
"Install the agent framework (smolagents + openai) — required to run the agent?"
|
|
450
|
+
);
|
|
451
|
+
if (out.agentLibs === "skipped" || out.agentLibs === "failed") {
|
|
452
|
+
log(yellow(" ⚠ Without smolagents + openai the agent can't run — install them before asking a question."));
|
|
453
|
+
}
|
|
454
|
+
return out;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Start the MLX embeddings server (gonext_mlx_embed.py) DETACHED on `port` for `modelDir`,
|
|
459
|
+
* with its own pidfile/log. No-op + { ready:true } when one already answers (BC3). Waits up
|
|
460
|
+
* to waitMs for /v1/models to respond. Mirrors startChatModelServer.
|
|
461
|
+
*/
|
|
462
|
+
export async function startEmbedServer({ modelDir, port, waitMs = 60_000, log = () => {} }) {
|
|
463
|
+
const base = `http://127.0.0.1:${port}`;
|
|
464
|
+
if (await probeModelServer(base, { timeoutMs: 1500 })) {
|
|
465
|
+
return { started: false, ready: true, reason: "already-running" };
|
|
466
|
+
}
|
|
467
|
+
if (!(await pyHasModule("mlx_lm"))) {
|
|
468
|
+
return { started: false, ready: false, reason: "mlx-lm not installed (pip install mlx-lm)" };
|
|
469
|
+
}
|
|
470
|
+
await mkdir(DIR, { recursive: true });
|
|
471
|
+
const outLog = await open(EMBED_LOG_FILE, "a");
|
|
472
|
+
let pid;
|
|
473
|
+
try {
|
|
474
|
+
const child = spawn(
|
|
475
|
+
python3Bin(),
|
|
476
|
+
[EMBED_SCRIPT, "--model", modelDir, "--port", String(port), "--host", "127.0.0.1"],
|
|
477
|
+
{ detached: true, stdio: ["ignore", outLog.fd, outLog.fd], env: process.env }
|
|
478
|
+
);
|
|
479
|
+
pid = child.pid;
|
|
480
|
+
await writeFile(EMBED_PID_FILE, String(pid ?? ""), "utf8");
|
|
481
|
+
child.unref();
|
|
482
|
+
} finally {
|
|
483
|
+
await outLog.close();
|
|
484
|
+
}
|
|
485
|
+
log(` starting embeddings server (pid ${pid}) on port ${port} — waiting for it to load…`);
|
|
486
|
+
const deadline = Date.now() + waitMs;
|
|
487
|
+
while (Date.now() < deadline) {
|
|
488
|
+
await sleep(2500);
|
|
489
|
+
if (await probeModelServer(base, { timeoutMs: 2000 })) return { started: true, ready: true, pid };
|
|
490
|
+
}
|
|
491
|
+
return { started: true, ready: false, pid, reason: `embeddings server still loading (see ${EMBED_LOG_FILE})` };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Ensure RAG is ready for the agent terminal (task #127): boto3 (cloud/S3 storage), the
|
|
496
|
+
* embedding model, and a running embeddings server. Uses the account's configured embed URL
|
|
497
|
+
* when it's local; otherwise the default local server on EMBED_PORT. BC3: an already-running
|
|
498
|
+
* embed server is left alone. Returns a status map.
|
|
499
|
+
*/
|
|
500
|
+
export async function ensureRag({ p, log, confirm }) {
|
|
501
|
+
const out = {};
|
|
502
|
+
log("");
|
|
503
|
+
log(" Checking RAG (agent knowledge base)…");
|
|
504
|
+
// boto3 — needed only for CLOUD (S3) RAG; local RAG stores on disk. Cheap + harmless, so
|
|
505
|
+
// offer it so cloud RAG works later without another setup pass.
|
|
506
|
+
if (!(await pyHasModule("boto3"))) {
|
|
507
|
+
if (await confirm("Install boto3 (needed only if you use cloud/S3 RAG)?", true)) {
|
|
508
|
+
try {
|
|
509
|
+
await pipInstall(["boto3"], log);
|
|
510
|
+
out.boto3 = "installed";
|
|
511
|
+
} catch (e) {
|
|
512
|
+
log(` ⚠ ${e instanceof Error ? e.message : e}`);
|
|
513
|
+
out.boto3 = "failed";
|
|
514
|
+
}
|
|
515
|
+
} else out.boto3 = "skipped";
|
|
516
|
+
} else out.boto3 = "present";
|
|
517
|
+
|
|
518
|
+
// Embed server URL: the account's configured local one, else the default local server.
|
|
519
|
+
const configuredEmbed = String(p?.ragEmbedUrl || "").trim();
|
|
520
|
+
const embedUrl =
|
|
521
|
+
configuredEmbed && isLocalModelUrl(configuredEmbed)
|
|
522
|
+
? configuredEmbed
|
|
523
|
+
: `http://127.0.0.1:${EMBED_PORT}`;
|
|
524
|
+
const embedPort = parsePort(embedUrl) || EMBED_PORT;
|
|
525
|
+
|
|
526
|
+
if (await probeModelServer(embedUrl)) {
|
|
527
|
+
log(` ✓ embeddings server already running at ${modelRoot(embedUrl)} — leaving it alone.`);
|
|
528
|
+
out.embed = "already-running";
|
|
529
|
+
return out;
|
|
530
|
+
}
|
|
531
|
+
if (configuredEmbed && !isLocalModelUrl(configuredEmbed)) {
|
|
532
|
+
log(` ⚠ your embeddings server (${modelRoot(configuredEmbed)}) is remote and isn't`);
|
|
533
|
+
log(" answering. Start it there — this machine can't launch it.");
|
|
534
|
+
out.embed = "remote";
|
|
535
|
+
return out;
|
|
536
|
+
}
|
|
537
|
+
// Download the embed model if missing.
|
|
538
|
+
if (!(await modelDirReady(EMBED_DIR))) {
|
|
539
|
+
if (!(await confirm(`Download the RAG embedding model ${EMBED_REPO.split("/").pop()} (~4 GB)?`, true))) {
|
|
540
|
+
log(" Skipped. To do it yourself:");
|
|
541
|
+
log(` hf download ${EMBED_REPO} --local-dir ${EMBED_DIR}`);
|
|
542
|
+
log(` gonext-local-worker embed --model ${EMBED_DIR} --port ${embedPort}`);
|
|
543
|
+
out.embed = "skipped-model";
|
|
544
|
+
return out;
|
|
545
|
+
}
|
|
546
|
+
try {
|
|
547
|
+
await downloadModel({ repo: EMBED_REPO, dir: EMBED_DIR, log });
|
|
548
|
+
} catch (e) {
|
|
549
|
+
log(` ⚠ ${e instanceof Error ? e.message : e}`);
|
|
550
|
+
out.embed = "download-failed";
|
|
551
|
+
return out;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (!(await confirm(`Start the RAG embeddings server on port ${embedPort} now?`, true))) {
|
|
555
|
+
log(` Skipped. Start it later: gonext-local-worker embed --model ${EMBED_DIR} --port ${embedPort}`);
|
|
556
|
+
out.embed = "not-started";
|
|
557
|
+
return out;
|
|
558
|
+
}
|
|
559
|
+
const r = await startEmbedServer({ modelDir: EMBED_DIR, port: embedPort, log });
|
|
560
|
+
if (r.ready) {
|
|
561
|
+
log(` ✓ embeddings server is up on port ${embedPort}.`);
|
|
562
|
+
out.embed = "started";
|
|
563
|
+
} else if (r.started) {
|
|
564
|
+
log(` … embeddings server is loading in the background on port ${embedPort}.`);
|
|
565
|
+
out.embed = "loading";
|
|
566
|
+
} else {
|
|
567
|
+
log(` ⚠ ${r.reason}`);
|
|
568
|
+
out.embed = "start-failed";
|
|
569
|
+
}
|
|
570
|
+
return out;
|
|
571
|
+
}
|
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.346",
|
|
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",
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"gonext": "./gonext-repl.mjs"
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
|
-
"test": "python3 -m unittest discover tests -v",
|
|
20
|
+
"test": "python3 -m unittest discover tests -v && node --test tests/*.test.mjs",
|
|
21
|
+
"test:js": "node --test tests/*.test.mjs",
|
|
21
22
|
"deploy:local": "npm install -g .",
|
|
22
23
|
"run": "gonext-local-worker",
|
|
23
24
|
"publish:org": "npm publish --access public"
|
|
@@ -25,6 +26,9 @@
|
|
|
25
26
|
"files": [
|
|
26
27
|
"gonext-local-worker.mjs",
|
|
27
28
|
"gonext-repl.mjs",
|
|
29
|
+
"device-login.mjs",
|
|
30
|
+
"daemon-control.mjs",
|
|
31
|
+
"model-doctor.mjs",
|
|
28
32
|
"gonext_probe_agent.py",
|
|
29
33
|
"gonext_agent_chat.py",
|
|
30
34
|
"gonext_transcribe.py",
|