@tiens.nguyen/gu-cli 1.0.798 → 1.0.799

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.
package/cli-token.mjs CHANGED
@@ -170,11 +170,44 @@ export async function claimTrial({ hostedBase, device, fetchImpl = fetch, timeou
170
170
  return typeof data?.token === "string"
171
171
  ? { ok: true, token: data.token, endsAt: data.endsAt ?? null, created: data.created === true }
172
172
  : { ok: false, why: "bad-response" };
173
- } catch {
174
- return { ok: false, why: "unreachable" };
173
+ } catch (err) {
174
+ // #17: say WHY it failed (DNS, timeout, refused, TLS…) — a bare "can't be reached" hid a
175
+ // fresh Ubuntu server's real problem.
176
+ return { ok: false, why: "unreachable", detail: netErrorDetail(err, timeoutMs) };
175
177
  }
176
178
  }
177
179
 
180
+ /** A short, human cause for a failed fetch (#17): "DNS lookup failed (ENOTFOUND)", "no answer in 8s"… */
181
+ export function netErrorDetail(err, timeoutMs = 8000) {
182
+ const name = String(err?.name ?? "");
183
+ if (name === "TimeoutError" || name === "AbortError") return `no answer in ${Math.round(timeoutMs / 1000)}s`;
184
+ const code = String(err?.cause?.code ?? err?.code ?? "");
185
+ const known = {
186
+ ENOTFOUND: "DNS lookup failed",
187
+ EAI_AGAIN: "DNS lookup failed (temporary)",
188
+ ECONNREFUSED: "connection refused",
189
+ ECONNRESET: "connection reset",
190
+ ETIMEDOUT: "connection timed out",
191
+ EHOSTUNREACH: "host unreachable",
192
+ ENETUNREACH: "network unreachable",
193
+ CERT_HAS_EXPIRED: "TLS certificate expired",
194
+ UNABLE_TO_VERIFY_LEAF_SIGNATURE: "TLS certificate could not be verified",
195
+ UNABLE_TO_GET_ISSUER_CERT_LOCALLY: "TLS certificate could not be verified (missing CA certificates?)",
196
+ SELF_SIGNED_CERT_IN_CHAIN: "TLS: self-signed certificate in chain (proxy?)",
197
+ DEPTH_ZERO_SELF_SIGNED_CERT: "TLS: self-signed certificate (proxy?)",
198
+ ERR_TLS_CERT_ALTNAME_INVALID: "TLS certificate does not match the host name",
199
+ };
200
+ if (code && known[code]) return `${known[code]} (${code})`;
201
+ if (code) return code;
202
+ const msg = String(err?.cause?.message ?? err?.message ?? err ?? "").trim();
203
+ return msg ? msg.slice(0, 160) : "network error";
204
+ }
205
+
206
+ /** " (at host)" for the messages above (#17), or "" when there is no base. */
207
+ function trialHost(base) {
208
+ try { return base ? ` (at ${new URL(String(base)).host})` : ""; } catch { return ""; }
209
+ }
210
+
178
211
  /** Where a person who thinks a lock is wrong can write (#315 P7) — the address the website shows. */
179
212
  export const SUPPORT_EMAIL = "support@getmarsic.cc";
180
213
 
