@tiens.nguyen/gonext-local-worker 1.0.167 → 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-local-worker.mjs +150 -1
- package/gonext-repl.mjs +336 -0
- package/gonext_agent_chat.py +373 -6
- package/package.json +4 -2
package/gonext-local-worker.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* - `gonext-local-worker ws-ping-test` — POST worker ws-ping (needs GONEXT_* env)
|
|
7
7
|
* - `gonext-local-worker simulate-chat [text]` — claim next chat job, push fake reply like the real worker (needs GONEXT_* env)
|
|
8
8
|
* - `gonext-local-worker embed --model <mlx-embed-model> [--port 8085]` — run the MLX embeddings server (RAG); use a separate terminal
|
|
9
|
+
* - `gonext-local-worker workspace add|remove|list …` — manage local code folders the agent may read/edit/test in
|
|
9
10
|
* - `gonext-local-worker` — starts polling loop (claims jobs and runs models)
|
|
10
11
|
*/
|
|
11
12
|
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
@@ -222,6 +223,129 @@ if (args[0] === "ws-ping-test") {
|
|
|
222
223
|
process.exit(0);
|
|
223
224
|
}
|
|
224
225
|
|
|
226
|
+
if (args[0] === "workspace") {
|
|
227
|
+
// Manage the local workspaces the agent may READ and EDIT code in. Registration
|
|
228
|
+
// happens HERE on the Mac (never from the web) — the registry is the security
|
|
229
|
+
// boundary for the agent's coding tools (grep/read/edit/run stay inside these roots).
|
|
230
|
+
// gonext-local-worker workspace add <path> [--name <name>] [--allow-run]
|
|
231
|
+
// gonext-local-worker workspace remove <path-or-name>
|
|
232
|
+
// gonext-local-worker workspace list
|
|
233
|
+
const wsFile = join(homedir(), ".gonext", "workspaces.json");
|
|
234
|
+
const readWs = async () => {
|
|
235
|
+
try {
|
|
236
|
+
const raw = JSON.parse(await readFile(wsFile, "utf8"));
|
|
237
|
+
return Array.isArray(raw?.workspaces) ? raw.workspaces : [];
|
|
238
|
+
} catch {
|
|
239
|
+
return [];
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
const writeWs = async (workspaces) => {
|
|
243
|
+
await mkdir(dirname(wsFile), { recursive: true });
|
|
244
|
+
await writeFile(wsFile, JSON.stringify({ workspaces }, null, 2) + "\n");
|
|
245
|
+
};
|
|
246
|
+
const sub = args[1];
|
|
247
|
+
const { resolve } = await import("node:path");
|
|
248
|
+
const { existsSync, statSync } = await import("node:fs");
|
|
249
|
+
if (sub === "add") {
|
|
250
|
+
let p = args[2] || "";
|
|
251
|
+
if (!p) {
|
|
252
|
+
console.error("Usage: gonext-local-worker workspace add <path> [--name <name>] [--allow-run]");
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
if (p.startsWith("~/") || p === "~") p = join(homedir(), p.slice(p.length > 1 ? 2 : 1));
|
|
256
|
+
p = resolve(p);
|
|
257
|
+
if (!existsSync(p) || !statSync(p).isDirectory()) {
|
|
258
|
+
console.error(`[gonext-worker] workspace add: not a directory: ${p}`);
|
|
259
|
+
process.exit(1);
|
|
260
|
+
}
|
|
261
|
+
const nameIdx = args.indexOf("--name");
|
|
262
|
+
const name = nameIdx >= 0 && args[nameIdx + 1] ? args[nameIdx + 1] : p.split("/").filter(Boolean).pop();
|
|
263
|
+
const allowRun = args.includes("--allow-run");
|
|
264
|
+
const list = (await readWs()).filter((w) => w.path !== p);
|
|
265
|
+
list.push({ name, path: p, allowRun, addedAt: new Date().toISOString() });
|
|
266
|
+
await writeWs(list);
|
|
267
|
+
console.log(
|
|
268
|
+
`[gonext-worker] workspace registered: ${name} → ${p}` +
|
|
269
|
+
(allowRun ? " (run-commands ENABLED)" : " (run-commands disabled; add --allow-run to enable tests)")
|
|
270
|
+
);
|
|
271
|
+
process.exit(0);
|
|
272
|
+
}
|
|
273
|
+
if (sub === "remove") {
|
|
274
|
+
const key = args[2] || "";
|
|
275
|
+
const list = await readWs();
|
|
276
|
+
const next = list.filter((w) => w.path !== key && w.name !== key);
|
|
277
|
+
if (next.length === list.length) {
|
|
278
|
+
console.error(`[gonext-worker] workspace remove: no workspace matches '${key}'`);
|
|
279
|
+
process.exit(1);
|
|
280
|
+
}
|
|
281
|
+
await writeWs(next);
|
|
282
|
+
console.log(`[gonext-worker] workspace removed: ${key}`);
|
|
283
|
+
process.exit(0);
|
|
284
|
+
}
|
|
285
|
+
if (sub === "revert") {
|
|
286
|
+
// Restore the files an agent run changed: copy backups back over edits and delete
|
|
287
|
+
// created files. Uses the manifest the python tools persist per change.
|
|
288
|
+
// gonext-local-worker workspace revert → latest run
|
|
289
|
+
// gonext-local-worker workspace revert <runId> → a specific run (see backups dir)
|
|
290
|
+
const { readdirSync, existsSync: ex } = await import("node:fs");
|
|
291
|
+
const { copyFile, unlink } = await import("node:fs/promises");
|
|
292
|
+
const backupsRoot = join(homedir(), ".gonext", "workspace-backups");
|
|
293
|
+
let runId = args[2] || "";
|
|
294
|
+
if (!runId) {
|
|
295
|
+
const runs = (() => {
|
|
296
|
+
try { return readdirSync(backupsRoot).filter((d) => !d.startsWith(".")).sort(); }
|
|
297
|
+
catch { return []; }
|
|
298
|
+
})();
|
|
299
|
+
if (runs.length === 0) {
|
|
300
|
+
console.error("[gonext-worker] workspace revert: no backup runs found.");
|
|
301
|
+
process.exit(1);
|
|
302
|
+
}
|
|
303
|
+
runId = runs[runs.length - 1];
|
|
304
|
+
}
|
|
305
|
+
const manifestPath = join(backupsRoot, runId, "manifest.json");
|
|
306
|
+
let manifest;
|
|
307
|
+
try {
|
|
308
|
+
manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
309
|
+
} catch {
|
|
310
|
+
console.error(`[gonext-worker] workspace revert: no manifest for run ${runId} (${manifestPath})`);
|
|
311
|
+
process.exit(1);
|
|
312
|
+
}
|
|
313
|
+
let restored = 0, deleted = 0, failed = 0;
|
|
314
|
+
for (const c of manifest?.changes ?? []) {
|
|
315
|
+
try {
|
|
316
|
+
if (c.action === "edit" && c.backup && ex(c.backup)) {
|
|
317
|
+
await copyFile(c.backup, c.path);
|
|
318
|
+
restored += 1;
|
|
319
|
+
console.log(` restored ${c.path}`);
|
|
320
|
+
} else if (c.action === "create" && ex(c.path)) {
|
|
321
|
+
await unlink(c.path);
|
|
322
|
+
deleted += 1;
|
|
323
|
+
console.log(` deleted ${c.path}`);
|
|
324
|
+
}
|
|
325
|
+
} catch (err) {
|
|
326
|
+
failed += 1;
|
|
327
|
+
console.error(` FAILED ${c.path}: ${err.message}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
console.log(
|
|
331
|
+
`[gonext-worker] workspace revert ${runId}: ${restored} restored, ${deleted} created-file(s) deleted` +
|
|
332
|
+
(failed ? `, ${failed} FAILED` : "") +
|
|
333
|
+
" (backups kept)"
|
|
334
|
+
);
|
|
335
|
+
process.exit(failed ? 1 : 0);
|
|
336
|
+
}
|
|
337
|
+
// default: list
|
|
338
|
+
const list = await readWs();
|
|
339
|
+
if (list.length === 0) {
|
|
340
|
+
console.log("[gonext-worker] no workspaces registered. Add one:\n gonext-local-worker workspace add ~/Projects/my-repo --allow-run");
|
|
341
|
+
} else {
|
|
342
|
+
for (const w of list) {
|
|
343
|
+
console.log(` ${w.name} ${w.path} run:${w.allowRun ? "yes" : "no"}`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
process.exit(0);
|
|
347
|
+
}
|
|
348
|
+
|
|
225
349
|
if (args[0] === "embed") {
|
|
226
350
|
// Start the MLX embeddings server (OpenAI-compatible /v1/embeddings) for the RAG
|
|
227
351
|
// feature, in the FOREGROUND of this terminal. Run it in a SEPARATE terminal from the
|
|
@@ -1631,7 +1755,10 @@ async function runAgentChatJob(job) {
|
|
|
1631
1755
|
codingBaseURL: payload?.codingBaseURL ?? "",
|
|
1632
1756
|
codingModelId: payload?.codingModelId ?? "",
|
|
1633
1757
|
tools: payload?.tools ?? ["http_request"],
|
|
1634
|
-
|
|
1758
|
+
// 0 = "not explicitly set": python defaults to 5, but may auto-raise to 12 when
|
|
1759
|
+
// workspaces are registered. Passing a literal 5 here would look user-set and
|
|
1760
|
+
// block that bump.
|
|
1761
|
+
maxSteps: payload?.maxSteps ?? 0,
|
|
1635
1762
|
// Max web_search + fetch_url calls the agent may make per task (user-configurable
|
|
1636
1763
|
// in web Settings → Agent). Default 10; past it the retrieval tools refuse.
|
|
1637
1764
|
researchBudget: payload?.researchBudget ?? 10,
|
|
@@ -1664,6 +1791,14 @@ async function runAgentChatJob(job) {
|
|
|
1664
1791
|
ragEmbedModel: payload?.ragEmbedModel ?? "",
|
|
1665
1792
|
ragEmbedUrl: payload?.ragEmbedUrl ?? "",
|
|
1666
1793
|
ragTopK: payload?.ragTopK ?? 6,
|
|
1794
|
+
// Workspaces the agent may read/edit/test code in — read fresh each job so a
|
|
1795
|
+
// `gonext-local-worker workspace add` applies without restarting the worker.
|
|
1796
|
+
workspaces: await readFile(join(homedir(), ".gonext", "workspaces.json"), "utf8")
|
|
1797
|
+
.then((raw) => {
|
|
1798
|
+
const parsed = JSON.parse(raw);
|
|
1799
|
+
return Array.isArray(parsed?.workspaces) ? parsed.workspaces : [];
|
|
1800
|
+
})
|
|
1801
|
+
.catch(() => []),
|
|
1667
1802
|
});
|
|
1668
1803
|
// 30 min max for an agent run: multi-step ReAct on a 14B/31B with a cold prompt
|
|
1669
1804
|
// cache — or a remote Ollama box on slow GPU cold-loading a big model — can run
|
|
@@ -2260,6 +2395,20 @@ async function runLocalHealthJob(job) {
|
|
|
2260
2395
|
modelPaths: modelPathStatus,
|
|
2261
2396
|
}
|
|
2262
2397
|
: undefined,
|
|
2398
|
+
// Registered coding workspaces (names/paths only — no secrets) so the web can
|
|
2399
|
+
// show which folders the agent may read/edit on this Mac.
|
|
2400
|
+
workspaces: await readFile(join(homedir(), ".gonext", "workspaces.json"), "utf8")
|
|
2401
|
+
.then((raw) => {
|
|
2402
|
+
const parsed = JSON.parse(raw);
|
|
2403
|
+
return Array.isArray(parsed?.workspaces)
|
|
2404
|
+
? parsed.workspaces.map((w) => ({
|
|
2405
|
+
name: String(w.name ?? ""),
|
|
2406
|
+
path: String(w.path ?? ""),
|
|
2407
|
+
allowRun: Boolean(w.allowRun),
|
|
2408
|
+
}))
|
|
2409
|
+
: undefined;
|
|
2410
|
+
})
|
|
2411
|
+
.catch(() => undefined),
|
|
2263
2412
|
};
|
|
2264
2413
|
const totalTimeSeconds = (Date.now() - start) / 1000;
|
|
2265
2414
|
const doneRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
|
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/gonext_agent_chat.py
CHANGED
|
@@ -519,6 +519,8 @@ _AGENT_KEYWORDS = re.compile(
|
|
|
519
519
|
r"|external\s+source|external\s+api|external\s+service"
|
|
520
520
|
r"|web\s+service|rest\s+api|rest\s+call"
|
|
521
521
|
r"|download|scrape|crawl|zip|unzip|\.zip|rag|index\s+the|knowledge\s+base|summari[sz]e"
|
|
522
|
+
r"|workspace|fix\s+(?:a\s+|the\s+|this\s+)?bug|refactor|codebase|source\s+code|repo(?:sitory)?"
|
|
523
|
+
r"|modify\s+(?:the\s+)?code|change\s+(?:the\s+)?code|run\s+(?:the\s+)?tests?"
|
|
522
524
|
r"|search|find|look\s*up|lookup|weather|news|latest|current|today|tonight"
|
|
523
525
|
r"|date|time|what\s+day|what\s+time"
|
|
524
526
|
r"|read\s+(?:this\s+|that\s+|the\s+)?(?:page|article|url|link)|open\s+(?:this\s+|that\s+|the\s+)?(?:url|link|page)|contents?\s+of"
|
|
@@ -1417,9 +1419,16 @@ def _rag_parse_location(loc: str):
|
|
|
1417
1419
|
return bucket, prefix.strip("/")
|
|
1418
1420
|
|
|
1419
1421
|
|
|
1422
|
+
# Registered workspace roots (from ~/.gonext/workspaces.json via the worker) — set
|
|
1423
|
+
# per-run by run_agent_chat. Reads are allowed in any of them; WRITES additionally
|
|
1424
|
+
# require _ws_write_allowed (workspace root + not inside .git/).
|
|
1425
|
+
_WS_ROOTS: list = [] # [{"name": str, "path": realpath str, "allowRun": bool}]
|
|
1426
|
+
|
|
1427
|
+
|
|
1420
1428
|
def _rag_read_allowed(path: str) -> str:
|
|
1421
|
-
"""Resolve `path` and ensure it stays within a safe root (worker cwd, temp dir,
|
|
1422
|
-
~/.gonext) — so the agent's file tools can't read
|
|
1429
|
+
"""Resolve `path` and ensure it stays within a safe root (worker cwd, temp dir,
|
|
1430
|
+
~/.gonext, or a registered workspace) — so the agent's file tools can't read
|
|
1431
|
+
arbitrary files like ~/.ssh."""
|
|
1423
1432
|
import os
|
|
1424
1433
|
import tempfile
|
|
1425
1434
|
rp = os.path.realpath(path)
|
|
@@ -1427,14 +1436,115 @@ def _rag_read_allowed(path: str) -> str:
|
|
|
1427
1436
|
os.path.realpath(os.getcwd()),
|
|
1428
1437
|
os.path.realpath(tempfile.gettempdir()),
|
|
1429
1438
|
os.path.realpath(os.path.join(os.path.expanduser("~"), ".gonext")),
|
|
1430
|
-
]
|
|
1439
|
+
] + [w["path"] for w in _WS_ROOTS]
|
|
1431
1440
|
if any(rp == r or rp.startswith(r + os.sep) for r in roots):
|
|
1432
1441
|
return rp
|
|
1433
1442
|
raise ValueError(
|
|
1434
|
-
"Path is outside the allowed working directories (download/unzip outputs
|
|
1443
|
+
"Path is outside the allowed working directories (download/unzip outputs "
|
|
1444
|
+
"or a registered workspace)."
|
|
1435
1445
|
)
|
|
1436
1446
|
|
|
1437
1447
|
|
|
1448
|
+
def _ws_for_path(path: str):
|
|
1449
|
+
"""Return the registered workspace dict containing `path`, else None."""
|
|
1450
|
+
import os
|
|
1451
|
+
rp = os.path.realpath(path)
|
|
1452
|
+
for w in _WS_ROOTS:
|
|
1453
|
+
if rp == w["path"] or rp.startswith(w["path"] + os.sep):
|
|
1454
|
+
return w
|
|
1455
|
+
return None
|
|
1456
|
+
|
|
1457
|
+
|
|
1458
|
+
def _ws_write_allowed(path: str) -> str:
|
|
1459
|
+
"""Resolve `path` for WRITING: must be inside a registered workspace and never
|
|
1460
|
+
inside its .git/ internals (agents must not corrupt version control)."""
|
|
1461
|
+
import os
|
|
1462
|
+
rp = os.path.realpath(path)
|
|
1463
|
+
w = _ws_for_path(rp)
|
|
1464
|
+
if w is None:
|
|
1465
|
+
raise ValueError(
|
|
1466
|
+
"Writes are only allowed inside a registered workspace "
|
|
1467
|
+
"(register one with: gonext-local-worker workspace add <path>)."
|
|
1468
|
+
)
|
|
1469
|
+
rel = os.path.relpath(rp, w["path"])
|
|
1470
|
+
if rel == ".git" or rel.startswith(".git" + os.sep) or (os.sep + ".git" + os.sep) in (os.sep + rel + os.sep):
|
|
1471
|
+
raise ValueError("Writes inside .git/ are not allowed.")
|
|
1472
|
+
return rp
|
|
1473
|
+
|
|
1474
|
+
|
|
1475
|
+
_WS_RUN_ID = None # backup folder for this run, created lazily on first change
|
|
1476
|
+
_WS_CHANGES: list = [] # [{"action": "edit"|"create", "path": str, "backup": str|None}]
|
|
1477
|
+
|
|
1478
|
+
|
|
1479
|
+
def _ws_backup_root() -> str:
|
|
1480
|
+
"""Backup dir for this run (created lazily). Also home of manifest.json."""
|
|
1481
|
+
global _WS_RUN_ID
|
|
1482
|
+
import os
|
|
1483
|
+
import time as _t
|
|
1484
|
+
if _WS_RUN_ID is None:
|
|
1485
|
+
_WS_RUN_ID = _t.strftime("%Y%m%d-%H%M%S", _t.localtime())
|
|
1486
|
+
root = os.path.join(os.path.expanduser("~"), ".gonext", "workspace-backups", _WS_RUN_ID)
|
|
1487
|
+
os.makedirs(root, exist_ok=True)
|
|
1488
|
+
return root
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
def _ws_snapshot(path: str) -> str:
|
|
1492
|
+
"""Copy the file to the run's backup dir before its FIRST edit in this run, so
|
|
1493
|
+
every change is revertible. Returns the backup path ('' if new file)."""
|
|
1494
|
+
import os
|
|
1495
|
+
import shutil
|
|
1496
|
+
if not os.path.isfile(path):
|
|
1497
|
+
return ""
|
|
1498
|
+
for c in _WS_CHANGES: # already snapshotted this run — keep the ORIGINAL version
|
|
1499
|
+
if c["path"] == path and c.get("backup"):
|
|
1500
|
+
return c["backup"]
|
|
1501
|
+
root = _ws_backup_root()
|
|
1502
|
+
dest = os.path.join(root, path.lstrip(os.sep))
|
|
1503
|
+
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
1504
|
+
shutil.copy2(path, dest)
|
|
1505
|
+
return dest
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
def _ws_record_change(action: str, path: str, backup) -> None:
|
|
1509
|
+
"""Track a change AND persist the run manifest so `gonext-local-worker workspace
|
|
1510
|
+
revert` can restore edits / delete created files after the process is gone."""
|
|
1511
|
+
import os
|
|
1512
|
+
_WS_CHANGES.append({"action": action, "path": path, "backup": backup or None})
|
|
1513
|
+
try:
|
|
1514
|
+
root = _ws_backup_root()
|
|
1515
|
+
with open(os.path.join(root, "manifest.json"), "w", encoding="utf-8") as fh:
|
|
1516
|
+
json.dump({"changes": _WS_CHANGES}, fh, indent=2)
|
|
1517
|
+
except OSError as e:
|
|
1518
|
+
_log(f"workspace manifest write failed: {e}")
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
# Command prefixes run_command may execute (first token after shlex split). Build/test
|
|
1522
|
+
# runners only — no shells, no package publishes, no sudo.
|
|
1523
|
+
_WS_RUN_ALLOWED = {
|
|
1524
|
+
"npm", "npx", "yarn", "pnpm", "node", "mvn", "./mvnw", "gradle", "./gradlew",
|
|
1525
|
+
"pytest", "python", "python3", "go", "cargo", "make", "dotnet", "swift",
|
|
1526
|
+
"rspec", "bundle", "php", "composer", "tsc", "jest", "vitest",
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
def _ws_scrubbed_env() -> dict:
|
|
1531
|
+
"""Minimal child env for run_command. The worker process env holds secrets
|
|
1532
|
+
(GONEXT_WORKER_KEY etc. via worker.env) — a repo's test script must NEVER see
|
|
1533
|
+
them, so we pass an explicit whitelist instead of inheriting."""
|
|
1534
|
+
import os
|
|
1535
|
+
keep = ("PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "SHELL", "USER", "TERM",
|
|
1536
|
+
"JAVA_HOME", "GOPATH", "CARGO_HOME", "NVM_DIR")
|
|
1537
|
+
return {k: os.environ[k] for k in keep if k in os.environ}
|
|
1538
|
+
|
|
1539
|
+
|
|
1540
|
+
def _ws_distill_output(out: str, cap_head: int = 1200, cap_tail: int = 4000) -> str:
|
|
1541
|
+
"""Cap huge build/test output: failures live at the tail, context at the head."""
|
|
1542
|
+
if len(out) <= cap_head + cap_tail + 200:
|
|
1543
|
+
return out
|
|
1544
|
+
return (out[:cap_head] + f"\n…[{len(out) - cap_head - cap_tail} chars omitted]…\n"
|
|
1545
|
+
+ out[-cap_tail:])
|
|
1546
|
+
|
|
1547
|
+
|
|
1438
1548
|
def _rag_s3_client(region: str, akid: str, secret: str):
|
|
1439
1549
|
import boto3 # noqa: PLC0415
|
|
1440
1550
|
return boto3.client(
|
|
@@ -1627,6 +1737,33 @@ def run_agent_chat(cfg):
|
|
|
1627
1737
|
if rag_enabled and not _boto3_ok:
|
|
1628
1738
|
_log("RAG requested but boto3 is not installed in the worker python — RAG tools disabled")
|
|
1629
1739
|
|
|
1740
|
+
# ---- Workspaces (local folders the agent may read/edit/test code in) ----
|
|
1741
|
+
# Registered on the Mac via `gonext-local-worker workspace add <path>`; the worker
|
|
1742
|
+
# passes the registry through cfg. Only these roots are writable — the security
|
|
1743
|
+
# boundary for the coding tools.
|
|
1744
|
+
global _WS_ROOTS
|
|
1745
|
+
_WS_ROOTS = []
|
|
1746
|
+
for w in (cfg.get("workspaces") or []):
|
|
1747
|
+
try:
|
|
1748
|
+
import os as _os
|
|
1749
|
+
p = _os.path.realpath(_os.path.expanduser(str(w.get("path") or "")))
|
|
1750
|
+
if p and _os.path.isdir(p):
|
|
1751
|
+
_WS_ROOTS.append({
|
|
1752
|
+
"name": str(w.get("name") or _os.path.basename(p)),
|
|
1753
|
+
"path": p,
|
|
1754
|
+
"allowRun": bool(w.get("allowRun")),
|
|
1755
|
+
})
|
|
1756
|
+
except Exception: # noqa: BLE001
|
|
1757
|
+
continue
|
|
1758
|
+
_WS_AVAILABLE = bool(_WS_ROOTS)
|
|
1759
|
+
if _WS_AVAILABLE:
|
|
1760
|
+
_log(f"workspaces: {[(w['name'], w['path'], w['allowRun']) for w in _WS_ROOTS]}")
|
|
1761
|
+
# Coding tasks are edit→test→fix loops — 5 steps is too tight. Bump the default
|
|
1762
|
+
# budget when workspaces are registered, unless the payload set one explicitly.
|
|
1763
|
+
if not cfg.get("maxSteps") and max_steps < 12:
|
|
1764
|
+
max_steps = 12
|
|
1765
|
+
_log("workspace registered → step budget raised to 12 (no explicit maxSteps)")
|
|
1766
|
+
|
|
1630
1767
|
def _rag_s3_and_loc():
|
|
1631
1768
|
client = _rag_s3_client(rag_region, rag_akid, rag_secret)
|
|
1632
1769
|
bucket, prefix = _rag_parse_location(rag_location)
|
|
@@ -1811,6 +1948,10 @@ def run_agent_chat(cfg):
|
|
|
1811
1948
|
_email_num = _tool_num
|
|
1812
1949
|
if _RAG_AVAILABLE:
|
|
1813
1950
|
_tool_num += 7 # download_file, unzip_file, list_dir, read_text_file, rag_index/add/search
|
|
1951
|
+
if _WS_AVAILABLE:
|
|
1952
|
+
_tool_num += 6 # grep_repo, read_file_lines, edit_lines, edit_file, create_file, run_command
|
|
1953
|
+
if not _RAG_AVAILABLE:
|
|
1954
|
+
_tool_num += 2 # list_dir + read_text_file registered for workspace browsing
|
|
1814
1955
|
_tool_count_word = {6: "SIX", 7: "SEVEN", 8: "EIGHT"}.get(_tool_num, str(_tool_num))
|
|
1815
1956
|
_rag_tool_block = (
|
|
1816
1957
|
" FILES & KNOWLEDGE BASE — to SUMMARIZE or ANSWER questions about a ZIP of files at a URL:\n"
|
|
@@ -1838,6 +1979,33 @@ def run_agent_chat(cfg):
|
|
|
1838
1979
|
"instantly — then list_dir/read_text_file or rag_index as needed. "
|
|
1839
1980
|
"If the user adds information, rag_add(source_url=url, text=...).\n"
|
|
1840
1981
|
) if _RAG_AVAILABLE else ""
|
|
1982
|
+
_ws_names = ", ".join(f"{w['name']} = {w['path']}" for w in _WS_ROOTS)
|
|
1983
|
+
_ws_tool_block = (
|
|
1984
|
+
f" WORKSPACES (local code folders you may READ and EDIT): {_ws_names}\n"
|
|
1985
|
+
" - list_dir(path) / read_text_file(path) — browse and read files.\n"
|
|
1986
|
+
" - grep_repo(pattern, path='', glob='') — search code, returns file:line hits. "
|
|
1987
|
+
"Use this to find the EXACT lines to change.\n"
|
|
1988
|
+
" - read_file_lines(path, start_line, end_line) — read WITH line numbers "
|
|
1989
|
+
"before editing.\n"
|
|
1990
|
+
" - edit_lines(path, start_line, end_line, new_content) — PRIMARY edit tool: "
|
|
1991
|
+
"replace a line range (backup saved automatically).\n"
|
|
1992
|
+
" - edit_file(path, old_string, new_string) — replace one exact unique string "
|
|
1993
|
+
"(must match byte-for-byte; prefer edit_lines).\n"
|
|
1994
|
+
" - create_file(path, content) — create a NEW file.\n"
|
|
1995
|
+
" - run_command(command, workdir='') — run a build/test command (npm test, mvn "
|
|
1996
|
+
"test, pytest…) to VERIFY changes (only in workspaces with run permission).\n"
|
|
1997
|
+
" NOTE: plain `import os`/`open()` is blocked — use ONLY these tools for files.\n"
|
|
1998
|
+
) if _WS_AVAILABLE else ""
|
|
1999
|
+
_ws_choose_line = (
|
|
2000
|
+
"- user asks to FIX A BUG / MODIFY / ADD CODE in a workspace -> "
|
|
2001
|
+
"grep_repo(pattern) (and/or rag_search if indexed) to FIND the file, THEN "
|
|
2002
|
+
"read_file_lines(file, around the hit) to see exact lines, THEN "
|
|
2003
|
+
"edit_lines(file, start, end, new_content) to change them, THEN "
|
|
2004
|
+
"run_command('…test…') to VERIFY if run permission exists — read failures and fix, "
|
|
2005
|
+
"iterate. Finish with final_answer summarizing WHAT you changed (files + lines). "
|
|
2006
|
+
"AFTER editing, re-read with grep_repo/read_file_lines (an S3 knowledge base may be "
|
|
2007
|
+
"stale — do not trust rag_search for freshly edited code).\n"
|
|
2008
|
+
) if _WS_AVAILABLE else ""
|
|
1841
2009
|
_pdf_read_tool_line = (
|
|
1842
2010
|
f" {_pdf_num}. extract_text_from_pdf(url) — READ/summarize an EXISTING PDF file "
|
|
1843
2011
|
"at a URL. This does NOT create a PDF (use create_pdf for that).\n"
|
|
@@ -1874,7 +2042,7 @@ def run_agent_chat(cfg):
|
|
|
1874
2042
|
"Use this ONLY when the task explicitly asks for the date or time.\n"
|
|
1875
2043
|
" 6. create_pdf(text, title='') — make a PDF document from text/data and return a "
|
|
1876
2044
|
"download link. Use ONLY when the task asks to create/make/generate/export a PDF.\n"
|
|
1877
|
-
+ _pdf_read_tool_line + _email_tool_line + _rag_tool_block +
|
|
2045
|
+
+ _pdf_read_tool_line + _email_tool_line + _rag_tool_block + _ws_tool_block +
|
|
1878
2046
|
"\n"
|
|
1879
2047
|
"http_request RETURN FORMAT: 'HTTP 200\\n{body}' — first line is 'HTTP <code>', body follows.\n"
|
|
1880
2048
|
"\n"
|
|
@@ -1898,7 +2066,7 @@ def run_agent_chat(cfg):
|
|
|
1898
2066
|
"- a PDF of info you must LOOK UP first (e.g. 'get the world cup schedule then make a "
|
|
1899
2067
|
"pdf') -> FIRST web_search/fetch_url to gather the real facts, THEN in a later step "
|
|
1900
2068
|
"create_pdf(text=<the gathered facts>, title=...). Never PDF the request wording itself.\n"
|
|
1901
|
-
+ _pdf_read_choose_line + _email_choose_line + _rag_choose_line +
|
|
2069
|
+
+ _pdf_read_choose_line + _email_choose_line + _rag_choose_line + _ws_choose_line +
|
|
1902
2070
|
"- A specific known API/URL was given -> http_request().\n"
|
|
1903
2071
|
"\n"
|
|
1904
2072
|
"RULES:\n"
|
|
@@ -2998,6 +3166,198 @@ def run_agent_chat(cfg):
|
|
|
2998
3166
|
except Exception as e: # noqa: BLE001
|
|
2999
3167
|
return f"Error: rag_search failed: {type(e).__name__}: {e}"
|
|
3000
3168
|
|
|
3169
|
+
@tool
|
|
3170
|
+
def grep_repo(pattern: str, path: str = "", glob: str = "") -> str:
|
|
3171
|
+
"""Search text files for a pattern (regex or literal) and return file:line hits. Use to find the EXACT lines to edit after rag_search located the relevant area.
|
|
3172
|
+
|
|
3173
|
+
Args:
|
|
3174
|
+
pattern: regex (or literal text) to search for.
|
|
3175
|
+
path: directory to search (a workspace or unzipped folder). Empty = first registered workspace.
|
|
3176
|
+
glob: optional filename filter, e.g. "*.java" or "*Controller*".
|
|
3177
|
+
"""
|
|
3178
|
+
try:
|
|
3179
|
+
import fnmatch
|
|
3180
|
+
import os
|
|
3181
|
+
root = _rag_read_allowed((path or "").strip() or (_WS_ROOTS[0]["path"] if _WS_ROOTS else "."))
|
|
3182
|
+
try:
|
|
3183
|
+
rx = re.compile(pattern)
|
|
3184
|
+
except re.error:
|
|
3185
|
+
rx = re.compile(re.escape(pattern))
|
|
3186
|
+
hits = []
|
|
3187
|
+
for abs_path, rel, _ext in _rag_iter_text_files(root):
|
|
3188
|
+
if glob and not fnmatch.fnmatch(os.path.basename(rel), glob) \
|
|
3189
|
+
and not fnmatch.fnmatch(rel, glob):
|
|
3190
|
+
continue
|
|
3191
|
+
try:
|
|
3192
|
+
with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
|
|
3193
|
+
for i, line in enumerate(fh, 1):
|
|
3194
|
+
if rx.search(line):
|
|
3195
|
+
hits.append(f"{rel}:{i}: {line.rstrip()[:200]}")
|
|
3196
|
+
if len(hits) >= 100:
|
|
3197
|
+
break
|
|
3198
|
+
except OSError:
|
|
3199
|
+
continue
|
|
3200
|
+
if len(hits) >= 100:
|
|
3201
|
+
break
|
|
3202
|
+
_emit({"type": "step", "text": f"Searching code → {pattern[:60]} ({len(hits)} hits)"})
|
|
3203
|
+
if not hits:
|
|
3204
|
+
return f"No matches for {pattern!r} under {root}."
|
|
3205
|
+
more = "\n…(capped at 100 hits)" if len(hits) >= 100 else ""
|
|
3206
|
+
return f"Matches under {root}:\n" + "\n".join(hits) + more
|
|
3207
|
+
except Exception as e: # noqa: BLE001
|
|
3208
|
+
return f"Error: grep_repo failed: {type(e).__name__}: {e}"
|
|
3209
|
+
|
|
3210
|
+
@tool
|
|
3211
|
+
def read_file_lines(path: str, start_line: int = 1, end_line: int = 0) -> str:
|
|
3212
|
+
"""Read a file with LINE NUMBERS (for locating code before edit_lines). end_line 0 = to end (capped).
|
|
3213
|
+
|
|
3214
|
+
Args:
|
|
3215
|
+
path: local file path.
|
|
3216
|
+
start_line: first line to show (1-based).
|
|
3217
|
+
end_line: last line to show inclusive; 0 = rest of file (max 400 lines shown).
|
|
3218
|
+
"""
|
|
3219
|
+
try:
|
|
3220
|
+
rp = _rag_read_allowed((path or "").strip())
|
|
3221
|
+
with open(rp, "r", encoding="utf-8", errors="replace") as fh:
|
|
3222
|
+
lines = fh.readlines()
|
|
3223
|
+
s = max(1, int(start_line or 1))
|
|
3224
|
+
e = int(end_line or 0) or len(lines)
|
|
3225
|
+
e = min(e, len(lines), s + 399)
|
|
3226
|
+
body = "".join(f"{i}: {lines[i - 1]}" for i in range(s, e + 1))
|
|
3227
|
+
return f"{rp} (lines {s}-{e} of {len(lines)}):\n{body}"
|
|
3228
|
+
except Exception as ex: # noqa: BLE001
|
|
3229
|
+
return f"Error: read_file_lines failed: {type(ex).__name__}: {ex}"
|
|
3230
|
+
|
|
3231
|
+
@tool
|
|
3232
|
+
def edit_lines(path: str, start_line: int, end_line: int, new_content: str) -> str:
|
|
3233
|
+
"""Replace lines start_line..end_line (inclusive, 1-based) of a workspace file with new_content. The PRIMARY edit tool — get exact line numbers from grep_repo/read_file_lines first. A backup is saved automatically.
|
|
3234
|
+
|
|
3235
|
+
Args:
|
|
3236
|
+
path: file inside a registered workspace.
|
|
3237
|
+
start_line: first line to replace (1-based).
|
|
3238
|
+
end_line: last line to replace (inclusive).
|
|
3239
|
+
new_content: replacement text (may be more or fewer lines).
|
|
3240
|
+
"""
|
|
3241
|
+
try:
|
|
3242
|
+
rp = _ws_write_allowed((path or "").strip())
|
|
3243
|
+
import os
|
|
3244
|
+
if not os.path.isfile(rp):
|
|
3245
|
+
return f"Error: not a file: {path}"
|
|
3246
|
+
backup = _ws_snapshot(rp)
|
|
3247
|
+
with open(rp, "r", encoding="utf-8", errors="replace") as fh:
|
|
3248
|
+
lines = fh.readlines()
|
|
3249
|
+
s, e = int(start_line), int(end_line)
|
|
3250
|
+
if not (1 <= s <= e <= len(lines)):
|
|
3251
|
+
return (f"Error: line range {s}-{e} is out of bounds "
|
|
3252
|
+
f"(file has {len(lines)} lines). Re-check with read_file_lines.")
|
|
3253
|
+
new_lines = new_content.splitlines(keepends=True)
|
|
3254
|
+
if new_lines and not new_lines[-1].endswith("\n"):
|
|
3255
|
+
new_lines[-1] += "\n"
|
|
3256
|
+
lines[s - 1:e] = new_lines
|
|
3257
|
+
with open(rp, "w", encoding="utf-8") as fh:
|
|
3258
|
+
fh.writelines(lines)
|
|
3259
|
+
_ws_record_change("edit", rp, backup)
|
|
3260
|
+
_emit({"type": "step", "text": f"Edited {os.path.basename(rp)} lines {s}-{e}"})
|
|
3261
|
+
shown = "".join(f"{i}: {lines[i - 1]}" for i in
|
|
3262
|
+
range(max(1, s - 2), min(len(lines), s + len(new_lines) + 1) + 1))
|
|
3263
|
+
return (f"Replaced lines {s}-{e} of {rp} with {len(new_lines)} line(s). "
|
|
3264
|
+
f"Backup saved. Updated region:\n{shown}")
|
|
3265
|
+
except Exception as ex: # noqa: BLE001
|
|
3266
|
+
return f"Error: edit_lines failed: {type(ex).__name__}: {ex}"
|
|
3267
|
+
|
|
3268
|
+
@tool
|
|
3269
|
+
def edit_file(path: str, old_string: str, new_string: str) -> str:
|
|
3270
|
+
"""Replace an EXACT unique text occurrence in a workspace file (must match byte-for-byte incl. whitespace). Prefer edit_lines when you have line numbers. A backup is saved automatically.
|
|
3271
|
+
|
|
3272
|
+
Args:
|
|
3273
|
+
path: file inside a registered workspace.
|
|
3274
|
+
old_string: exact existing text to replace (must occur exactly once).
|
|
3275
|
+
new_string: replacement text.
|
|
3276
|
+
"""
|
|
3277
|
+
try:
|
|
3278
|
+
rp = _ws_write_allowed((path or "").strip())
|
|
3279
|
+
import os
|
|
3280
|
+
if not os.path.isfile(rp):
|
|
3281
|
+
return f"Error: not a file: {path}"
|
|
3282
|
+
with open(rp, "r", encoding="utf-8", errors="replace") as fh:
|
|
3283
|
+
text = fh.read()
|
|
3284
|
+
n = text.count(old_string)
|
|
3285
|
+
if n == 0:
|
|
3286
|
+
return ("Error: old_string not found — it must match EXACTLY "
|
|
3287
|
+
"(check whitespace with read_file_lines, or use edit_lines).")
|
|
3288
|
+
if n > 1:
|
|
3289
|
+
return f"Error: old_string occurs {n} times — add surrounding context to make it unique, or use edit_lines."
|
|
3290
|
+
backup = _ws_snapshot(rp)
|
|
3291
|
+
with open(rp, "w", encoding="utf-8") as fh:
|
|
3292
|
+
fh.write(text.replace(old_string, new_string, 1))
|
|
3293
|
+
_ws_record_change("edit", rp, backup)
|
|
3294
|
+
_emit({"type": "step", "text": f"Edited {os.path.basename(rp)}"})
|
|
3295
|
+
return f"Replaced 1 occurrence in {rp}. Backup saved."
|
|
3296
|
+
except Exception as ex: # noqa: BLE001
|
|
3297
|
+
return f"Error: edit_file failed: {type(ex).__name__}: {ex}"
|
|
3298
|
+
|
|
3299
|
+
@tool
|
|
3300
|
+
def create_file(path: str, content: str) -> str:
|
|
3301
|
+
"""Create a NEW file inside a registered workspace (errors if it already exists — use edit tools for existing files).
|
|
3302
|
+
|
|
3303
|
+
Args:
|
|
3304
|
+
path: new file path inside a workspace.
|
|
3305
|
+
content: full file content.
|
|
3306
|
+
"""
|
|
3307
|
+
try:
|
|
3308
|
+
rp = _ws_write_allowed((path or "").strip())
|
|
3309
|
+
import os
|
|
3310
|
+
if os.path.exists(rp):
|
|
3311
|
+
return f"Error: {path} already exists — use edit_lines/edit_file to change it."
|
|
3312
|
+
os.makedirs(os.path.dirname(rp) or ".", exist_ok=True)
|
|
3313
|
+
with open(rp, "w", encoding="utf-8") as fh:
|
|
3314
|
+
fh.write(content)
|
|
3315
|
+
_ws_record_change("create", rp, None)
|
|
3316
|
+
_emit({"type": "step", "text": f"Created {os.path.basename(rp)}"})
|
|
3317
|
+
return f"Created {rp} ({len(content)} chars)."
|
|
3318
|
+
except Exception as ex: # noqa: BLE001
|
|
3319
|
+
return f"Error: create_file failed: {type(ex).__name__}: {ex}"
|
|
3320
|
+
|
|
3321
|
+
@tool
|
|
3322
|
+
def run_command(command: str, workdir: str = "", timeout_seconds: int = 180) -> str:
|
|
3323
|
+
"""Run a build/test command inside a workspace that has run permission (e.g. 'npm test', 'mvn test', 'pytest'). Use to VERIFY code changes; read the output and fix failures.
|
|
3324
|
+
|
|
3325
|
+
Args:
|
|
3326
|
+
command: the command line (build/test runners only; no shells or sudo).
|
|
3327
|
+
workdir: directory to run in. Empty = first registered workspace.
|
|
3328
|
+
timeout_seconds: kill the command after this many seconds (max 600).
|
|
3329
|
+
"""
|
|
3330
|
+
try:
|
|
3331
|
+
import os
|
|
3332
|
+
import shlex
|
|
3333
|
+
import subprocess
|
|
3334
|
+
wd = _rag_read_allowed((workdir or "").strip() or (_WS_ROOTS[0]["path"] if _WS_ROOTS else "."))
|
|
3335
|
+
w = _ws_for_path(wd)
|
|
3336
|
+
if w is None or not w.get("allowRun"):
|
|
3337
|
+
return ("Error: running commands is not enabled for this workspace. "
|
|
3338
|
+
"Re-register it with: gonext-local-worker workspace add <path> --allow-run")
|
|
3339
|
+
argv = shlex.split(command or "")
|
|
3340
|
+
if not argv:
|
|
3341
|
+
return "Error: empty command."
|
|
3342
|
+
if argv[0] not in _WS_RUN_ALLOWED:
|
|
3343
|
+
return (f"Error: '{argv[0]}' is not an allowed runner. Allowed: "
|
|
3344
|
+
+ ", ".join(sorted(_WS_RUN_ALLOWED)))
|
|
3345
|
+
_emit({"type": "step", "text": f"Running → {command[:70]}"})
|
|
3346
|
+
t = max(5, min(int(timeout_seconds or 180), 600))
|
|
3347
|
+
# Scrubbed env: repo test scripts must never see worker secrets.
|
|
3348
|
+
proc = subprocess.run(
|
|
3349
|
+
argv, cwd=wd, env=_ws_scrubbed_env(), timeout=t,
|
|
3350
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
|
3351
|
+
)
|
|
3352
|
+
out = _ws_distill_output(proc.stdout or "")
|
|
3353
|
+
status = "PASSED (exit 0)" if proc.returncode == 0 else f"FAILED (exit {proc.returncode})"
|
|
3354
|
+
_emit({"type": "step", "text": f"Command {status.split()[0].lower()} → {command[:50]}"})
|
|
3355
|
+
return f"{status}\n{out}" if out.strip() else status
|
|
3356
|
+
except subprocess.TimeoutExpired:
|
|
3357
|
+
return f"Error: command timed out after {timeout_seconds}s."
|
|
3358
|
+
except Exception as ex: # noqa: BLE001
|
|
3359
|
+
return f"Error: run_command failed: {type(ex).__name__}: {ex}"
|
|
3360
|
+
|
|
3001
3361
|
agent_tools = [http_request, web_search, fetch_url, calculate,
|
|
3002
3362
|
get_current_datetime, create_pdf]
|
|
3003
3363
|
# Only register the PDF reader / email / RAG tools when available (kept in sync
|
|
@@ -3009,6 +3369,13 @@ def run_agent_chat(cfg):
|
|
|
3009
3369
|
if _RAG_AVAILABLE:
|
|
3010
3370
|
agent_tools += [download_file, unzip_file, list_dir, read_text_file,
|
|
3011
3371
|
rag_index, rag_add, rag_search]
|
|
3372
|
+
if _WS_AVAILABLE:
|
|
3373
|
+
agent_tools += [grep_repo, read_file_lines, edit_lines, edit_file,
|
|
3374
|
+
create_file, run_command]
|
|
3375
|
+
# list_dir/read_text_file are needed for workspace browsing even when RAG
|
|
3376
|
+
# (S3 indexing) isn't configured.
|
|
3377
|
+
if not _RAG_AVAILABLE:
|
|
3378
|
+
agent_tools += [list_dir, read_text_file]
|
|
3012
3379
|
agent_kwargs = dict(
|
|
3013
3380
|
tools=agent_tools,
|
|
3014
3381
|
model=model,
|
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",
|