"""The wide net — every candidate that might govern what you are about to do (#4103). WHY THIS EXISTS Milestone 416 step 5, from the operator's compromise: "if you're worried about excluding potentially important data let limit it to 50 entries or something like that" Fifty in the PUSH would be milestone 394 with extra steps — a wall the reader skims, with the governing record indistinguishable from forty-nine others. Fifty in a PULL is a different object: it arrives only when a session asks, it crowds nothing out, and it is the honest home for "do not exclude potentially important data". WHY IT IS NOT `search(content_type="rule")` That tool is the DEEP pull and should stay that way — `_search_rules` returns `statement`, `why` and `how_to_apply` in full, on the reasoning that a caller who went looking deserves the whole record rather than a summary to re-fetch. Six results already run to thousands of tokens. This is the SHALLOW pull: many candidates, each just enough to decide whether to open it. Opposite trade-off, so it is a second tool rather than a bigger `limit` on the first. WHY THE TAIL IS TRUNCATED RATHER THAN FULL Because the step's premise needed correcting. The task said fifty "costs nothing", and `_rule_hint_line` had already measured otherwise: a line runs ~143 tokens once its trigger is rendered, and #3855 tripled trigger lengths across the corpus. Fifty of those is ~7,000 tokens — cheap next to an arm that fires before every Bash call, but not free, and a tool that promises a free wide net gets reached for casually and then regretted. So this reuses the graduated shape #3851 measured for the push: the top few carry their trigger in full, the rest carry a cut of it. TRUNCATED, never dropped — the trigger is what lets a reader judge relevance without opening the record, and a teaser without one is just an id. """ from __future__ import annotations import textwrap import time from scribe.mcp._context import current_user_id from scribe.services.embeddings import semantic_search_rules from scribe.services.retrieval_telemetry import record_retrieval # The telemetry `source`, and a PULL — never added to AMBIENT_SOURCES (those # are deliveries nobody chose) and never confused with a push arm. Keeping it # separate is load-bearing right now: the push arms' near-miss distributions # are the evidence #4121 rests on, and a pull mixed into them would move the # very numbers that step is arguing from. SOURCE = "wide_net" # No bar. That is the point of the tool rather than an oversight — the caller # asked for the wide net precisely because they do not trust a bar to decide # for them here. Every row carries its score, so the reader sees where the # ranking falls off and judges it themselves. THRESHOLD = 0.0 MAX_LIMIT = 50 DEFAULT_LIMIT = 25 # How many candidates carry their trigger in full before the rest are cut. DEFAULT_DETAIL = 5 # The cut length for the tail. Long enough to carry the first clause of a # trigger — which is where these state the act they are about — short enough # that forty-five of them stay affordable. _TEASER_CHARS = 140 def _teaser(text: str) -> tuple[str, bool]: """Shorten at a word break with a visible cut. Returns (text, was_cut). The technique is `plugin_context._goal_line`'s, and the fallback is the part worth copying: `textwrap.shorten` returns a bare "…" when the string is one unbroken word longer than the cap, and a raw slice ends mid-word claiming to be the whole thing (#4036). Kept local rather than shared because the two callers wrap it in different sentences; if a third appears, that is the moment to extract it rather than now. """ flat = " ".join((text or "").split()) if len(flat) <= _TEASER_CHARS: return flat, False short = textwrap.shorten(flat, width=_TEASER_CHARS, placeholder="…") if short == "…": short = flat[: _TEASER_CHARS - 1] + "…" return short, True async def what_might_apply( query: str, limit: int = DEFAULT_LIMIT, detail: int = DEFAULT_DETAIL, kind: str = "", project_id: int = 0, ) -> dict: """Every rule that might bear on what you are about to do, ranked, with no bar. REACH FOR THIS BEFORE A CONSEQUENTIAL OR IRREVERSIBLE ACT — a push, a merge, a delete, a deploy, anything outward-facing — and before handing work back because you are unsure whether you are allowed to finish it. It is the tool the "ask before a consequential act" reflex should call. A rule reaches a session by retrieval, and the arms that push rules at you have a small budget: they deliver the few highest-scoring candidates and say nothing about what sat just underneath. That is right for an arm that fires before every command and wrong for the one moment you actually want to be sure. This is that moment's tool. WHAT IT RETURNS, AND WHY THE TAIL LOOKS LIKE NOISE There is no threshold. You get the `limit` nearest candidates whatever they score, ordered, each with its score — so the tail IS expected to be irrelevant, and that is the design. You are not reading the list for its average quality; you are reading it for the one record you would otherwise have missed. Scan the triggers, open what looks live with `get_rule(id)`, and ignore the rest. The first few carry their trigger in full; the rest carry a cut of it, marked `truncated`. A cut trigger is still enough to decide whether to open the record, which is the whole job of a teaser. `kind` is on every row and is never something to infer: a **rule** must be followed, a **preference** records how the operator wants work done. Missing a rule is a mistake; missing a preference costs consistency. A NOTE ON WHAT THIS CANNOT DO. It is a pull, so it only helps if you ask. The failure it was built for — a session withholding a routine action because the rule permitting it never arrived — produces no tool call of its own, so nothing will prompt you. Asking is the habit; this is where to put it. Args: query: what you are about to do, in the words you would use to describe it — "push to dev after committing", "delete the staging database", "merge dev to main". A command string works; a sentence usually works better, because triggers are written as prose. limit: how many candidates, default 25, capped at 50. detail: how many carry their trigger in FULL before the rest are cut, default 5. kind: "rule" or "preference" to restrict; omit for both. project_id: scope to one project — its own rules plus every global one. Omit to ask the whole rulebook. """ uid = current_user_id() limit = max(1, min(int(limit), MAX_LIMIT)) detail = max(0, min(int(detail), limit)) report: dict = {} started = time.perf_counter() if project_id: raw = await semantic_search_rules( uid, query, limit=limit, threshold=THRESHOLD, kind=kind or None, report=report, project_id=project_id, ) else: raw = await semantic_search_rules( uid, query, limit=limit, threshold=THRESHOLD, kind=kind or None, report=report, everywhere=True, ) duration_ms = (time.perf_counter() - started) * 1000 candidates = [] for rank, (score, rule) in enumerate(raw): full = rank < detail trigger, was_cut = ( (" ".join((rule.when_to_apply or "").split()), False) if full else _teaser(rule.when_to_apply or "") ) candidates.append({ "id": rule.id, "title": rule.title, # Force, not topic. See the docstring — this is never inferred. "kind": rule.kind, # A rule in a rulebook topic is global; one on a project binds # there alone (milestone 414). Which it is changes how far a # reader should generalise from it. "scope": "project" if rule.project_id else "global", "when_to_apply": trigger, "truncated": was_cut, "score": round(float(score), 4), }) # Logged as a pull, with `searched` honoured: a search that never ran must # not be recorded as a ranker declining (#3765). record_retrieval( user_id=uid, source=SOURCE, query=query, threshold=THRESHOLD, limit=limit, project_id=project_id or None, is_task=None, results=raw, duration_ms=duration_ms, best_available=report.get("best_available_score"), best_available_id=report.get("best_available_id"), searched=report.get("searched", True), ) return { "candidates": candidates, "returned": len(candidates), "detailed": min(detail, len(candidates)), # Present so a caller can tell "the corpus offered nothing" from "the # search never ran" — an empty query, an unavailable embedder and a # failed query all return zero rows and mean different things (#3670). "searched": bool(report.get("searched", True)), "open_with": "get_rule(id)", } def register(mcp) -> None: mcp.tool(name="what_might_apply")(what_might_apply)