@tiens.nguyen/gonext-local-worker 1.0.274 → 1.0.276

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.
@@ -1893,6 +1893,9 @@ async function runAgentChatJob(job) {
1893
1893
  agentModelId: payload?.agentModelId ?? "",
1894
1894
  codingBaseURL: payload?.codingBaseURL ?? "",
1895
1895
  codingModelId: payload?.codingModelId ?? "",
1896
+ // Optional search model (task #105): synthesizes web_search results. Empty = no synthesis.
1897
+ searchBaseURL: payload?.searchBaseURL ?? "",
1898
+ searchModelId: payload?.searchModelId ?? "",
1896
1899
  tools: payload?.tools ?? ["http_request"],
1897
1900
  // 0 = "not explicitly set": python defaults to 5, but may auto-raise to 12 when
1898
1901
  // workspaces are registered. Passing a literal 5 here would look user-set and
@@ -364,90 +364,354 @@ def _get_json(url, timeout=15):
364
364
  return None
365
365
 
366
366
 
367
- def _web_search_impl(query, max_results=5):
368
- """Look up factual info via free no-key JSON APIs (DuckDuckGo + Wikipedia).
369
-
370
- Returns a short SUMMARY (for a direct-fact question) followed by a numbered list
371
- of the top matching PAGES (title snippet URL), so the model can fetch_url() a
372
- real page when a one-line summary can't carry the data it needs (a schedule, a
373
- table, a fixtures list). Returning only a single Wikipedia intro used to trap weak
374
- models in a re-search loop they never saw a candidate URL to open (task #75).
375
- Never fabricatesreturns an honest 'no results' when nothing is found.
376
- """
367
+ # --- web_search: parallel keyless multi-backend (task #103) ------------------------------
368
+ # The old impl called DuckDuckGo's INSTANT-ANSWER API + Wikipedia sequentially. The IA API
369
+ # isn't a real web SERP (only curated abstracts for known entities), so general queries came
370
+ # back thin/empty and slow (2-3 sequential 15-25s fetches). This runs several KEYLESS
371
+ # backends CONCURRENTLY with short per-backend timeouts and an overall deadline, then merges
372
+ # + dedupes + ranks by cross-engine agreement. Backends: DuckDuckGo HTML (real results),
373
+ # a self-hosted SearXNG metasearch (aggregates Google/Bing/Yahoo/… server-side, keyless set
374
+ # GONEXT_SEARXNG_URL or cfg.searxngUrl), and Wikipedia (encyclopedic). No API keys.
375
+ _WEB_SEARCH_TTL = 300 # seconds short cache so repeated queries in a turn don't re-hit net
376
+ _WEB_SEARCH_CACHE: dict = {} # (query_lower, searxng_url) -> (ts, result_text)
377
+ _WEB_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
378
+ "(KHTML, like Gecko) Chrome/121.0 Safari/537.36")
379
+
380
+
381
+ def _get_text(url, timeout=6, ua=None, headers=None):
382
+ """GET a URL and return the decoded body text ("" on failure). For scraping SERP HTML."""
383
+ h = {"User-Agent": ua or _WEB_UA,
384
+ "Accept": "text/html,application/json;q=0.9,*/*;q=0.8",
385
+ "Accept-Language": "en-US,en;q=0.9"}
386
+ if headers:
387
+ h.update(headers)
388
+ req = urllib.request.Request(url, method="GET", headers=h)
389
+ try:
390
+ with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as resp:
391
+ return resp.read(600_000).decode("utf-8", errors="replace")
392
+ except Exception as e: # noqa: BLE001
393
+ _log(f"web_search GET failed {url[:80]}: {e}")
394
+ return ""
395
+
396
+
397
+ def _search_strip(s):
377
398
  import html as _html
378
- from urllib.parse import quote
379
- q = (query or "").strip()
380
- if not q:
381
- return "web_search: empty query."
399
+ return _html.unescape(re.sub(r"<[^>]+>", "", s or "")).strip()
382
400
 
383
- def _strip(s):
384
- return _html.unescape(re.sub(r"<[^>]+>", "", s or "")).strip()
385
401
 
