@tiens.nguyen/gonext-local-worker 1.0.177 → 1.0.179

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.
@@ -1799,6 +1799,9 @@ async function runAgentChatJob(job) {
1799
1799
  return Array.isArray(parsed?.workspaces) ? parsed.workspaces : [];
1800
1800
  })
1801
1801
  .catch(() => []),
1802
+ // The `gonext` terminal REPL's launch folder — download_file/unzip_file output
1803
+ // lands here (must be a registered workspace; the python side re-validates).
1804
+ activeWorkspace: payload?.activeWorkspace ?? "",
1802
1805
  });
1803
1806
  // 30 min max for an agent run: multi-step ReAct on a 14B/31B with a cold prompt
1804
1807
  // cache — or a remote Ollama box on slow GPU cold-loading a big model — can run
package/gonext-repl.mjs CHANGED
@@ -199,7 +199,9 @@ async function runAgentTurn(history) {
199
199
  const res = await fetch(`${apiBase}/api/worker/agent-ask`, {
200
200
  method: "POST",
201
201
  headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
202
- body: JSON.stringify({ messages: history }),
202
+ // cwd = this terminal's folder → agent puts download/unzip output here (when it's
203
+ // a registered workspace).
204
+ body: JSON.stringify({ messages: history, cwd: resolve(process.cwd()) }),
203
205
  });
204
206
  const data = await res.json().catch(() => ({}));
205
207
  if (!res.ok) {
@@ -37,6 +37,33 @@ import urllib.error
37
37
  _REAL_STDOUT = sys.stdout
38
38
 
39
39
 
40
+ # Playful "still thinking" status words shown on the heartbeat while a model call is
41
+ # in flight (see _heartbeat). Loaded once from thinking_words.txt next to this file;
42
+ # a random one is picked per tick so the wait feels alive instead of a fixed string.
43
+ _THINKING_WORDS: list = []
44
+
45
+
46
+ def _thinking_word() -> str:
47
+ """A random playful status word (e.g. 'Caffeinating'). Falls back to a small
48
+ built-in list if thinking_words.txt is missing or empty."""
49
+ import os
50
+ import random
51
+ global _THINKING_WORDS
52
+ if not _THINKING_WORDS:
53
+ try:
54
+ path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "thinking_words.txt")
55
+ with open(path, "r", encoding="utf-8") as fh:
56
+ _THINKING_WORDS = [
57
+ line.strip() for line in fh
58
+ if line.strip() and not line.lstrip().startswith("#")
59
+ ]
60
+ except OSError:
61
+ _THINKING_WORDS = []
62
+ if not _THINKING_WORDS:
63
+ _THINKING_WORDS = ["Thinking", "Percolating", "Caffeinating", "Cogitating"]
64
+ return random.choice(_THINKING_WORDS)
65
+
66
+
40
67
  def _ssl_context():
41
68
  import ssl
42
69
  # Disable cert verification — this agent runs locally against dev tunnels
@@ -1158,6 +1185,17 @@ def _rag_workdir(source_key: str) -> str:
1158
1185
  return base
1159
1186
 
1160
1187
 
1188
+ def _rag_base_dir(source_key: str) -> str:
1189
+ """Where a URL's downloaded/unzipped files should live. Prefer the active terminal
1190
+ workspace (the folder the `gonext` REPL was opened from) so files land in the
1191
+ user's project; otherwise fall back to the per-URL cache dir under ~/.gonext."""
1192
+ import os
1193
+ if _WS_ACTIVE:
1194
+ os.makedirs(_WS_ACTIVE, exist_ok=True)
1195
+ return _WS_ACTIVE
1196
+ return _rag_workdir(source_key)
1197
+
1198
+
1161
1199
  def _rag_download(url: str, dest_path: str = "") -> tuple:
1162
1200
  """Download a public URL with SSRF + size guards — IDEMPOTENT per URL. Every URL
1163
1201
  maps to a deterministic work dir (~/.gonext/rag-work/<urlHash>/); relative
@@ -1168,7 +1206,7 @@ def _rag_download(url: str, dest_path: str = "") -> tuple:
1168
1206
  src = _rag_gdrive_direct((url or "").strip())
1169
1207
  _rag_assert_safe_url(src)
1170
1208
  key = _rag_source_key(url)
1171
- workdir = _rag_workdir(key)
1209
+ workdir = _rag_base_dir(key)
1172
1210
  dest_path = os.path.expanduser((dest_path or "").strip())
1173
1211
  if not dest_path:
1174
1212
  name = os.path.basename(src.split("?")[0]) or "download.bin"
@@ -1176,21 +1214,23 @@ def _rag_download(url: str, dest_path: str = "") -> tuple:
1176
1214
  name += ".zip"
1177
1215
  dest_path = os.path.join(workdir, name)
1178
1216
  elif not os.path.isabs(dest_path):
1179
- # Contain relative paths in the per-URL work dir — models pass bare names like
1217
+ # Contain relative paths in the work dir — models pass bare names like
1180
1218
  # "project.zip" which would otherwise land in the worker's cwd (the home dir).
1181
1219
  dest_path = os.path.join(workdir, dest_path)
1182
1220
  # Cache hit 1: the exact target already exists.
1183
1221
  if os.path.isfile(dest_path) and os.path.getsize(dest_path) > 0:
1184
1222
  return dest_path, True
1185
- # Cache hit 2: this URL was downloaded before under a different name — the work dir
1186
- # is per-URL, so any archive already in it IS this URL's file.
1187
- try:
1188
- for fn in sorted(os.listdir(workdir)):
1189
- p = os.path.join(workdir, fn)
1190
- if os.path.isfile(p) and fn.lower().endswith(".zip") and os.path.getsize(p) > 0:
1191
- return p, True
1192
- except OSError:
1193
- pass
1223
+ # Cache hit 2: this URL was downloaded before under a different name — ONLY safe in
1224
+ # the per-URL cache dir (one URL per dir). In an active workspace the folder holds
1225
+ # many unrelated files, so a bare "any zip = this URL" match would be wrong.
1226
+ if not _WS_ACTIVE:
1227
+ try:
1228
+ for fn in sorted(os.listdir(workdir)):
1229
+ p = os.path.join(workdir, fn)
1230
+ if os.path.isfile(p) and fn.lower().endswith(".zip") and os.path.getsize(p) > 0:
1231
+ return p, True
1232
+ except OSError:
1233
+ pass
1194
1234
  os.makedirs(os.path.dirname(dest_path) or ".", exist_ok=True)
1195
1235
  req = urllib.request.Request(src, headers={"User-Agent": "gonext-rag/1.0"})
1196
1236
  total = 0
@@ -1220,8 +1260,15 @@ def _rag_resolve_zip(zip_path: str) -> str:
1220
1260
  if os.path.isfile(zp):
1221
1261
  return zp
1222
1262
  if not os.path.isabs(zp):
1263
+ base = os.path.basename(zp)
1264
+ # Look in the active terminal workspace first (that's where downloads land now),
1265
+ # then fall back to the per-URL cache dirs.
1266
+ if _WS_ACTIVE:
1267
+ cand = os.path.join(_WS_ACTIVE, base)
1268
+ if os.path.isfile(cand):
1269
+ return cand
1223
1270
  hits = _glob.glob(os.path.join(
1224
- os.path.expanduser("~"), ".gonext", "rag-work", "*", os.path.basename(zp)))
1271
+ os.path.expanduser("~"), ".gonext", "rag-work", "*", base))
1225
1272
  if hits:
1226
1273
  return hits[0]
1227
1274
  return zp
@@ -1424,6 +1471,12 @@ def _rag_parse_location(loc: str):
1424
1471
  # require _ws_write_allowed (workspace root + not inside .git/).
1425
1472
  _WS_ROOTS: list = [] # [{"name": str, "path": realpath str, "allowRun": bool}]
1426
1473
 
1474
+ # The active terminal workspace: the folder the `gonext` REPL was launched from
1475
+ # (passed through as payload.activeWorkspace). When set, download_file/unzip_file put
1476
+ # their output HERE instead of the shared ~/.gonext/rag-work cache, so files land in
1477
+ # the user's actual project folder. Only honoured if it's a registered workspace root.
1478
+ _WS_ACTIVE: str = ""
1479
+
1427
1480
 
1428
1481
  def _rag_read_allowed(path: str) -> str:
1429
1482
  """Resolve `path` and ensure it stays within a safe root (worker cwd, temp dir,
@@ -1437,6 +1490,10 @@ def _rag_read_allowed(path: str) -> str:
1437
1490
  os.path.realpath(tempfile.gettempdir()),
1438
1491
  os.path.realpath(os.path.join(os.path.expanduser("~"), ".gonext")),
1439
1492
  ] + [w["path"] for w in _WS_ROOTS]
1493
+ # The active terminal folder (download/unzip target) — allow reading what we just
1494
+ # extracted there, even when it isn't a registered workspace.
1495
+ if _WS_ACTIVE:
1496
+ roots.append(_WS_ACTIVE)
1440
1497
  if any(rp == r or rp.startswith(r + os.sep) for r in roots):
1441
1498
  return rp
1442
1499
  raise ValueError(
@@ -1741,7 +1798,7 @@ def run_agent_chat(cfg):
1741
1798
  # Registered on the Mac via `gonext-local-worker workspace add <path>`; the worker
1742
1799
  # passes the registry through cfg. Only these roots are writable — the security
1743
1800
  # boundary for the coding tools.
1744
- global _WS_ROOTS
1801
+ global _WS_ROOTS, _WS_ACTIVE
1745
1802
  _WS_ROOTS = []
1746
1803
  for w in (cfg.get("workspaces") or []):
1747
1804
  try:
@@ -1756,6 +1813,31 @@ def run_agent_chat(cfg):
1756
1813
  except Exception: # noqa: BLE001
1757
1814
  continue
1758
1815
  _WS_AVAILABLE = bool(_WS_ROOTS)
1816
+ # Active terminal workspace: the folder the `gonext` REPL was launched from (sent as
1817
+ # payload.activeWorkspace = the terminal's cwd). download_file / unzip_file put their
1818
+ # output HERE instead of the shared ~/.gonext/rag-work cache, so files land where the
1819
+ # user opened the terminal. Honoured for any real directory the user explicitly opened
1820
+ # the terminal in — these tools already write outside registered workspaces (to
1821
+ # ~/.gonext), so this is not a new write boundary; the code-EDIT tools stay gated on
1822
+ # _WS_ROOTS (registered workspaces) via _ws_write_allowed, unchanged.
1823
+ _WS_ACTIVE = ""
1824
+ _aw_raw = str(cfg.get("activeWorkspace") or "").strip()
1825
+ if _aw_raw:
1826
+ try:
1827
+ import os as _os
1828
+ aw = _os.path.realpath(_os.path.expanduser(_aw_raw))
1829
+ if _os.path.isdir(aw):
1830
+ _WS_ACTIVE = aw
1831
+ registered = any(
1832
+ aw == w["path"] or aw.startswith(w["path"] + _os.sep) for w in _WS_ROOTS
1833
+ )
1834
+ _log(f"active workspace: {aw} (download/unzip output → here"
1835
+ f"{'' if registered else '; not a registered workspace — reads/writes here limited to download/unzip'})")
1836
+ else:
1837
+ _log(f"active workspace ignored (not a directory): {aw}")
1838
+ except Exception as _e: # noqa: BLE001
1839
+ _WS_ACTIVE = ""
1840
+ _log(f"active workspace ignored (error resolving '{_aw_raw}'): {_e}")
1759
1841
  if _WS_AVAILABLE:
1760
1842
  _log(f"workspaces: {[(w['name'], w['path'], w['allowRun']) for w in _WS_ROOTS]}")
1761
1843
  # Coding tasks are edit→test→fix loops — 5 steps is too tight. Bump the default
@@ -2726,9 +2808,11 @@ def run_agent_chat(cfg):
2726
2808
  waited = 0
2727
2809
  while not hb_stop.wait(45):
2728
2810
  waited += 45
2811
+ # A random playful word each tick (e.g. "Caffeinating… (90s)") instead
2812
+ # of a fixed "still working" line — a large or cold-loading model can
2813
+ # legitimately take a few minutes on prompt-eval.
2729
2814
  _emit({"type": "step",
2730
- "text": f"Model is still working… ({waited}s — a large or "
2731
- "cold-loading model can take a few minutes)"})
2815
+ "text": f"{_thinking_word()}… ({waited}s)"})
2732
2816
  _log(f"heartbeat: model call in flight {waited}s")
