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

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
@@ -440,24 +440,35 @@ 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 → aggregated results [{title,snippet,url}] (keyless)."""
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)."""
444
446
  from urllib.parse import quote
445
447
  base = (base or "").strip().rstrip("/")
446
448
  if not base:
447
- return []
449
+ return {"results": [], "answers": []}
448
450
  data = _get_json(f"{base}/search?q={quote(q)}&format=json&safesearch=0", timeout=timeout)
449
- out = []
451
+ results, answers = [], []
450
452
  if isinstance(data, dict):
451
453
  for r in (data.get("results") or []):
452
454
  url = (r.get("url") or "").strip()
453
455
  if not url:
454
456
  continue
455
- out.append({
457
+ results.append({
456
458
  "title": _search_strip(r.get("title") or url)[:120],
457
459
  "snippet": _search_strip(r.get("content") or ""),
458
460
  "url": url,
459
461
  })
460
- return out
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}
461
472
 
462
473
 
463
474
  def _bk_wikipedia(q, timeout=6, n=5):
@@ -497,7 +508,87 @@ def _bk_ddg_ia_summary(q, timeout=6):
497
508
  return ("", "")
498
509
 
499
510
 
500
- def _web_search_impl(query, max_results=5, searxng_url=""):
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=""):
501
592
  """Parallel keyless web search (task #103): run DuckDuckGo-HTML + SearXNG (if configured)
502
593
  + Wikipedia CONCURRENTLY with a short deadline, merge/dedupe/rank by cross-engine
503
594
  agreement, and return a short SUMMARY (when available) + a numbered list of the top
@@ -512,8 +603,9 @@ def _web_search_impl(query, max_results=5, searxng_url=""):
512
603
  return "web_search: empty query."
513
604
  searxng = (searxng_url or os.environ.get("GONEXT_SEARXNG_URL") or "").strip()
514
605
 
515
- # Short TTL cache (identical query within a turn).
516
- ckey = (q.lower(), searxng)
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)
517
609
  hit = _WEB_SEARCH_CACHE.get(ckey)
518
610
  if hit and (_t.time() - hit[0]) < _WEB_SEARCH_TTL:
519
611
  return hit[1]
@@ -529,6 +621,7 @@ def _web_search_impl(query, max_results=5, searxng_url=""):
529
621
  tasks.append(("summary", lambda: _bk_ddg_ia_summary(q, per_timeout)))
530
622
 
531
623
  results_by = {}
624
+ answers = [] # zero-click 'answer box' text (SearXNG + DDG-IA)
532
625
  summary, summary_src = "", ""
533
626
  with ThreadPoolExecutor(max_workers=len(tasks)) as ex:
534
627
  futs = {ex.submit(fn): name for name, fn in tasks}
@@ -540,8 +633,13 @@ def _web_search_impl(query, max_results=5, searxng_url=""):
540
633
  continue
541
634
  if name == "summary":
542
635
  summary, summary_src = r
636
+ elif name == "searxng":
637
+ results_by["searxng"] = r.get("results", [])
638
+ answers.extend(r.get("answers", []))
543
639
  else:
544
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 ""))
545
643
 
546
644
  # Merge + dedupe by normalized URL; rank by cross-engine AGREEMENT then first-seen order.
547
645
  merged = {}
@@ -563,24 +661,55 @@ def _web_search_impl(query, max_results=5, searxng_url=""):
563
661
  order += 1
564
662
  ranked = sorted(merged.values(), key=lambda e: (-e["hits"], e["order"]))[:max_results]
565
663
 
566
- if not summary and not ranked:
664
+ if not answers and not ranked:
567
665
  return (
568
666
  f"No results found for '{q}'. Tell the user you couldn't find this — "
569
667
  "do NOT invent an answer or a URL."
570
668
  )
571
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
+
572
687
  parts = []
573
- if summary:
574
- parts.append(summary + (f"\nSource: {summary_src}" if summary_src else ""))
688
+ if answers:
689
+ parts.append("Answer:\n" + "\n".join(f" {a}" for a in answers[:3]))
575
690
  if ranked:
576
- lines = ["Top pages (call fetch_url on the most relevant to read it in full):"]
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]
577
694
  for i, e in enumerate(ranked, 1):
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))
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))
583
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
+
584
713
  _WEB_SEARCH_CACHE[ckey] = (_t.time(), out)
585
714
  return out
586
715
 
@@ -4009,13 +4138,13 @@ def run_agent_chat(cfg):
4009
4138
 
4010
4139
  @tool
4011
4140
  def web_search(query: str) -> str:
4012
- """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.
4013
4142
 
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.
4143
+ Use this INSTEAD of guessing a URL whenever the user asks to 'find' something or asks a
4144
+ factual / current-events question.
4016
4145
 
4017
4146
  Args:
4018
- 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'.
4019
4148
  """
4020
4149
  dup, nudge = _dedup("web_search", query,
4021
4150
  "Use the facts you have ALREADY gathered: if the task asks for a "
@@ -4028,9 +4157,13 @@ def run_agent_chat(cfg):
4028
4157
  if blocked:
4029
4158
  return blocked
4030
4159
  _emit({"type": "step", "text": f"Searching the web → {query[:80]}"})
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())
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
+ )
4034
4167
  _log(f"web_search {query[:60]!r} → {result[:80]}")
4035
4168
  _last_obs["text"] = result
4036
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.275",
3
+ "version": "1.0.277",
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",