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

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.
@@ -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
- maxSteps: payload?.maxSteps ?? 5,
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}`, {
@@ -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, or
1422
- ~/.gonext) — so the agent's file tools can't read arbitrary files like ~/.ssh."""
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 only)."
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.167",
3
+ "version": "1.0.170",
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",