@@ -256,7 +289,15 @@ export async function promptForToken({
256
289
  ? [`This is a free trial token: ${daysLeft(trial.endsAt)} days left on this computer, until ${shortDate(trial.endsAt)}.`, getOne, "Enter uses it — or delete it (one Backspace) and paste your own token."]
257
290
  : [
258
291
  ...(trial.why === "used" && trial.endedAt ? [`The free trial on this computer ended on ${shortDate(trial.endedAt)}.`] : []),
259
- ...(trial.why === "unreachable" ? ["The gu service can't be reached right now — Enter on an empty line tries again."] : []),
292
+ ...(trial.why === "unreachable"
293
+ ? [`The gu service can't be reached right now${trialHost(hostedBase)}${trial.detail ? `: ${trial.detail}` : ""} — Enter on an empty line tries again.`]
294
+ : []),
295
+ ...(/^http-\d+$/.test(String(trial.why ?? ""))
296
+ ? [`The gu service answered ${trial.why.replace("http-", "HTTP ")}${trialHost(hostedBase)} — no free trial from it right now. Enter on an empty line tries again.`]
297
+ : []),
298
+ ...(trial.why === "bad-response"
299
+ ? [`The gu service${trialHost(hostedBase)} sent an answer without a token — Enter on an empty line tries again.`]
300
+ : []),
260
301
  getOne,
261
302
  required ? "Paste it here, then Enter." : "Paste it here — or Enter to skip for now.",
262
303
  ];
@@ -28,6 +28,7 @@ import json
28
28
  import os
29
29
  import random
30
30
  import re
31
+ import socket
31
32
  from urllib.parse import urlparse
32
33
  import sys
33
34
  import threading
@@ -2615,6 +2616,69 @@ def _search_tally_bump(kind: str, found: int = 0, query: str = "") -> None:
2615
2616
  _SEARCH_TALLY["grep_hits"] += max(0, int(found or 0))
2616
2617
 
2617
2618
 
2619
+ # RAG FALLBACK (lexical). When the embedder is down — or indexing fails, or the query cannot be
2620
+ # embedded — a semantic search must not just return an error the model has to work around. The
2621
+ # user asked for exactly this: warn on the bar, but still ANSWER the search, by grep. A
2622
+ # plain-language question is turned into keywords, every text file is scanned for them, and
2623
+ # files are ranked by how many DIFFERENT keywords they contain (then by hit count), which is a
2624
+ # fair stand-in for "relevant" when no vectors are available.
2625
+ _FALLBACK_STOPWORDS = frozenset("""
2626
+ a an and are as at be been but by can could do does did for from has have how i if in into is it
2627
+ its me my no not of on or our so than that the their them then there these this those to too
2628
+ up us was we were what when where which while who why will with would you your about after all
2629
+ any also code file files find show tell get set use used using way work works handled happen
2630
+ happens project repo function method class does where's what's how's
2631
+ """.split())
2632
+
2633
+
2634
+ def _rag_fallback_terms(query: str, limit: int = 6) -> list:
2635
+ """Keywords worth grepping for, from a plain-language query. Order kept, duplicates dropped."""
2636
+ out, seen = [], set()
2637
+ for tok in re.findall(r"[A-Za-z_][A-Za-z0-9_.$-]*", str(query or "")):
2638
+ tok = tok.strip(".-$")
2639
+ low = tok.lower()
2640
+ if len(low) < 3 or low in _FALLBACK_STOPWORDS or low in seen:
2641
+ continue
2642
+ seen.add(low)
2643
+ out.append(tok)
2644
+ if len(out) >= limit:
2645
+ break
2646
+ return out
2647
+
2648
+
2649
+ def _lexical_fallback_search(query: str, files, max_files: int = 8, per_file: int = 3) -> dict:
2650
+ """Rank files by how many distinct query keywords they contain. Never raises.
2651
+
2652
+ `files` yields (abs_path, rel_path). Returns {"terms", "results", "hits"} where results is a
2653
+ list of {"file", "terms", "hits", "lines": [(lineno, text)]} best first.
2654
+ """
2655
+ terms = _rag_fallback_terms(query)
2656
+ if not terms:
2657
+ return {"terms": [], "results": [], "hits": 0}
2658
+ rxs = [(t.lower(), re.compile(re.escape(t), re.IGNORECASE)) for t in terms]
2659
+ scored = []
2660
+ total = 0
2661
+ for abs_path, _rel in files:
2662
+ try:
2663
+ with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
2664
+ found, hits, lines = set(), 0, []
2665
+ for i, line in enumerate(fh, 1):
2666
+ matched = [t for t, rx in rxs if rx.search(line)]
2667
+ if not matched:
2668
+ continue
2669
+ found.update(matched)
2670
+ hits += 1
2671
+ if len(lines) < per_file:
2672
+ lines.append((i, line.rstrip()[:200]))
2673
+ except OSError:
2674
+ continue
2675
+ if found:
2676
+ total += hits
2677
+ scored.append({"file": abs_path, "terms": len(found), "hits": hits, "lines": lines})
2678
+ scored.sort(key=lambda r: (-r["terms"], -r["hits"], r["file"]))
2679
+ return {"terms": terms, "results": scored[:max_files], "hits": total}
2680
+
2681
+
2618
2682
  def _retrieval_summary(tally: dict, rag_state: str = "", note_unused: bool = True,
2619
2683
  searches: list = None) -> str:
2620
2684
  """One line naming how this turn found code — "" when it never looked for any.
@@ -5297,7 +5361,17 @@ def _rag_base_dir(source_key: str) -> str:
5297
5361
  return _rag_workdir(source_key)
5298
5362
 
5299
5363
 
5300
- def _rag_probe_embed(base: str, timeout: float = 1.5):
5364
+ def _probe_is_timeout(e: BaseException) -> bool:
5365
+ """Did this probe failure come from the HOST not answering in time (vs. refused/DNS/etc.)?"""
5366
+ if isinstance(e, (socket.timeout, TimeoutError)):
5367
+ return True
5368
+ reason = getattr(e, "reason", None)
5369
+ if isinstance(reason, (socket.timeout, TimeoutError)):
5370
+ return True
5371
+ return "timed out" in str(e).lower()
5372
+
5373
+
5374
+ def _rag_probe_embed(base: str, timeout: float = 2.5, attempts: int = 2):
5301
5375
  """(reachable, detail) for an embeddings endpoint. Never raises.
5302
5376
 
5303
5377
  Config alone says nothing about whether embeddings will work: _embed() is only called
@@ -5309,6 +5383,24 @@ def _rag_probe_embed(base: str, timeout: float = 1.5):
5309
5383
  root = re.sub(r"/(v1|api/embeddings|api/embed|api)/?$", "", (base or "").rstrip("/")).rstrip("/")
5310
5384
  if not root:
5311
5385
  return False, "no URL configured"
5386
+ # ONE BLIP IS NOT "DOWN". A single 1.5s attempt marked a healthy remote embedder as
5387
+ # unreachable after one slow TLS handshake, and that verdict was then saved into the
5388
+ # session and shown in red until the next turn. So: a slightly longer timeout, and a
5389
+ # second pass before giving up. A timeout skips the remaining paths of that pass (the
5390
+ # HOST is slow, not the path), which bounds a truly dead host to attempts*timeout.
5391
+ last = "no response"
5392
+ for attempt in range(max(1, attempts)):
5393
+ if attempt:
5394
+ time.sleep(0.3)
5395
+ ok, detail, last = _rag_probe_embed_once(root, timeout)
5396
+ if ok:
5397
+ return True, detail
5398
+ return False, last
5399
+
5400
+
5401
+ def _rag_probe_embed_once(root: str, timeout: float):
5402
+ """One pass over the probe paths: (reachable, detail, last_error)."""
5403
+ last = "no response"
5312
5404
  for path in ("/v1/models", "/api/tags"): # OpenAI-compatible, then Ollama
5313
5405
  try:
5314
5406
  req = urllib.request.Request(f"{root}{path}", method="GET")
@@ -5318,13 +5410,16 @@ def _rag_probe_embed(base: str, timeout: float = 1.5):
5318
5410
  # CERTIFICATE_VERIFY_FAILED against an endpoint that was perfectly fine.
5319
5411
  with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as resp:
5320
5412
  if 200 <= resp.status < 300:
5321
- return True, ""
5413
+ return True, "", ""
5414
+ last = f"HTTP {resp.status}"
5322
5415
  except urllib.error.HTTPError as e:
5323
5416
  # It ANSWERED — 401/403/404 still proves something is listening there.
5324
- return True, f"HTTP {e.code}"
5417
+ return True, f"HTTP {e.code}", ""
5325
5418
  except Exception as e: # noqa: BLE001
5326
5419
  last = f"{type(e).__name__}: {e}"
5327
- return False, last
5420
+ if _probe_is_timeout(e):
5421
+ break
5422
+ return False, "", last
5328
5423
 
5329
5424
 
5330
5425
  def _same_embed_model(a: str, b: str) -> bool:
@@ -12284,6 +12379,45 @@ def run_agent_chat(cfg):
12284
12379
  _last_obs["text"] = out
12285
12380
  return out
12286
12381
 
12382
+ def _rag_grep_fallback(query: str, why: str) -> str:
12383
+ """Answer a semantic search lexically when RAG cannot, and SAY so (bar + terminal).
12384
+
12385
+ The state is left (or set) to a failure state so the bottom bar keeps its warning;
12386
+ the model gets real file:line hits instead of an error to route around.
12387
+ """
12388
+ if _RAG_STATE.get("s") not in ("unreachable", "unavailable"):
12389
+ _RAG_STATE["s"] = "fallback"
12390
+ _emit({"type": "step",
12391
+ "text": f"⚠ RAG unavailable ({why}) → falling back to keyword search"})
12392
+ _log(f"RAG fallback: {why} — answering {query[:80]!r} with a lexical search")
12393
+ try:
12394
+ root = _rag_read_allowed(_default_ws_root())
12395
+ res = _lexical_fallback_search(
12396
+ query, ((a, r) for a, r, _e in _rag_iter_text_files(root)))
12397
+ except Exception as e: # noqa: BLE001
12398
+ msg = (f"RAG is unavailable ({why}) and the keyword fallback failed: "
12399
+ f"{type(e).__name__}: {e}. Use grep_repo(pattern) / read_file_lines.")
12400
+ _last_obs["text"] = msg
12401
+ return msg
12402
+ _search_tally_bump("grep", res["hits"], " | ".join(res["terms"]) or query)
12403
+ head = (f"NOTE: semantic search is unavailable ({why}), so this is a KEYWORD search "
12404
+ f"for {', '.join(res['terms']) or '(no usable keywords)'} — results are "
12405
+ "lexical, not semantic.")
12406
+ if not res["results"]:
12407
+ msg = (head + f"\nNo file under {root} contains those keywords. Try grep_repo "
12408
+ "with a more specific identifier, or list_dir/read_file_lines.")
12409
+ _last_obs["text"] = msg
12410
+ return msg
12411
+ parts = [head, f"Best matching files under {root} (by distinct keywords matched):"]
12412
+ for r in res["results"]:
12413
+ parts.append(f"\n{r['file']} ({r['terms']}/{len(res['terms'])} keywords, "
12414
+ f"{r['hits']} line(s))")
12415
+ parts.extend(f" {r['file']}:{ln}: {txt}" for ln, txt in r["lines"])
12416
+ parts.append("\nRead the relevant lines with read_file_lines before relying on them.")
12417
+ out = _ws_cap_obs("\n".join(parts), cap=_OBS_CAPS["search"])
12418
+ _last_obs["text"] = out
12419
+ return out
12420
+
12287
12421
  @tool
12288
12422
  def rag_search_workspace(query: str, k: int = 0) -> str:
12289
12423
  """Search the CURRENT PROJECT's knowledge base (built by rag_index_workspace) for code/text relevant to a question. Use for BROAD questions where you don't know the exact symbol to grep for — e.g. "how is auth handled?". For an exact name or string, grep_repo is faster and more precise.
@@ -12293,9 +12427,14 @@ def run_agent_chat(cfg):
12293
12427
  k: number of chunks to return (0 = configured default).
12294
12428
  """
12295
12429
  if not _RAG_AVAILABLE:
12296
- msg = _rag_unavailable_msg()
12297
- _last_obs["text"] = msg
12298
- return msg
12430
+ # Not configured / no embedder: still answer, lexically (bar keeps warning).
12431
+ if rag_enabled:
12432
+ _RAG_STATE["s"] = "unavailable"
12433
+ return _rag_grep_fallback(query, "RAG not available")
12434
+ if _RAG_STATE.get("s") == "unreachable":
12435
+ # The turn's probe already found the embedder down: indexing and query
12436
+ # embedding would both fail (after timeouts). Go straight to the fallback.
12437
+ return _rag_grep_fallback(query, "embedder unreachable")
12299
12438
  try:
12300
12439
  root = _rag_read_allowed(_default_ws_root())
12301
12440
  except Exception as e: # noqa: BLE001
@@ -12370,16 +12509,15 @@ def run_agent_chat(cfg):
12370
12509
  _RAG_STATE["s"] = "no-index"
12371
12510
  _emit({"type": "step",
12372
12511
  "text": f"RAG indexing failed → {_rag_embed_failure_hint(_ie)}"})
12373
- msg = (f"Could not build the knowledge base: {type(_ie).__name__}: {_ie}. "
12374
- "Use grep_repo meanwhile.")
12375
- _last_obs["text"] = msg
12376
- return msg
12512
+ return _rag_grep_fallback(query, f"indexing failed: {type(_ie).__name__}")
12377
12513
  if not _rag_load_all(root):
12378
12514
  _RAG_STATE["s"] = "no-index"
12379
- msg = "Indexing produced nothing to search — use grep_repo instead."
12380
- _last_obs["text"] = msg
12381
- return msg
12382
- return rag_search(root, query, k)
12515
+ return _rag_grep_fallback(query, "indexing produced nothing")
12516
+ out = rag_search(root, query, k)
12517
+ if isinstance(out, str) and out.startswith("Error: rag_search failed"):
12518
+ # Typically the query could not be embedded (embedder went down mid-turn).
12519
+ return _rag_grep_fallback(query, out[len("Error: rag_search failed: "):][:80])
12520
+ return out
12383
12521
 
12384
12522
  @tool
12385
12523
  def grep_repo(pattern: str, path: str = "", glob: str = "") -> str:
package/gu-repl.mjs CHANGED
@@ -2254,8 +2254,18 @@ const ragSegmentText = () => {
2254
2254
  //
2255
2255
  // UNREACHABLE IS THE EXCEPTION and stays visible: an embedder that is DOWN is a fault the user
2256
2256
  // can fix and would want to know about now, not a settled fact about the folder.
2257
- if (!REPL_VERBOSE && b.ragState !== "unreachable") return lexSeg;
2258
- return dim(" · ") + (b.ragState === "unreachable" ? red(label) : dim(label)) + lexSeg;
2257
+ // …BUT ONLY WHEN IT IS THIS SESSION'S VERDICT. A breakdown reloaded from the saved session
2258
+ // (stale: true) carries the LAST turn's probe result — possibly a one-off timeout hours ago.
2259
+ // Showing that in red claimed "the embedder is down now" before anything had checked; it
2260
+ // read as a live fault on a perfectly healthy server. Stale → dimmed and marked "·" like
2261
+ // the carried numbers above, and hidden outside --verbose; the next turn re-probes.
2262
+ const liveDown = b.ragState === "unreachable" && !b.stale;
2263
+ // RAG FAILED MID-TURN and searches were answered by the grep fallback: a WARNING (yellow),
2264
+ // always visible — the turn still got results, but not semantic ones.
2265
+ const liveFallback = b.ragState === "fallback" && !b.stale;
2266
+ if (!REPL_VERBOSE && !liveDown && !liveFallback) return lexSeg;
2267
+ if (b.stale) return dim(" · ") + dim(`${label}·`) + lexSeg;
2268
+ return dim(" · ") + (liveDown ? red(label) : liveFallback ? yellow(label) : dim(label)) + lexSeg;
2259
2269
  };
2260
2270
  /**
2261
2271
  * IS ANYTHING NEWER? (task #181) — cached, never probed from a repaint.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.798",
3
+ "version": "1.0.799",
4
4
  "description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -87,13 +87,15 @@ export function clipVisible(s, max) {
87
87
  export function ragZeroReason(state) {
88
88
  switch (state) {
89
89
  case "unreachable":
90
- return "the embedding server is DOWN — indexing and search would both fail";
90
+ return "the embedding server is DOWN — searches fall back to keyword (grep) search";
91
91
  case "no-index":
92
92
  return "nothing indexed for this project yet — run rag_index_workspace()";
93
93
  case "unavailable":
94
94
  return "RAG is not configured for this workspace";
95
95
  case "ready":
96
96
  return "indexed and reachable — the model chose not to search";
97
+ case "fallback":
98
+ return "RAG failed this turn — searches were answered by keyword (grep) fallback";
97
99
  default:
98
100
  return "the model never called rag_search this turn";
99
101
  }
@@ -106,7 +108,7 @@ export function ragSegmentLabel(b) {
106
108
  if (rag > 0) return null; // …the caller renders the number + percent instead
107
109
  switch (b.ragState) {
108
110
  case "unreachable":
109
- return "rag ✗ embedder down";
111
+ return "rag ✗ embedder down → grep fallback";
110
112
  case "no-index":
111
113
  return "rag — not indexed";
112
114
  case "unavailable":
@@ -123,6 +125,10 @@ export function ragSegmentLabel(b) {
123
125
  return "rag — folder too large; register a project folder";
124
126
  case "indexing-later":
125
127
  return "rag — indexing after this turn";
128
+ // RAG FAILED MID-TURN (indexing or query embedding) and the search was answered by the
129
+ // keyword fallback instead. A warning, not an error: the turn still got results.
130
+ case "fallback":
131
+ return "rag ⚠ unavailable → grep fallback";
126
132
  // Nothing has been measured yet (the state before any turn has run). "0" is a claim we
127
133
  // cannot support; a dash says "no figure", which is the truth.
128
134
  case "unknown":