386
- summary = ""
387
- summary_src = ""
388
- results = [] # (title, snippet, url)
389
- seen = set()
402
+ def _norm_url(u):
403
+ """Normalize a URL for dedupe (lower host/scheme, drop fragment + trailing slash)."""
404
+ from urllib.parse import urlsplit, urlunsplit
405
+ u = (u or "").strip()
406
+ if not u:
407
+ return ""
408
+ try:
409
+ s = urlsplit(u)
410
+ return urlunsplit((s.scheme.lower(), s.netloc.lower(), s.path.rstrip("/"), "", ""))
411
+ except Exception: # noqa: BLE001
412
+ return u.rstrip("/")
390
413
 
391
- def _add(title, snippet, url):
392
- url = (url or "").strip()
393
- if not url or url in seen or len(results) >= max_results:
394
- return
395
- seen.add(url)
396
- results.append((_strip(title) or url, _strip(snippet), url))
397
414
 
398
- # 1) DuckDuckGo Instant Answer — a direct abstract + related topics (as pages).
399
- ddg = _get_json(
400
- f"https://api.duckduckgo.com/?q={quote(q)}&format=json&no_html=1&skip_disambig=1"
401
- )
402
- if isinstance(ddg, dict):
403
- abstract = (ddg.get("AbstractText") or "").strip()
404
- if abstract:
405
- summary = abstract[:1200]
406
- summary_src = (ddg.get("AbstractURL") or "").strip()
407
- for topic in ddg.get("RelatedTopics") or []:
408
- if isinstance(topic, dict) and topic.get("Text") and topic.get("FirstURL"):
409
- _add(topic["Text"][:80], topic["Text"], topic["FirstURL"])
410
-
411
- # 2) Wikipedia full-text search — SEVERAL candidate pages with snippets, so the
412
- # model can pick the specific one (e.g. a "…knockout stage" fixtures page).
415
+ def _bk_ddg_html(q, timeout=6):
416
+ """DuckDuckGo HTML endpoint → real web results [{title,snippet,url}] (keyless)."""
417
+ from urllib.parse import quote, unquote
418
+ html_body = _get_text(f"https://html.duckduckgo.com/html/?q={quote(q)}", timeout=timeout)
419
+ if not html_body:
420
+ return []
421
+ out = []
422
+ anchors = re.findall(r'<a\b[^>]*class="result__a"[^>]*>.*?</a>', html_body, re.S)
423
+ snippets = re.findall(r'<a\b[^>]*class="result__snippet"[^>]*>(.*?)</a>', html_body, re.S)
424
+ for i, a in enumerate(anchors):
425
+ hm = re.search(r'href="([^"]*)"', a)
426
+ if not hm:
427
+ continue
428
+ href = hm.group(1)
429
+ um = re.search(r'[?&]uddg=([^&]+)', href)
430
+ real = unquote(um.group(1)) if um else href
431
+ if real.startswith("//"):
432
+ real = "https:" + real
433
+ if not real.startswith("http"):
434
+ continue
435
+ title = _search_strip(a)
436
+ snip = _search_strip(snippets[i]) if i < len(snippets) else ""
437
+ if title:
438
+ out.append({"title": title[:120], "snippet": snip, "url": real})
439
+ return out
440
+
441
+
442
+ def _bk_searxng(q, base, timeout=6):
443
+ """Self-hosted SearXNG JSON API (keyless) → {"results":[{title,snippet,url}], "answers":[str]}.
444
+ `answers` = SearXNG's aggregated zero-click ANSWERS + INFOBOXES (the 'answer box' / knowledge
445
+ panel a human sees on a SERP) — surfaced first so one search often needs no page read (#104)."""
446
+ from urllib.parse import quote
447
+ base = (base or "").strip().rstrip("/")
448
+ if not base:
449
+ return {"results": [], "answers": []}
450
+ data = _get_json(f"{base}/search?q={quote(q)}&format=json&safesearch=0", timeout=timeout)
451
+ results, answers = [], []
452
+ if isinstance(data, dict):
453
+ for r in (data.get("results") or []):
454
+ url = (r.get("url") or "").strip()
455
+ if not url:
456
+ continue
457
+ results.append({
458
+ "title": _search_strip(r.get("title") or url)[:120],
459
+ "snippet": _search_strip(r.get("content") or ""),
460
+ "url": url,
461
+ })
462
+ for a in (data.get("answers") or []):
463
+ txt = _search_strip(a if isinstance(a, str) else (a.get("answer") or a.get("content") or ""))
464
+ if txt:
465
+ answers.append(txt[:600])
466
+ for ib in (data.get("infoboxes") or []):
467
+ if isinstance(ib, dict):
468
+ txt = _search_strip(ib.get("content") or "")
469
+ if txt:
470
+ answers.append(txt[:600])
471
+ return {"results": results, "answers": answers}
472
+
473
+
474
+ def _bk_wikipedia(q, timeout=6, n=5):
475
+ """Wikipedia full-text search → encyclopedic candidate pages [{title,snippet,url}]."""
476
+ from urllib.parse import quote
413
477
  search = _get_json(
414
478
  "https://en.wikipedia.org/w/api.php?action=query&list=search"
415
- f"&srsearch={quote(q)}&srlimit={max_results}&format=json"
479
+ f"&srsearch={quote(q)}&srlimit={n}&format=json",
480
+ timeout=timeout,
416
481
  )
