@tiens.nguyen/gonext-local-worker 1.0.160 → 1.0.164

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/gonext_agent_chat.py +118 -12
  2. package/package.json +1 -1
@@ -1156,17 +1156,39 @@ def _rag_workdir(source_key: str) -> str:
1156
1156
  return base
1157
1157
 
1158
1158
 
1159
- def _rag_download(url: str, dest_path: str = "") -> str:
1160
- """Download a public URL to a local path with SSRF + size guards. Returns the path."""
1159
+ def _rag_download(url: str, dest_path: str = "") -> tuple:
1160
+ """Download a public URL with SSRF + size guards IDEMPOTENT per URL. Every URL
1161
+ maps to a deterministic work dir (~/.gonext/rag-work/<urlHash>/); relative
1162
+ dest_paths are contained there (never the process cwd, which is the user's home),
1163
+ and a repeat call for an already-downloaded URL reuses the cached file instantly
1164
+ (follow-up questions re-run the pipeline cheaply). Returns (path, cached)."""
1161
1165
  import os
1162
1166
  src = _rag_gdrive_direct((url or "").strip())
1163
1167
  _rag_assert_safe_url(src)
1164
1168
  key = _rag_source_key(url)
1169
+ workdir = _rag_workdir(key)
1170
+ dest_path = os.path.expanduser((dest_path or "").strip())
1165
1171
  if not dest_path:
1166
1172
  name = os.path.basename(src.split("?")[0]) or "download.bin"
1167
1173
  if not name.lower().endswith(".zip") and "zip" in src.lower():
1168
1174
  name += ".zip"
1169
- dest_path = os.path.join(_rag_workdir(key), name)
1175
+ dest_path = os.path.join(workdir, name)
1176
+ elif not os.path.isabs(dest_path):
1177
+ # Contain relative paths in the per-URL work dir — models pass bare names like
1178
+ # "project.zip" which would otherwise land in the worker's cwd (the home dir).
1179
+ dest_path = os.path.join(workdir, dest_path)
1180
+ # Cache hit 1: the exact target already exists.
1181
+ if os.path.isfile(dest_path) and os.path.getsize(dest_path) > 0:
1182
+ return dest_path, True
1183
+ # Cache hit 2: this URL was downloaded before under a different name — the work dir
1184
+ # is per-URL, so any archive already in it IS this URL's file.
1185
+ try:
1186
+ for fn in sorted(os.listdir(workdir)):
1187
+ p = os.path.join(workdir, fn)
1188
+ if os.path.isfile(p) and fn.lower().endswith(".zip") and os.path.getsize(p) > 0:
1189
+ return p, True
1190
+ except OSError:
1191
+ pass
1170
1192
  os.makedirs(os.path.dirname(dest_path) or ".", exist_ok=True)
1171
1193
  req = urllib.request.Request(src, headers={"User-Agent": "gonext-rag/1.0"})
1172
1194
  total = 0
@@ -1184,15 +1206,48 @@ def _rag_download(url: str, dest_path: str = "") -> str:
1184
1206
  f"Download exceeds the {_RAG_MAX_DOWNLOAD_BYTES // (1024*1024)} MB cap."
1185
1207
  )
1186
1208
  out.write(block)
1187
- return dest_path
1209
+ return dest_path, False
1210
+
1211
+
1212
+ def _rag_resolve_zip(zip_path: str) -> str:
1213
+ """Resolve a (possibly relative) zip path. Bare names the model echoes from earlier
1214
+ turns are searched in the rag work dirs so a fresh process still finds them."""
1215
+ import glob as _glob
1216
+ import os
1217
+ zp = os.path.expanduser((zip_path or "").strip())
1218
+ if os.path.isfile(zp):
1219
+ return zp
1220
+ if not os.path.isabs(zp):
1221
+ hits = _glob.glob(os.path.join(
1222
+ os.path.expanduser("~"), ".gonext", "rag-work", "*", os.path.basename(zp)))
1223
+ if hits:
1224
+ return hits[0]
1225
+ return zp
1188
1226
 
1189
1227
 
1190
1228
  def _rag_unzip(zip_path: str, dest_dir: str = "") -> tuple:
1191
- """Extract a local zip with Zip-Slip + zip-bomb guards. Returns (dir, file_list)."""
1229
+ """Extract a local zip with Zip-Slip + zip-bomb guards — IDEMPOTENT. If the target
1230
+ dir already has extracted files, reuse it instead of re-extracting. Relative
1231
+ dest_dirs are placed next to the zip (inside the per-URL work dir), never the cwd.
1232
+ Returns (dir, file_list, cached)."""
1192
1233
  import os
