@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.
- package/gonext-repl.mjs +336 -0
- package/package.json +4 -2
package/gonext-repl.mjs
ADDED
|
@@ -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.
|
|
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",
|