2733
2817
 
2734
2818
  hb = threading.Thread(target=_heartbeat, daemon=True)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.177",
3
+ "version": "1.0.179",
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",
@@ -28,6 +28,7 @@
28
28
  "gonext_agent_chat.py",
29
29
  "gonext_transcribe.py",
30
30
  "gonext_mlx_embed.py",
31
+ "thinking_words.txt",
31
32
  "README.md",
32
33
  "launchd/"
33
34
  ],
@@ -0,0 +1,106 @@
1
+ # Playful "still thinking / still working" status words for the agent heartbeat.
2
+ # One word/phrase per line; blank lines and lines starting with # are ignored.
3
+ # The worker appends "…" and the elapsed seconds, e.g. "Caffeinating… (90s)".
4
+ Collating
5
+ Caffeinating
6
+ Percolating
7
+ Marinating
8
+ Ruminating
9
+ Cogitating
10
+ Noodling
11
+ Pondering
12
+ Contemplating
13
+ Mulling
14
+ Brewing
15
+ Simmering
16
+ Steeping
17
+ Distilling
18
+ Fermenting
19
+ Incubating
20
+ Marshalling
21
+ Wrangling
22
+ Herding electrons
23
+ Untangling
24
+ Unknotting
25
+ Defragmenting
26
+ Reticulating splines
27
+ Compiling thoughts
28
+ Buffering brilliance
29
+ Warming up the neurons
30
+ Spinning up
31
+ Booting the big brain
32
+ Consulting the oracle
33
+ Summoning tokens
34
+ Conjuring
35
+ Divining
36
+ Calibrating
37
+ Recalibrating
38
+ Triangulating
39
+ Extrapolating
40
+ Interpolating
41
+ Synthesizing
42
+ Crystallizing
43
+ Coalescing
44
+ Assembling
45
+ Stitching
46
+ Weaving
47
+ Knitting
48
+ Threading the needle
49
+ Connecting the dots
50
+ Chasing the thread
51
+ Following the breadcrumbs
52
+ Sifting
53
+ Sieving
54
+ Panning for gold
55
+ Mining
56
+ Excavating
57
+ Spelunking
58
+ Deep-diving
59
+ Surfacing insights
60
+ Percolating harder
61
+ Steeping the tea
62
+ Kneading the dough
63
+ Proofing the loaf
64
+ Letting it rise
65
+ Whisking
66
+ Folding in the facts
67
+ Reducing the sauce
68
+ Plating up
69
+ Garnishing
70
+ Sharpening the pencils
71
+ Oiling the gears
72
+ Greasing the cogs
73
+ Winding the springs
74
+ Charging the flux capacitor
75
+ Aligning the stars
76
+ Consulting the tea leaves
77
+ Reading the room
78
+ Gathering wool
79
+ Chewing it over
80
+ Sleeping on it
81
+ Doing the math
82
+ Carrying the one
83
+ Crunching numbers
84
+ Tallying
85
+ Counting sheep
86
+ Herding cats
87
+ Nudging pixels
88
+ Polishing
89
+ Buffing
90
+ Tuning the antenna
91
+ Finding the signal
92
+ Clearing the static
93
+ Untwisting
94
+ Loosening the knot
95
+ Priming the pump
96
+ Stoking the furnace
97
+ Kindling
98
+ Fanning the embers
99
+ Gathering steam
100
+ Building momentum
101
+ Hatching a plan
102
+ Plotting a course
103
+ Charting the map
104
+ Threading the maze
105
+ Almost there
106
+ Nearly cooked