482
+ out = []
417
483
  try:
418
484
  hits = search["query"]["search"]
419
485
  except Exception: # noqa: BLE001
420
486
  hits = []
421
487
  for hit in hits:
422
488
  slug = quote(hit.get("title", "").replace(" ", "_"))
423
- _add(hit.get("title", ""), hit.get("snippet", ""),
424
- f"https://en.wikipedia.org/wiki/{slug}")
425
- # If no direct abstract, use the best-matching page's summary as the answer.
426
- if not summary and hits:
427
- slug = quote(hits[0]["title"].replace(" ", "_"))
428
- s = _get_json("https://en.wikipedia.org/api/rest_v1/page/summary/" + slug)
429
- if isinstance(s, dict):
430
- summary = (s.get("extract") or "").strip()[:1200]
431
- summary_src = f"https://en.wikipedia.org/wiki/{slug}"
432
-
433
- if not summary and not results:
489
+ out.append({
490
+ "title": hit.get("title", ""),
491
+ "snippet": _search_strip(hit.get("snippet", "")),
492
+ "url": f"https://en.wikipedia.org/wiki/{slug}",
493
+ })
494
+ return out
495
+
496
+
497
+ def _bk_ddg_ia_summary(q, timeout=6):
498
+ """DuckDuckGo Instant Answer — a direct abstract for a well-known entity (summary only)."""
499
+ from urllib.parse import quote
500
+ ddg = _get_json(
501
+ f"https://api.duckduckgo.com/?q={quote(q)}&format=json&no_html=1&skip_disambig=1",
502
+ timeout=timeout,
503
+ )
504
+ if isinstance(ddg, dict):
505
+ abstract = (ddg.get("AbstractText") or "").strip()
506
+ if abstract:
507
+ return (abstract[:1200], (ddg.get("AbstractURL") or "").strip())
508
+ return ("", "")
509
+
510
+
511
+ def _condense_for_query(text, query, max_chars=1600):
512
+ """Keep the QUERY-RELEVANT paragraphs of a page (task #104): split into paragraphs,
513
+ score each by distinct query-term overlap, keep the highest-scoring ones up to max_chars,
514
+ then restore original order. Boilerplate/nav is already gone (via _html_to_text); this
515
+ picks the part that actually answers the query so the read stays small + on-signal."""
516
+ terms = {t for t in re.findall(r"[a-z0-9]{3,}", (query or "").lower())}
517
+ paras = [p.strip() for p in re.split(r"\n\s*\n|\n", text or "") if len(p.strip()) > 20]
518
+ if not paras:
519
+ return (text or "")[:max_chars].strip()
520
+ if not terms:
521
+ return "\n".join(paras)[:max_chars].strip()
522
+ scored = [(sum(1 for t in terms if t in p.lower()), i, p) for i, p in enumerate(paras)]
523
+ chosen, total = [], 0
524
+ for score, i, p in sorted(scored, key=lambda x: (-x[0], x[1])):
525
+ if score == 0 and chosen: # stop once we're into zero-signal paragraphs
526
+ break
527
+ if chosen and total + len(p) > max_chars:
528
+ break
529
+ chosen.append((i, p))
530
+ total += len(p) + 1
531
+ chosen.sort()
532
+ return "\n".join(p for _, p in chosen)[:max_chars].strip()
533
+
534
+
535
+ def _read_pages(urls, query, per_page_chars=1600, per_timeout=5):
536
+ """Fetch several result pages IN PARALLEL and return {url: condensed_readable_text}.
537
+ The 'read' half of search-and-read (task #104) — reuses _fetch_page_impl + _html_to_text.
538
+ Skips non-HTML/failed pages (the snippet stays as the fallback)."""
539
+ from concurrent.futures import ThreadPoolExecutor
540
+ import time as _t
541
+
542
+ def _read_one(u):
543
+ status, ctype, raw = _fetch_page_impl(u, timeout=per_timeout, max_bytes=300000)
544
+ if not status or status >= 400:
545
+ return u, ""
546
+ if ctype and ("html" not in ctype and "text" not in ctype):
547
+ return u, "" # skip pdf/json/binary — the model can fetch_url those explicitly
548
+ text = _html_to_text(raw.decode("utf-8", errors="replace"), limit=8000)
549
+ return u, _condense_for_query(text, query, per_page_chars)
550
+
551
+ out = {}
552
+ if not urls:
553
+ return out
554
+ with ThreadPoolExecutor(max_workers=len(urls)) as ex:
555
+ futs = {ex.submit(_read_one, u): u for u in urls}
556
+ end = _t.time() + per_timeout + 3
557
+ for fut in list(futs):
558
+ try:
559
+ uu, txt = fut.result(timeout=max(0.0, end - _t.time()))
560
+ if txt:
561
+ out[uu] = txt
562
+ except Exception: # noqa: BLE001
563
+ continue
564
+ return out
565
+
566
+
567
+ def _synthesize_search(query, context, base_url, model_id, api_key="local"):
568
+ """Search model (task #105): ask a configured OpenAI-compatible model to write a concise,
569
+ cited answer from the already-fetched search results — so the (slow) code model doesn't
570
+ have to read/reason over raw pages. Returns "" on any failure (caller falls back)."""
571
+ from openai import OpenAI
572
+ client = OpenAI(base_url=base_url, api_key=api_key or "local", max_retries=0, timeout=45)
573
+ resp = _chat_create(
574
+ client,
575
+ model=model_id or "default_model",
576
+ messages=[
577
+ {"role": "system", "content": (
578
+ "You are a research assistant. Using ONLY the search results provided, write a "
579
+ "concise, factual answer to the user's query. Cite sources inline as [n] using "
580
+ "their numbers. If the results do not contain the answer, say so plainly — never "
581
+ "invent facts, dates, names, or URLs."
582
+ )},
583
+ {"role": "user", "content": f"Query: {query}\n\nSearch results:\n{context}\n\nAnswer:"},
584
+ ],
585
+ max_tokens=700,
586
+ temperature=0.2,
587
+ )
588
+ return (resp.choices[0].message.content or "").strip()
589
+
590
+
591
+ def _web_search_impl(query, max_results=5, searxng_url="", search_base_url="", search_model_id=""):
592
+ """Parallel keyless web search (task #103): run DuckDuckGo-HTML + SearXNG (if configured)
593
+ + Wikipedia CONCURRENTLY with a short deadline, merge/dedupe/rank by cross-engine
594
+ agreement, and return a short SUMMARY (when available) + a numbered list of the top
595
+ PAGES (title — snippet — URL) so the model can fetch_url() the best one. Never fabricates.
596
+ """
597
+ import os
598
+ import time as _t
599
+ from concurrent.futures import ThreadPoolExecutor
600
+
601
+ q = (query or "").strip()
602
+ if not q:
603
+ return "web_search: empty query."
604
+ searxng = (searxng_url or os.environ.get("GONEXT_SEARXNG_URL") or "").strip()
605
+
606
+ # Short TTL cache (identical query within a turn). Key includes the search model so a
607
+ # synthesized answer isn't served when the model config changed.
608
+ ckey = (q.lower(), searxng, search_base_url, search_model_id)
609
+ hit = _WEB_SEARCH_CACHE.get(ckey)
610
+ if hit and (_t.time() - hit[0]) < _WEB_SEARCH_TTL:
611
+ return hit[1]
612
+
613
+ per_timeout = 6 # per-backend socket timeout
614
+ overall = 9 # overall wall-clock deadline across all backends
615
+
616
+ # Result-producing backends (run in parallel). Order = tie-break priority in the merge.
617
+ tasks = [("ddg", lambda: _bk_ddg_html(q, per_timeout))]
618
+ if searxng:
619
+ tasks.append(("searxng", lambda: _bk_searxng(q, searxng, per_timeout)))
620
+ tasks.append(("wikipedia", lambda: _bk_wikipedia(q, per_timeout, max_results)))
621
+ tasks.append(("summary", lambda: _bk_ddg_ia_summary(q, per_timeout)))
622
+
623
+ results_by = {}
624
+ answers = [] # zero-click 'answer box' text (SearXNG + DDG-IA)
625
+ summary, summary_src = "", ""
626
+ with ThreadPoolExecutor(max_workers=len(tasks)) as ex:
627
+ futs = {ex.submit(fn): name for name, fn in tasks}
628
+ end = _t.time() + overall
629
+ for fut, name in futs.items():
630
+ try:
631
+ r = fut.result(timeout=max(0.0, end - _t.time()))
632
+ except Exception: # noqa: BLE001 — a slow/failed backend is skipped
633
+ continue
634
+ if name == "summary":
635
+ summary, summary_src = r
636
+ elif name == "searxng":
637
+ results_by["searxng"] = r.get("results", [])
638
+ answers.extend(r.get("answers", []))
639
+ else:
640
+ results_by[name] = r
641
+ if summary: # DDG instant answer is an 'answer box' too
642
+ answers.insert(0, summary + (f" (source: {summary_src})" if summary_src else ""))
643
+
644
+ # Merge + dedupe by normalized URL; rank by cross-engine AGREEMENT then first-seen order.
645
+ merged = {}
646
+ order = 0
647
+ for name in ("searxng", "ddg", "wikipedia"):
648
+ for r in results_by.get(name, []):
649
+ nu = _norm_url(r.get("url"))
650
+ if not nu:
651
+ continue
652
+ if nu in merged:
653
+ merged[nu]["hits"] += 1
654
+ if not merged[nu]["snippet"] and r.get("snippet"):
655
+ merged[nu]["snippet"] = r["snippet"]
656
+ else:
657
+ merged[nu] = {
658
+ "title": r.get("title") or r["url"], "snippet": r.get("snippet", ""),
659
+ "url": r["url"], "hits": 1, "order": order,
660
+ }
661
+ order += 1
662
+ ranked = sorted(merged.values(), key=lambda e: (-e["hits"], e["order"]))[:max_results]
663
+
664
+ if not answers and not ranked:
434
665
  return (
435
666
  f"No results found for '{q}'. Tell the user you couldn't find this — "
436
667
  "do NOT invent an answer or a URL."
437
668
  )