1193
1234
  import zipfile
1235
+ zip_path = _rag_resolve_zip(zip_path)
1236
+ dest_dir = os.path.expanduser((dest_dir or "").strip())
1194
1237
  if not dest_dir:
1195
1238
  dest_dir = os.path.splitext(zip_path)[0] + "_unzipped"
1239
+ elif not os.path.isabs(dest_dir):
1240
+ dest_dir = os.path.join(os.path.dirname(zip_path) or ".", dest_dir)
1241
+ # Cache hit: already extracted for this zip → return the existing listing.
1242
+ if os.path.isdir(dest_dir):
1243
+ existing = []
1244
+ for dirpath, _dirnames, filenames in os.walk(dest_dir):
1245
+ for fn in filenames:
1246
+ existing.append(os.path.relpath(os.path.join(dirpath, fn), dest_dir))
1247
+ if len(existing) > _RAG_MAX_FILES:
1248
+ break
1249
+ if existing:
1250
+ return dest_dir, sorted(existing), True
1196
1251
  os.makedirs(dest_dir, exist_ok=True)
1197
1252
  dest_root = os.path.realpath(dest_dir)
1198
1253
  written = 0
@@ -1216,7 +1271,7 @@ def _rag_unzip(zip_path: str, dest_dir: str = "") -> tuple:
1216
1271
  dst.write(src.read())
1217
1272
  written += 1
1218
1273
  names.append(info.filename)
1219
- return dest_dir, names
1274
+ return dest_dir, names, False
1220
1275
 
1221
1276
 
1222
1277
  def _rag_iter_text_files(root: str):
@@ -1776,8 +1831,11 @@ def run_agent_chat(cfg):
1776
1831
  "THEN list_dir(dir) to find the README, THEN read_text_file(that README) THEN answer from it. "
1777
1832
  "This is the FASTEST path for a summary — do NOT index unless deep Q&A across many files is needed.\n"
1778
1833
  "- user wants deep Q&A across the WHOLE project -> download_file THEN unzip_file THEN "
1779
- "rag_index(dir, source_url=url) THEN rag_search(source_url=url, query=<question>) THEN answer. "
1780
- "For FOLLOW-UPs on the SAME url, just rag_search (do NOT re-download). "
1834
+ "rag_index(dir, source_url=url) THEN rag_search(source_url=url, query=<question>) THEN answer.\n"
1835
+ "- FOLLOW-UP question about a ZIP url from EARLIER in the conversation -> try "
1836
+ "rag_search(source_url=url, query=<question>) FIRST. If it says no knowledge base exists, "
1837
+ "run download_file(url) + unzip_file(zip) again — both are CACHED per url and return "
1838
+ "instantly — then list_dir/read_text_file or rag_index as needed. "
1781
1839
  "If the user adds information, rag_add(source_url=url, text=...).\n"
1782
1840
  ) if _RAG_AVAILABLE else ""
