@tiens.nguyen/gonext-local-worker 1.0.170 → 1.0.172

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 +336 -0
  2. package/package.json +4 -2
@@ -0,0 +1,336 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `gonext` — terminal agent REPL (P6).
4
+ *
5
+ * Run it inside any project folder:
6
+ * $ cd ~/Projects/my-repo
7
+ * $ gonext
8
+ *
9
+ * It offers to register the CURRENT folder as an agent workspace (with a trust prompt
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 queue — no race with a polling worker daemon), streaming the agent's steps
15
+ * and thinking into the terminal.
16
+ *
17
+ * Commands: /exit /revert [runId] /help Ctrl-C aborts the current run.
18
+ */
19
+ import { readFile, mkdir, writeFile } from "node:fs/promises";
20
+ import { existsSync, statSync } from "node:fs";
21
+ import { spawn } from "node:child_process";
22
+ import { homedir } from "node:os";
23
+ import { dirname, join, resolve, basename } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+ import { createInterface } from "node:readline";
26
+ import dotenv from "dotenv";
27
+
28
+ const DIR = dirname(fileURLToPath(import.meta.url));
29
+ const ENV_FILE = join(homedir(), ".gonext", "worker.env");
30
+ const WS_FILE = join(homedir(), ".gonext", "workspaces.json");
31
+
32
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
33
+ const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
34
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
35
+ const red = (s) => `\x1b[31m${s}\x1b[0m`;
36
+
37
+ // ---------- auth: worker key + API base from ~/.gonext/worker.env ----------
38
+ dotenv.config({ path: ENV_FILE });
39
+ dotenv.config();
40
+ const workerKey = String(process.env.GONEXT_WORKER_KEY ?? "").trim();
41
+ const apiBase = String(process.env.GONEXT_API_BASE ?? "").trim().replace(/\/+$/, "");
42
+ if (!workerKey || !apiBase) {
43
+ console.error(
44
+ red("gonext: no worker key / API base found.") +
45
+ `\nExpected ${ENV_FILE} (written by the Installation Wizard), or configure manually:` +
46
+ "\n gonext-local-worker set <workerKey> --api-base https://chat-api-v2.gomarsic.cc"
47
+ );
48
+ process.exit(1);
49
+ }
50
+
51
+ // Line intake: attach the listener IMMEDIATELY so lines that arrive while startup
52
+ // awaits (workspace prompt fetch, payload probe) are queued instead of lost — vital
53
+ // for piped/scripted input, harmless for interactive TTYs. Both ask() and the main
54
+ // loop consume from this queue (never rl.question, to avoid double-capture).
55
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
56
+ const _lineQueue = [];
57
+ let _lineWaiter = null;
58
+ let _stdinClosed = false;
59
+ rl.on("line", (l) => {
60
+ if (_lineWaiter) {
61
+ const w = _lineWaiter;
62
+ _lineWaiter = null;
63
+ w(l);
64
+ } else {
65
+ _lineQueue.push(l);
66
+ }
67
+ });
68
+ rl.on("close", () => {
69
+ _stdinClosed = true;
70
+ if (_lineWaiter) {
71
+ const w = _lineWaiter;
72
+ _lineWaiter = null;
73
+ w(null);
74
+ }
75
+ });
76
+ const nextLine = () =>
77
+ _lineQueue.length > 0
78
+ ? Promise.resolve(_lineQueue.shift())
79
+ : _stdinClosed
80
+ ? Promise.resolve(null)
81
+ : new Promise((res) => (_lineWaiter = res));
82
+ const ask = async (q) => {
83
+ process.stdout.write(q);
84
+ const a = await nextLine();
85
+ return (a ?? "").trim();
86
+ };
87
+
88
+ // ---------- workspace registration for the current folder ----------
89
+ async function readWorkspaces() {
90
+ try {
91
+ const raw = JSON.parse(await readFile(WS_FILE, "utf8"));
92
+ return Array.isArray(raw?.workspaces) ? raw.workspaces : [];
93
+ } catch {
94
+ return [];
95
+ }
96
+ }
97
+
98
+ async function ensureWorkspace() {
99
+ const cwd = resolve(process.cwd());
100
+ const list = await readWorkspaces();
101
+ const hit = list.find((w) => cwd === w.path || cwd.startsWith(w.path + "/"));
102
+ if (hit) {
103
+ console.log(
104
+ dim(`workspace: ${hit.name} → ${hit.path} (run ${hit.allowRun ? "enabled" : "disabled"})`)
105
+ );
106
+ return;
107
+ }
108
+ if (!existsSync(cwd) || !statSync(cwd).isDirectory()) return;
109
+ console.log(`This folder is not a registered workspace:\n ${cwd}`);
110
+ const reg = (await ask("Register it so the agent can read/edit code here? [Y/n] ")) || "y";
111
+ if (!/^y(es)?$/i.test(reg)) {
112
+ console.log(dim("Skipped — the agent will not be able to touch files here."));
113
+ return;
114
+ }
115
+ const trust = (await ask(
116
+ "Do you trust this folder — allow the agent to RUN build/test commands\n" +
117
+ "(npm test, mvn, pytest…)? [y/N] "
118
+ )) || "n";
119
+ const allowRun = /^y(es)?$/i.test(trust);
120
+ const next = list.filter((w) => w.path !== cwd);
121
+ next.push({
122
+ name: basename(cwd),
123
+ path: cwd,
124
+ allowRun,
125
+ addedAt: new Date().toISOString(),
126
+ });
127
+ await mkdir(dirname(WS_FILE), { recursive: true });
128
+ await writeFile(WS_FILE, JSON.stringify({ workspaces: next }, null, 2) + "\n");
129
+ console.log(
130
+ green(`✓ workspace registered: ${basename(cwd)}`) +
131
+ (allowRun ? green(" (run-commands enabled)") : dim(" (run-commands disabled)"))
132
+ );
133
+ }
134
+
135
+ // ---------- fetch the ready-to-run agent payload from user settings ----------
136
+ async function fetchAgentPayload(messages) {
137
+ const res = await fetch(`${apiBase}/api/worker/agent-payload`, {
138
+ method: "POST",
139
+ headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
140
+ body: JSON.stringify({ messages }),
141
+ });
142
+ const text = await res.text();
143
+ let data;
144
+ try {
145
+ data = JSON.parse(text);
146
+ } catch {
147
+ data = {};
148
+ }
149
+ if (!res.ok) {
150
+ if (res.status === 404) {
151
+ throw new Error(
152
+ "the API does not have POST /api/worker/agent-payload yet — deploy the latest API (sam build && sam deploy)."
153
+ );
154
+ }
155
+ throw new Error(data?.error || `agent-payload failed (HTTP ${res.status})`);
156
+ }
157
+ return data;
158
+ }
159
+
160
+ // ---------- run one agent turn by executing gonext_agent_chat.py directly ----------
161
+ let child = null;
162
+
163
+ async function runAgentTurn(payload) {
164
+ // Map the job payload to the python cfg the same way the worker daemon does in
165
+ // runAgentChatJob (gonext-local-worker.mjs) — KEEP IN SYNC with that block.
166
+ const input = JSON.stringify({
167
+ messages: payload?.messages ?? [],
168
+ agentBaseURL: payload?.agentBaseURL ?? "",
169
+ agentApiKey: payload?.agentApiKey ?? "",
170
+ agentModelId: payload?.agentModelId ?? "",
171
+ codingBaseURL: payload?.codingBaseURL ?? "",
172
+ codingModelId: payload?.codingModelId ?? "",
173
+ tools: payload?.tools ?? ["http_request"],
174
+ maxSteps: payload?.maxSteps ?? 0,
175
+ researchBudget: payload?.researchBudget ?? 10,
176
+ agentToolMode: payload?.agentToolMode ?? "code",
177
+ apiBaseURL: apiBase,
178
+ workerKey,
179
+ emailEnabled: payload?.emailEnabled ?? false,
180
+ emailApiUrl: payload?.emailApiUrl ?? "",
181
+ emailApiMethod: payload?.emailApiMethod ?? "POST",
182
+ emailApiHeaders: payload?.emailApiHeaders ?? "",
183
+ emailBodyTemplate: payload?.emailBodyTemplate ?? "",
184
+ emailFrom: payload?.emailFrom ?? "",
185
+ emailAllowList: payload?.emailAllowList ?? "",
186
+ ragEnabled: payload?.ragEnabled ?? false,
187
+ ragS3Location: payload?.ragS3Location ?? "",
188
+ ragAwsRegion: payload?.ragAwsRegion ?? "",
189
+ ragAwsAccessKeyId: payload?.ragAwsAccessKeyId ?? "",
190
+ ragAwsSecretAccessKey: payload?.ragAwsSecretAccessKey ?? "",
191
+ ragEmbedModel: payload?.ragEmbedModel ?? "",
192
+ ragEmbedUrl: payload?.ragEmbedUrl ?? "",
193
+ ragTopK: payload?.ragTopK ?? 6,
194
+ workspaces: await readWorkspaces(),
195
+ });
196
+
197
+ const python =
198
+ (process.env.GONEXT_PROBE_PYTHON ?? process.env.GONEXT_MLX_LM_PYTHON ?? "").trim() ||
199
+ "python3";
200
+ // WeasyPrint (create_pdf) needs Homebrew libs on macOS — same env the worker sets.
201
+ let extraEnv;
202
+ if (process.platform === "darwin") {
203
+ const existing = process.env.DYLD_FALLBACK_LIBRARY_PATH ?? "";
204
+ extraEnv = {
205
+ DYLD_FALLBACK_LIBRARY_PATH: [existing, "/opt/homebrew/lib", "/usr/local/lib"]
206
+ .filter(Boolean)
207
+ .join(":"),
208
+ };
209
+ }
210
+
211
+ return new Promise((resolveTurn) => {
212
+ let finalText = "";
213
+ let buf = "";
214
+ child = spawn(python, [join(DIR, "gonext_agent_chat.py")], {
215
+ env: { ...process.env, ...(extraEnv ?? {}) },
216
+ });
217
+ child.stdin.write(input);
218
+ child.stdin.end();
219
+ child.stdout.on("data", (chunk) => {
220
+ buf += chunk.toString();
221
+ let nl;
222
+ while ((nl = buf.indexOf("\n")) >= 0) {
223
+ const line = buf.slice(0, nl);
224
+ buf = buf.slice(nl + 1);
225
+ if (!line.trim()) continue;
226
+ let ev;
227
+ try {
228
+ ev = JSON.parse(line);
229
+ } catch {
230
+ continue;
231
+ }
232
+ if (ev.type === "step" && typeof ev.text === "string") {
233
+ process.stdout.write(dim(`\n· ${ev.text}`));
234
+ } else if (ev.type === "stream" && typeof ev.text === "string") {
235
+ process.stdout.write(dim(ev.text));
236
+ } else if (ev.type === "final" && typeof ev.text === "string") {
237
+ finalText = ev.text;
238
+ } else if (ev.type === "log" && process.env.GONEXT_REPL_VERBOSE === "1") {
239
+ process.stdout.write(dim(`\n[log] ${ev.text}`));
240
+ }
241
+ }
242
+ });
243
+ child.stderr.on("data", (chunk) => {
244
+ if (process.env.GONEXT_REPL_VERBOSE === "1") {
245
+ process.stderr.write(dim(chunk.toString()));
246
+ }
247
+ });
248
+ child.on("error", (err) => {
249
+ console.error(red(`\ngonext: failed to start ${python}: ${err.message}`));
250
+ child = null;
251
+ resolveTurn("");
252
+ });
253
+ child.on("exit", () => {
254
+ child = null;
255
+ resolveTurn(finalText);
256
+ });
257
+ });
258
+ }
259
+
260
+ // ---------- REPL ----------
261
+ async function main() {
262
+ console.log(cyan("GoNext agent REPL") + dim(` · API ${apiBase} · key ${workerKey.slice(0, 8)}…`));
263
+ await ensureWorkspace();
264
+ // Show which models will answer, straight from user settings (also validates auth).
265
+ try {
266
+ const probe = await fetchAgentPayload([{ role: "user", content: "ping" }]);
267
+ const p = probe?.payload ?? {};
268
+ console.log(
269
+ dim(
270
+ `agent model: ${p.agentModelId || probe?.modelKey || "?"}` +
271
+ (p.codingModelId ? ` · coder: ${p.codingModelId}` : "") +
272
+ (p.ragEnabled ? " · RAG on" : "")
273
+ )
274
+ );
275
+ } catch (err) {
276
+ console.error(red(`gonext: ${err.message}`));
277
+ process.exit(1);
278
+ }
279
+ console.log(dim("Ask about this repo, or /help. Ctrl-C aborts a running turn.\n"));
280
+
281
+ const history = [];
282
+ rl.on("SIGINT", () => {
283
+ if (child) {
284
+ child.kill("SIGKILL");
285
+ child = null;
286
+ process.stdout.write(red("\n^C run aborted\n"));
287
+ } else {
288
+ process.stdout.write(dim("\n(/exit to quit)\n") + cyan(">> "));
289
+ }
290
+ });
291
+
292
+ for (;;) {
293
+ process.stdout.write(cyan(">> "));
294
+ const lineRaw = await nextLine();
295
+ if (lineRaw === null) break; // stdin closed
296
+ const line = lineRaw.trim();
297
+ if (!line) continue;
298
+ if (line === "/exit" || line === "/quit") break;
299
+ if (line === "/help") {
300
+ console.log(dim(" /exit — quit · /revert [runId] — undo the agent's file edits (latest run)"));
301
+ continue;
302
+ }
303
+ if (line.startsWith("/revert")) {
304
+ const runId = line.split(/\s+/)[1] ?? "";
305
+ await new Promise((res) => {
306
+ const p = spawn(
307
+ process.execPath,
308
+ [join(DIR, "gonext-local-worker.mjs"), "workspace", "revert", ...(runId ? [runId] : [])],
309
+ { stdio: "inherit" }
310
+ );
311
+ p.on("exit", res);
312
+ p.on("error", res);
313
+ });
314
+ continue;
315
+ }
316
+ history.push({ role: "user", content: line });
317
+ try {
318
+ const { payload } = await fetchAgentPayload(history);
319
+ const answer = await runAgentTurn(payload);
320
+ if (answer) {
321
+ history.push({ role: "assistant", content: answer });
322
+ console.log(`\n\n${answer}\n`);
323
+ } else {
324
+ history.pop(); // failed turn — don't poison the next request's history
325
+ console.log(red("\n(no answer — run failed or was aborted)\n"));
326
+ }
327
+ } catch (err) {
328
+ history.pop();
329
+ console.error(red(`\ngonext: ${err.message}\n`));
330
+ }
331
+ }
332
+ rl.close();
333
+ process.exit(0);
334
+ }
335
+
336
+ main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.170",
3
+ "version": "1.0.172",
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",
@@ -13,7 +13,8 @@
13
13
  "url": "https://github.com/tiennsloit/gonext/issues"
14
14
  },
15
15
  "bin": {
16
- "gonext-local-worker": "./gonext-local-worker.mjs"
16
+ "gonext-local-worker": "./gonext-local-worker.mjs",
17
+ "gonext": "./gonext-repl.mjs"
17
18
  },
18
19
  "scripts": {
19
20
  "deploy:local": "npm install -g .",
@@ -22,6 +23,7 @@
22
23
  },
23
24
  "files": [
24
25
  "gonext-local-worker.mjs",
26
+ "gonext-repl.mjs",
25
27
  "gonext_probe_agent.py",
26
28
  "gonext_agent_chat.py",
27
29
  "gonext_transcribe.py",