438
669
 
670
+ # Search-AND-read (task #104): READ the top pages here (in parallel) so the model gets
671
+ # answer-ready content from ONE call and rarely needs a follow-up fetch_url — the big win
672
+ # when the reasoning model is slow (each avoided round-trip saves a full model step).
673
+ read_enabled = (os.environ.get("GONEXT_SEARCH_READ", "1").strip() != "0")
674
+ try:
675
+ read_k = max(0, int(os.environ.get("GONEXT_SEARCH_READ_K", "3")))
676
+ except ValueError:
677
+ read_k = 3
678
+ try:
679
+ page_chars = max(400, int(os.environ.get("GONEXT_SEARCH_PAGE_CHARS", "1600")))
680
+ except ValueError:
681
+ page_chars = 1600
682
+ page_texts = {}
683
+ if read_enabled and read_k and ranked:
684
+ page_texts = _read_pages([e["url"] for e in ranked[:read_k]], q,
685
+ per_page_chars=page_chars, per_timeout=5)
686
+
439
687
  parts = []
440
- if summary:
441
- parts.append(summary + (f"\nSource: {summary_src}" if summary_src else ""))
442
- if results:
443
- lines = ["Top pages (call fetch_url on the most relevant to read it in full):"]
444
- for i, (title, snippet, url) in enumerate(results, 1):
445
- line = f"{i}. {title}"
446
- if snippet:
447
- line += f" {snippet[:160]}"
448
- lines.append(line + f"\n {url}")
449
- parts.append("\n".join(lines))
450
- return "\n\n".join(parts)
688
+ if answers:
689
+ parts.append("Answer:\n" + "\n".join(f" {a}" for a in answers[:3]))
690
+ if ranked:
691
+ header = ("Sources already READ for you (you usually do NOT need fetch_url; "
692
+ "call it only to read a specific page in full):")
693
+ blocks = [header]
694
+ for i, e in enumerate(ranked, 1):
695
+ head = f"[{i}] {e['title']}\n {e['url']}"
696
+ body = page_texts.get(e["url"]) or (e["snippet"][:200] if e["snippet"] else "")
697
+ blocks.append(head + (f"\n{body}" if body else ""))
698
+ parts.append("\n\n".join(blocks))
699
+ out = "\n\n".join(parts)
700
+
701
+ # Search model (task #105): if configured, have it write the answer FROM the results we
702
+ # just read — so the code model gets a clean cited answer, not raw pages. Best-effort:
703
+ # any failure falls back to the read output above.
704
+ if search_base_url and search_model_id and (answers or ranked):
705
+ try:
706
+ synth = _synthesize_search(q, out, search_base_url, search_model_id)
707
+ if synth:
708
+ srcs = "\n".join(f"[{i}] {e['url']}" for i, e in enumerate(ranked, 1))
709
+ out = f"Answer:\n{synth}" + (f"\n\nSources:\n{srcs}" if srcs else "")
710
+ except Exception as e: # noqa: BLE001
711
+ _log(f"search-model synthesis failed ({type(e).__name__}: {e}); using read output")
712
+
713
+ _WEB_SEARCH_CACHE[ckey] = (_t.time(), out)
714
+ return out
451
715
 