1783
1841
  _pdf_read_tool_line = (
@@ -2325,6 +2383,26 @@ def run_agent_chat(cfg):
2325
2383
  in_tok = out_tok = reasoning_len = 0
2326
2384
  first_token_at = None
2327
2385
 
2386
+ # Runaway guard: a degenerate model (seen live: gemma4:26b spewing
2387
+ # "<channel|><thought>" forever) will stream garbage until the client times
2388
+ # out — a multi-minute hang. Abort early if the output either (a) balloons
2389
+ # past a hard char cap, or (b) collapses into a repeating short line. On abort
2390
+ # we return "" so the empty-turn retry re-prompts with the CODE-NOW directive.
2391
+ _MAX_STREAM_CHARS = 16000 # a single agent step never legitimately needs this
2392
+ _stream_buf = [] # every streamed piece (content + reasoning)
2393
+ _stream_chars = 0
2394
+ _runaway = False
2395
+
2396
+ def _looks_runaway() -> bool:
2397
+ tail = "".join(_stream_buf)[-1600:]
2398
+ lines = [ln.strip() for ln in tail.splitlines() if ln.strip()]
2399
+ # 12+ of the last lines are the same short string → degenerate repetition.
2400
+ if len(lines) >= 12:
2401
+ last = lines[-12:]
2402
+ if len(set(last)) <= 2 and all(len(ln) <= 80 for ln in last):
2403
+ return True
2404
+ return False
2405
+
2328
2406
  def _mark_first():
2329
2407
  # First streamed byte (content OR reasoning). The gap t0→here is pure
2330
2408
  # prompt-eval time (server sent nothing before this); everything after is
@@ -2362,13 +2440,33 @@ def run_agent_chat(cfg):
2362
2440
  if rpiece:
2363
2441
  _mark_first()
2364
2442
  reasoning_len += len(rpiece)
2443
+ _stream_buf.append(rpiece)
2444
+ _stream_chars += len(rpiece)
2365
2445
  _emit({"type": "stream", "text": rpiece})
2366
2446
  piece = getattr(delta, "content", None)
2367
2447
  if piece:
2368
2448
  _mark_first()
2369
2449
  parts.append(piece)
2450
+ _stream_buf.append(piece)
2451
+ _stream_chars += len(piece)
2370
2452
  _emit({"type": "stream", "text": piece})
2453
+ # Check the runaway guard periodically (cheap) once enough has streamed.
2454
+ if _stream_chars > 500 and (_stream_chars > _MAX_STREAM_CHARS or _looks_runaway()):
2455
+ _runaway = True
2456
+ reason = "char cap" if _stream_chars > _MAX_STREAM_CHARS else "repeating output"
2457
+ _log(f"streamed generate: ABORTING ({reason}) after {_stream_chars} chars "
2458
+ "— model degenerated; closing stream.")
2459
+ try:
2460
+ stream.close()
2461
+ except Exception: # noqa: BLE001
2462
+ pass
2463
+ break
2371
2464
  content = "".join(parts)
2465
+ if _runaway:
2466
+ # Discard the garbage; "" makes _streamed_generate run the CODE-NOW retry.
2467
+ _emit({"type": "step", "text": "Model output ran away — restarting this step…"})
2468
+ _log(f"streamed generate: discarded {len(content)} runaway chars → empty content")
2469
+ return "", role, in_tok, out_tok, reasoning_len
2372
2470
  _log(f"streamed generate assembled {len(content)} chars "
2373
2471
  f"(in={in_tok} out={out_tok} tokens, reasoning={reasoning_len} chars)")
2374
2472
  return content, role, in_tok, out_tok, reasoning_len
@@ -2678,10 +2776,14 @@ def run_agent_chat(cfg):
2678
2776
  url: public http(s) URL of the file to download.
2679
2777
  dest_path: optional local save path; leave empty for an automatic work-dir path.
2680
2778
  """
2681
- _emit({"type": "step", "text": f"Downloading → {url[:80]}"})
2682
2779
  try:
2683
2780
  import os
2684
- path = _rag_download(url, (dest_path or "").strip())
2781
+ path, cached = _rag_download(url, (dest_path or "").strip())
2782
+ if cached:
2783
+ _emit({"type": "step", "text": "Already downloaded — reusing cached file"})
2784
+ return (f"Already downloaded earlier — reusing {path} "
2785
+ f"({os.path.getsize(path)} bytes). Next: unzip_file(zip_path='{path}').")
2786
+ _emit({"type": "step", "text": f"Downloading → {url[:80]}"})
2685
2787
  return f"Downloaded to {path} ({os.path.getsize(path)} bytes). Next: unzip_file(zip_path='{path}') if it is a zip."
2686
2788
  except Exception as e: # noqa: BLE001
2687
2789
  return f"Error: download failed: {type(e).__name__}: {e}"
@@ -2694,11 +2796,15 @@ def run_agent_chat(cfg):
2694
2796
  zip_path: local path to the .zip (from download_file).
2695
2797
  dest_dir: optional target directory; leave empty for an automatic one.
2696
2798
  """
2697
- _emit({"type": "step", "text": "Unzipping…"})
2698
2799
  try:
2699
- d, names = _rag_unzip((zip_path or "").strip(), (dest_dir or "").strip())
2800
+ d, names, cached = _rag_unzip((zip_path or "").strip(), (dest_dir or "").strip())
2700
2801
  preview = ", ".join(names[:15])
2701
2802
  more = f" …(+{len(names) - 15} more)" if len(names) > 15 else ""
2803
+ if cached:
2804
+ _emit({"type": "step", "text": "Already unzipped — reusing extracted files"})
2805
+ return (f"Already unzipped earlier — {len(names)} files at {d}. "
2806
+ f"Files: {preview}{more}. Use list_dir/read_text_file or rag_index on it.")
2807
+ _emit({"type": "step", "text": "Unzipping…"})
2702
2808
  return f"Unzipped {len(names)} files to {d}. Files: {preview}{more}. Next: rag_index(path='{d}', source_url=<the original url>)."
2703
2809
  except Exception as e: # noqa: BLE001
2704
2810
  return f"Error: unzip failed: {type(e).__name__}: {e}"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.160",
3
+ "version": "1.0.164",
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",