@tiens.nguyen/gonext-local-worker 1.0.277 → 1.0.280

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,9 +1893,6 @@ 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 ?? "",
1899
1896
  tools: payload?.tools ?? ["http_request"],
1900
1897
  // 0 = "not explicitly set": python defaults to 5, but may auto-raise to 12 when
1901
1898
  // workspaces are registered. Passing a literal 5 here would look user-set and
@@ -440,35 +440,24 @@ def _bk_ddg_html(q, timeout=6):
440
440
 
441
441
 
442
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)."""
443
+ """Self-hosted SearXNG JSON API → aggregated results [{title,snippet,url}] (keyless)."""
446
444
  from urllib.parse import quote
447
445
  base = (base or "").strip().rstrip("/")
448
446
  if not base:
449
- return {"results": [], "answers": []}
447
+ return []
450
448
  data = _get_json(f"{base}/search?q={quote(q)}&format=json&safesearch=0", timeout=timeout)
451
- results, answers = [], []
449
+ out = []
452
450
  if isinstance(data, dict):
453
451
  for r in (data.get("results") or []):
454
452
  url = (r.get("url") or "").strip()
455
453
  if not url:
456
454
  continue
457
- results.append({
455
+ out.append({
458
456
  "title": _search_strip(r.get("title") or url)[:120],
459
457
  "snippet": _search_strip(r.get("content") or ""),
460
458
  "url": url,
461
459
  })
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}
460
+ return out
472
461
 
473
462
 
474
463
  def _bk_wikipedia(q, timeout=6, n=5):
@@ -508,87 +497,7 @@ def _bk_ddg_ia_summary(q, timeout=6):
508
497
  return ("", "")
509
498
 
510
499
 
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=""):
500
+ def _web_search_impl(query, max_results=5, searxng_url=""):
592
501
  """Parallel keyless web search (task #103): run DuckDuckGo-HTML + SearXNG (if configured)
593
502
  + Wikipedia CONCURRENTLY with a short deadline, merge/dedupe/rank by cross-engine
594
503
  agreement, and return a short SUMMARY (when available) + a numbered list of the top
@@ -603,9 +512,8 @@ def _web_search_impl(query, max_results=5, searxng_url="", search_base_url="", s
603
512
  return "web_search: empty query."
604
513
  searxng = (searxng_url or os.environ.get("GONEXT_SEARXNG_URL") or "").strip()
605
514
 
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)
515
+ # Short TTL cache (identical query within a turn).
516
+ ckey = (q.lower(), searxng)
609
517
  hit = _WEB_SEARCH_CACHE.get(ckey)
610
518
  if hit and (_t.time() - hit[0]) < _WEB_SEARCH_TTL:
611
519
  return hit[1]
@@ -621,7 +529,6 @@ def _web_search_impl(query, max_results=5, searxng_url="", search_base_url="", s
621
529
  tasks.append(("summary", lambda: _bk_ddg_ia_summary(q, per_timeout)))
622
530
 
623
531
  results_by = {}
624
- answers = [] # zero-click 'answer box' text (SearXNG + DDG-IA)
625
532
  summary, summary_src = "", ""
626
533
  with ThreadPoolExecutor(max_workers=len(tasks)) as ex:
627
534
  futs = {ex.submit(fn): name for name, fn in tasks}
@@ -633,13 +540,8 @@ def _web_search_impl(query, max_results=5, searxng_url="", search_base_url="", s
633
540
  continue
634
541
  if name == "summary":
635
542
  summary, summary_src = r
636
- elif name == "searxng":
637
- results_by["searxng"] = r.get("results", [])
638
- answers.extend(r.get("answers", []))
639
543
  else:
640
544
  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
545
 
644
546
  # Merge + dedupe by normalized URL; rank by cross-engine AGREEMENT then first-seen order.
645
547
  merged = {}
@@ -661,55 +563,24 @@ def _web_search_impl(query, max_results=5, searxng_url="", search_base_url="", s
661
563
  order += 1
662
564
  ranked = sorted(merged.values(), key=lambda e: (-e["hits"], e["order"]))[:max_results]
663
565
 
664
- if not answers and not ranked:
566
+ if not summary and not ranked:
665
567
  return (
666
568
  f"No results found for '{q}'. Tell the user you couldn't find this — "
667
569
  "do NOT invent an answer or a URL."
668
570
  )
669
571
 
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
-
687
572
  parts = []
688
- if answers:
689
- parts.append("Answer:\n" + "\n".join(f" {a}" for a in answers[:3]))
573
+ if summary:
574
+ parts.append(summary + (f"\nSource: {summary_src}" if summary_src else ""))
690
575
  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]
576
+ lines = ["Top pages (call fetch_url on the most relevant to read it in full):"]
694
577
  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))
578
+ line = f"{i}. {e['title']}"
579
+ if e["snippet"]:
580
+ line += f"{e['snippet'][:160]}"
581
+ lines.append(line + f"\n {e['url']}")
582
+ parts.append("\n".join(lines))
699
583
  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
584
  _WEB_SEARCH_CACHE[ckey] = (_t.time(), out)
714
585
  return out
715
586
 
@@ -4138,13 +4009,13 @@ def run_agent_chat(cfg):
4138
4009
 
4139
4010
  @tool
4140
4011
  def web_search(query: str) -> str:
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.
4012
+ """Search for factual or encyclopedic information using free no-key sources.
4142
4013
 
4143
- Use this INSTEAD of guessing a URL whenever the user asks to 'find' something or asks a
4144
- factual / current-events question.
4014
+ Use this INSTEAD of guessing a URL when the user asks to 'find' something or asks a
4015
+ general-knowledge question. Returns a short summary and a source URL.
4145
4016
 
4146
4017
  Args:
4147
- query: What to look up, e.g. 'FIFA World Cup 2026 final date' or 'express error handling best practices'.
4018
+ query: What to look up, e.g. 'capital of France' or 'productivity day-to-day method'.
4148
4019
  """
4149
4020
  dup, nudge = _dedup("web_search", query,
4150
4021
  "Use the facts you have ALREADY gathered: if the task asks for a "
@@ -4157,13 +4028,9 @@ def run_agent_chat(cfg):
4157
4028
  if blocked:
4158
4029
  return blocked
4159
4030
  _emit({"type": "step", "text": f"Searching the web → {query[:80]}"})
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
- )
4031
+ # SearXNG URL (task #103): from Settings (cfg.searxngUrl) if the API sends it, else
4032
+ # the GONEXT_SEARXNG_URL env fallback inside _web_search_impl. Keyless either way.
4033
+ result = _web_search_impl(query, searxng_url=(cfg.get("searxngUrl") or "").strip())
4167
4034
  _log(f"web_search {query[:60]!r} → {result[:80]}")
4168
4035
  _last_obs["text"] = result
4169
4036
  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.277",
3
+ "version": "1.0.280",
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",