452
716
 
453
717
  class _AgentConfigError(RuntimeError):
@@ -3874,13 +4138,13 @@ def run_agent_chat(cfg):
3874
4138
 
3875
4139
  @tool
3876
4140
  def web_search(query: str) -> str:
3877
- """Search for factual or encyclopedic information using free no-key sources.
4141
+ """Search the web AND read the top results in one call — returns an 'Answer:' (when found) plus the READ CONTENT of the best pages, not just links. Because the pages are already read for you, you normally do NOT need to call fetch_url afterwards — answer or create_pdf directly from what this returns. Only call fetch_url when you must read one specific page in full.
3878
4142
 
3879
- Use this INSTEAD of guessing a URL when the user asks to 'find' something or asks a
3880
- general-knowledge question. Returns a short summary and a source URL.
4143
+ Use this INSTEAD of guessing a URL whenever the user asks to 'find' something or asks a
4144
+ factual / current-events question.
3881
4145
 
3882
4146
  Args:
3883
- query: What to look up, e.g. 'capital of France' or 'productivity day-to-day method'.
4147
+ query: What to look up, e.g. 'FIFA World Cup 2026 final date' or 'express error handling best practices'.
3884
4148
  """
3885
4149
  dup, nudge = _dedup("web_search", query,
3886
4150
  "Use the facts you have ALREADY gathered: if the task asks for a "
@@ -3893,7 +4157,13 @@ def run_agent_chat(cfg):
3893
4157
  if blocked:
3894
4158
  return blocked
3895
4159
  _emit({"type": "step", "text": f"Searching the web → {query[:80]}"})
3896
- result = _web_search_impl(query)
4160
+ # SearXNG URL (task #103) + optional search model (task #105) from Settings.
4161
+ result = _web_search_impl(
4162
+ query,
4163
+ searxng_url=(cfg.get("searxngUrl") or "").strip(),
4164
+ search_base_url=(cfg.get("searchBaseURL") or "").strip(),
4165
+ search_model_id=(cfg.get("searchModelId") or "").strip(),
4166
+ )
3897
4167
  _log(f"web_search {query[:60]!r} → {result[:80]}")
3898
4168
  _last_obs["text"] = result
3899
4169
  if result and not result.startswith("Error"):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.274",
3
+ "version": "1.0.276",
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",