@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 +44 -3
- package/gonext_agent_chat.py +153 -15
- package/gu-repl.mjs +12 -2
- package/package.json +1 -1
- package/terminal-layout.mjs +8 -2
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
|
-
|
|
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"
|
|
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
|
];
|
package/gonext_agent_chat.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
12297
|
-
|
|
12298
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12380
|
-
|
|
12381
|
-
|
|
12382
|
-
|
|
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
|
-
|
|
2258
|
-
|
|
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.
|
|
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",
|
package/terminal-layout.mjs
CHANGED
|
@@ -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 —
|
|
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":
|