@allansantos-dev/smart-tool 0.9.40 → 0.9.41

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/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.41 - beta
4
+
5
+ - Far fewer calls to the embedding and rerank models. Measured on a real machine (24 h): 8303 embedding and 1473 rerank
6
+ calls, 0.3% of them failing; the volume came from four places, all cut: (1) an indexing job no longer embeds a fixed
7
+ text to learn the vector size when the index already stores it for the same model (1300 calls a day); (2) embedding
8
+ batches carry up to 128 chunks and 120,000 characters instead of 32,000 characters (3.8 chunks per call before); a
9
+ request the gateway refuses for its size is split in two halves once; (3) one rerank call per search instead of one
10
+ per block (code, test, doc); the blocks keep the order the separate calls gave; (4) a project in `eager` mode whose
11
+ files change all the time waits longer between jobs (60, 120, 240, 480 up to 900 s, back to 60 s after a quiet
12
+ period), and `*.tmp`, `*.bak`, `*.swp` and `*~` files no longer mark a project as changed or enter the index.
13
+ Measurement before and after in `docs/research/data/call-volume-*.json`, findings and sources in
14
+ `docs/research/2026-10-27-excesso-de-chamadas-embed-rerank.md`. Tests in `tests/test_call_volume.py`.
15
+ - A model that answers 429 or 503 three times in a row opens a circuit for 30 s: calls to it fail at once and the search
16
+ answers from the lexical index with the usual warning, instead of repeating requests. At most 6 requests are in
17
+ flight per model. Rerank and the model check wait the gateway's `Retry-After` (or a jittered exponential delay) before
18
+ asking again, like embeddings.
19
+ - The setup Models page groups the failures by type (429, 503, other 5xx, timeout, connection, authentication, other):
20
+ one row per type and role with the count, first and last time, one sample message, and the latest 20 lines on
21
+ expand. The summary shows the calls per purpose (indexing, query, rerank, model check). `dependency_mode` has its own
22
+ card in the setup rules.
23
+ - The dependency notice also reads `requirements*.txt` (PyPI, `==` pins and the lower bound of `>=` and `~=`) and `go.mod`
24
+ (Go, `require` lines with a plain `vX.Y.Z`). `web_fetch` reads PDFs with the same page and character caps as the
25
+ indexer (`document_text`).
26
+
3
27
  ## 0.9.40 - beta
4
28
 
5
29
  - PDFs of the project are searchable. `smart_search` indexes the text layer of a `.pdf` inside the project scope and
package/README.md CHANGED
@@ -16,7 +16,7 @@ registered as the `playwright` server where this machine lets a browser be drive
16
16
  An optional hook routes the agent's native Grep/Glob/Read/Bash/WebSearch/WebFetch calls to these tools when they
17
17
  are cheaper, or just tells the agent what Smart Tool would do.
18
18
 
19
- **Status:** 0.9.40 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
19
+ **Status:** 0.9.41 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
20
20
 
21
21
  ## How it works
22
22
 
@@ -129,7 +129,7 @@ Redirects) and Maintenance (Setup audit, Startup).
129
129
  When a new version recommends other models than the ones you use, a Windows notification (**Apply now**, **Don't
130
130
  show again**) and the **New recommended models** card on this page offer them: nothing changes until you apply, the
131
131
  models you had are kept, and **Switch back to my previous models** undoes it. The **Model failures (last 24 h)** table
132
- lists every failed gateway call by role, model and tool; a model that fails half of its calls (5 or more) also raises a
132
+ groups the failed gateway calls by type (429, 503, other 5xx, timeout, connection, authentication) and role, with the count, the first and last time and one sample, and shows the calls per purpose; a model that fails half of its calls (5 or more) also raises a
133
133
  notification and a tray alert. A failure inside a tool call also comes back as a warning line in the tool answer;
134
134
  calls made by a hook or by background work (index scope and profile, block-pattern proposals) have no answer to
135
135
  carry it, so they show only in the table, the log and the notification. The local embedder is not in that table or in
@@ -189,7 +189,7 @@ Redirects) and Maintenance (Setup audit, Startup).
189
189
  A documented function edited only in its body is not mentioned. `project_manage` with `action=docs` lists the
190
190
  public functions still without a docstring, for documenting a project that started without them.
191
191
 
192
- **Dependency check on edit**: an edit of `package.json` that adds or changes a dependency lists the OSV advisory IDs of up to 5 of them (exact version, or the lower bound of `^`, `~` and `>=`), answered from a 6 h disk cache or one 2.5 s batch call that sends only package names and versions; when OSV cannot answer, the reminder says the check was skipped and the edit goes on. `dependency_mode` in `config.json`: `warn` (default) or `off`.
192
+ **Dependency check on edit**: an edit of `package.json`, `requirements*.txt` or `go.mod` that adds or changes a dependency lists the OSV advisory IDs of up to 5 of them (exact version, or the lower bound of `^`, `~`, `~=` and `>=`), answered from a 6 h disk cache or one 2.5 s batch call that sends only package names and versions; when OSV cannot answer, the reminder says the check was skipped and the edit goes on. `dependency_mode` in `config.json`: `warn` (default) or `off`.
193
193
 
194
194
  **PDFs** inside a project are indexed as text with `[page N]` markers (20 MiB, 300 pages and 20 s per file; a scanned, password-protected or corrupt PDF is skipped with the reason), so `smart_search` returns the page that holds a passage.
195
195
 
package/config.py CHANGED
@@ -62,7 +62,7 @@ DOC_MODES = ("require", "remind", "off")
62
62
  # Edit hook on functions that copy one already indexed (exact or near-identical body): warn = let the edit run and
63
63
  # point to the existing function; off = say nothing. Never blocks: near matches are leads, not defects.
64
64
  DUPLICATE_MODES = ("warn", "off")
65
- # Edit hook on package.json: warn = let the edit run and list the added or changed dependencies that have OSV advisories;
65
+ # Edit hook on package.json, requirements*.txt and go.mod: warn = let the edit run and list the added or changed dependencies that have OSV advisories;
66
66
  # off = say nothing and make no network call.
67
67
  DEPENDENCY_MODES = ("warn", "off")
68
68
  # Router model reviewing, in the background, searches the deterministic redirect rule let run: on = propose block
@@ -1,10 +1,12 @@
1
- """Vulnerable-dependency notice for an edit of package.json, built before the edit runs.
1
+ """Vulnerable-dependency notice for an edit of a dependency manifest, built before the edit runs.
2
2
 
3
- Compares the dependency tables of the file before and after the edit, asks OSV (one `querybatch` call, short timeout)
4
- about the added or changed ones only, and answers from a disk cache of (package, version) -> advisory IDs for 6 hours.
5
- Only package names and versions leave the machine. The caller (`hook_decision`) turns a `DependencyCheckError` into
3
+ Manifests: package.json (OSV ecosystem npm), requirements*.txt (PyPI: `==` pins and the lower bound of `>=` and `~=`)
4
+ and go.mod (Go: `require` lines with a plain vX.Y.Z). Compares the dependencies of the file before and after the edit,
5
+ asks OSV (one `querybatch` call, short timeout) about the added or changed ones only, and answers from a disk cache of
6
+ (ecosystem, package, version) -> advisory IDs for 6 hours. Only package names and versions leave the machine. The caller (`hook_decision`) turns a `DependencyCheckError` into
6
7
  "dependency check skipped: <reason>" and lets the edit go on.
7
8
  """
9
+ import collections
8
10
  import json
9
11
  import os
10
12
  import re
@@ -23,13 +25,17 @@ TABLES = ("dependencies", "devDependencies", "peerDependencies", "optionalDepend
23
25
  PREFIX = "Smart Tool dependencies: "
24
26
  _SPECIFIER_RE = re.compile(r"(\^|~|>=|>|=)?\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?"
25
27
  r"((?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)")
28
+ _REQUIREMENTS_FILE_RE = re.compile(r"requirements[A-Za-z0-9._-]*\.txt")
29
+ _REQUIREMENT_RE = re.compile(r"([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*([^;]*)")
30
+ _PIP_VERSION_RE = re.compile(r"[0-9][0-9A-Za-z.+!_-]*")
31
+ _GO_VERSION_RE = re.compile(r"v\d+\.\d+\.\d+")
26
32
 
27
33
 
28
34
  class DependencyCheckError(RuntimeError):
29
35
  """The check could not run (OSV unreachable or answering badly, unreadable cache, current file not JSON)."""
30
36
 
31
37
 
32
- def _tables(text):
38
+ def _npm_pairs(text):
33
39
  """The (name, specifier) pairs of every dependency table in a package.json text.
34
40
 
35
41
  Parameters: text (str) file content.
@@ -49,32 +55,91 @@ def _tables(text):
49
55
  return pairs
50
56
 
51
57
 
52
- def changed_dependencies(before, after):
53
- """Dependencies added or whose specifier changed between two package.json texts.
58
+ def _requirements_pairs(text):
59
+ """The (name, specifier) pairs of a requirements.txt text; options, includes, editable and URL lines are skipped.
54
60
 
55
- Parameters: before (str or None) text before the edit, None when the file did not exist; after (str) text after.
56
- Returns: list of (name, specifier); empty when `after` is not valid JSON (the user is mid-edit).
57
- Raises: DependencyCheckError when `before` exists but is not valid JSON.
61
+ Parameters: text (str) file content.
62
+ Returns: dict (name, specifier) -> None in file order; the specifier is the version clauses without spaces ("" when
63
+ the line only names the package).
64
+ """
65
+ pairs = {}
66
+ for raw in text.splitlines():
67
+ line = raw.split(" #")[0].split(";")[0].strip().rstrip("\\").strip()
68
+ if not line or line.startswith(("#", "-")):
69
+ continue
70
+ line = " ".join(word for word in line.split() if not word.startswith("--"))
71
+ match = _REQUIREMENT_RE.fullmatch(line)
72
+ if not match:
73
+ continue
74
+ specifier = "".join(match.group(2).split())
75
+ if specifier and specifier[0] not in "=<>!~":
76
+ continue
77
+ pairs[(match.group(1), specifier)] = None
78
+ return pairs
79
+
80
+
81
+ def _go_pairs(text):
82
+ """The (module, version) pairs of the `require` lines of a go.mod text, single-line and block; replace, exclude and
83
+ the other directives are skipped.
84
+
85
+ Parameters: text (str) file content.
86
+ Returns: dict (module, version) -> None in file order.
58
87
  """
59
- now = _tables(after)
88
+ pairs, block = {}, None
89
+ for raw in text.splitlines():
90
+ tokens = raw.split("//")[0].split()
91
+ if not tokens:
92
+ continue
93
+ if block is None:
94
+ if tokens[0] == "require" and len(tokens) == 3:
95
+ pairs[(tokens[1], tokens[2])] = None
96
+ elif tokens[-1] == "(":
97
+ block = tokens[0]
98
+ elif tokens == [")"]:
99
+ block = None
100
+ elif block == "require" and len(tokens) == 2:
101
+ pairs[(tokens[0], tokens[1])] = None
102
+ return pairs
103
+
104
+
105
+ def ecosystem_of(path):
106
+ """The OSV ecosystem of a manifest file name.
107
+
108
+ Parameters: path (str) file path.
109
+ Returns: "npm" for package.json, "PyPI" for requirements*.txt, "Go" for go.mod; None for any other file.
110
+ """
111
+ name = os.path.basename(path)
112
+ if name == "package.json":
113
+ return "npm"
114
+ if name == "go.mod":
115
+ return "Go"
116
+ return "PyPI" if _REQUIREMENTS_FILE_RE.fullmatch(name) else None
117
+
118
+
119
+ def changed_dependencies(before, after, ecosystem="npm"):
120
+ """Dependencies added or whose specifier changed between two manifest texts.
121
+
122
+ Parameters: before (str or None) text before the edit, None when the file did not exist; after (str) text after;
123
+ ecosystem (str) one of ECOSYSTEMS.
124
+ Returns: list of (name, specifier); empty when `after` is a package.json that is not valid JSON (mid-edit).
125
+ Raises: DependencyCheckError when `before` exists but is a package.json that is not valid JSON.
126
+ """
127
+ parse = _ECOSYSTEMS[ecosystem].parse
128
+ now = parse(after)
60
129
  if now is None:
61
130
  return []
62
131
  if before is None or not before.strip():
63
132
  old = {}
64
133
  else:
65
- old = _tables(before)
134
+ old = parse(before)
66
135
  if old is None:
67
136
  raise DependencyCheckError("current package.json is not valid JSON")
68
137
  return [pair for pair in now if pair not in old]
69
138
 
70
139
 
71
- def queried_version(specifier):
72
- """The version to ask OSV about for a specifier.
73
-
74
- Parameters: specifier (str) the value in the dependency table.
75
- Returns: str, the exact version or the lower bound of a ^, ~, >= or > range; None when the specifier cannot be
76
- reduced safely (tags, `*`, URLs, file:, git, workspace:, npm: aliases, x-ranges, compound ranges).
77
- """
140
+ def _npm_version(specifier):
141
+ """The npm case of `queried_version`: the exact version or the lower bound of a ^, ~, >= or > range; None for tags,
142
+ `*`, URLs, file:, git, workspace:, npm: aliases, x-ranges and compound ranges."""
78
143
  match = _SPECIFIER_RE.fullmatch(specifier.strip())
79
144
  if not match:
80
145
  return None
@@ -87,6 +152,42 @@ def queried_version(specifier):
87
152
  return ".".join(parts) + (tail or "")
88
153
 
89
154
 
155
+ def _pip_version(specifier):
156
+ """The PyPI case of `queried_version`: the `==` version, else the first `>=` or `~=` lower bound; None for no
157
+ specifier, `===`, wildcards, `<`, `>`, `!=` alone and anything that is not a version."""
158
+ clauses = specifier.split(",")
159
+ for operators in (("==",), (">=", "~=")):
160
+ for clause in clauses:
161
+ for operator in operators:
162
+ if clause.startswith(operator) and _PIP_VERSION_RE.fullmatch(clause[len(operator):]):
163
+ return clause[len(operator):]
164
+ return None
165
+
166
+
167
+ def _go_version(specifier):
168
+ """The Go case of `queried_version`: a plain vX.Y.Z; None for pseudo-versions, pre-releases and +incompatible."""
169
+ return specifier if _GO_VERSION_RE.fullmatch(specifier) else None
170
+
171
+
172
+ def queried_version(specifier, ecosystem="npm"):
173
+ """The version to ask OSV about for a specifier.
174
+
175
+ Parameters: specifier (str) the value in the dependency table; ecosystem (str) one of ECOSYSTEMS.
176
+ Returns: str, the exact version or the lower bound of the range; None when the specifier cannot be reduced safely.
177
+ """
178
+ return _ECOSYSTEMS[ecosystem].version(specifier)
179
+
180
+
181
+ _Ecosystem = collections.namedtuple("_Ecosystem", "parse version label exact")
182
+ _ECOSYSTEMS = {
183
+ "npm": _Ecosystem(_npm_pairs, _npm_version, lambda name, spec: f"{name}@{spec}", lambda spec, version: spec == version),
184
+ "PyPI": _Ecosystem(_requirements_pairs, _pip_version, lambda name, spec: f"{name}{spec}",
185
+ lambda spec, version: spec == "==" + version),
186
+ "Go": _Ecosystem(_go_pairs, _go_version, lambda name, spec: f"{name}@{spec}", lambda spec, version: spec == version),
187
+ }
188
+ ECOSYSTEMS = tuple(_ECOSYSTEMS)
189
+
190
+
90
191
  def _load_cache():
91
192
  """The cache as {"name@version": {"ids": [...], "at": epoch}}; empty when the file does not exist.
92
193
 
@@ -111,26 +212,27 @@ def _fresh_ids(entry, now):
111
212
  return entry["ids"] if now - entry["at"] < CACHE_TTL_S else None
112
213
 
113
214
 
114
- def _advisories(queries):
215
+ def _advisories(ecosystem, queries):
115
216
  """Advisory IDs for (name, version) pairs, from the cache and one OSV batch for the rest.
116
217
 
117
- Parameters: queries (list of (name, version)).
218
+ Parameters: ecosystem (str) one of ECOSYSTEMS; queries (list of (name, version)).
118
219
  Returns: tuple (list of IDs or None per query in order, str cache-write problem or "").
119
220
  Raises: DependencyCheckError when the cache is corrupt or OSV cannot answer.
120
221
  """
121
222
  cache, now = _load_cache(), time.time()
122
- found = {query: _fresh_ids(cache.get("@".join(query)), now) for query in queries}
223
+ key = lambda query: f"{ecosystem}:{query[0]}@{query[1]}" # noqa: E731
224
+ found = {query: _fresh_ids(cache.get(key(query)), now) for query in queries}
123
225
  missing = [query for query in queries if found[query] is None]
124
226
  if not missing:
125
227
  return [found[query] for query in queries], ""
126
228
  try:
127
- answered = freshness.npm_advisories_batch(missing, OSV_TIMEOUT_S)
229
+ answered = freshness.advisories_batch(ecosystem, missing, OSV_TIMEOUT_S)
128
230
  except RuntimeError as exc:
129
231
  raise DependencyCheckError(str(exc)) from exc
130
232
  for query, ids in zip(missing, answered):
131
233
  found[query] = ids
132
234
  if ids is not None:
133
- cache["@".join(query)] = {"ids": ids, "at": now}
235
+ cache[key(query)] = {"ids": ids, "at": now}
134
236
  cache = {key: entry for key, entry in cache.items() if _fresh_ids(entry, now) is not None}
135
237
  try:
136
238
  os.makedirs(os.path.dirname(CACHE_PATH), exist_ok=True)
@@ -140,28 +242,30 @@ def _advisories(queries):
140
242
  return [found[query] for query in queries], ""
141
243
 
142
244
 
143
- def notice(before, after):
144
- """The dependency notice for one package.json edit.
245
+ def notice(before, after, ecosystem="npm"):
246
+ """The dependency notice for one manifest edit.
145
247
 
146
- Parameters: before (str or None) file text before the edit; after (str) text after it.
248
+ Parameters: before (str or None) file text before the edit; after (str) text after it; ecosystem (str) one of
249
+ ECOSYSTEMS, from `ecosystem_of`.
147
250
  Returns: str, "Smart Tool dependencies: ..." lines for the changed dependencies that have advisories or could not
148
251
  be verified, "" when there is nothing to say.
149
252
  Raises: DependencyCheckError when the check could not run.
150
253
  """
151
- changed = changed_dependencies(before, after)
254
+ spec = _ECOSYSTEMS[ecosystem]
255
+ changed = changed_dependencies(before, after, ecosystem)
152
256
  handled, extra = changed[:MAX_PACKAGES], len(changed) - MAX_PACKAGES
153
257
  lines, queries = {}, []
154
258
  for name, specifier in handled:
155
- version = queried_version(specifier) if freshness.NPM_NAME_RE.fullmatch(name) else None
259
+ version = spec.version(specifier) if freshness.NAME_RES[ecosystem].fullmatch(name) else None
156
260
  if version is None:
157
- lines[(name, specifier)] = f"`{name}@{specifier}` specifier not verified."
261
+ lines[(name, specifier)] = f"`{spec.label(name, specifier)}` specifier not verified."
158
262
  else:
159
263
  queries.append((name, specifier, version))
160
264
  problem = ""
161
265
  if queries:
162
- ids, problem = _advisories([(name, version) for name, _specifier, version in queries])
266
+ ids, problem = _advisories(ecosystem, [(name, version) for name, _specifier, version in queries])
163
267
  for (name, specifier, version), advisories in zip(queries, ids):
164
- label = f"`{name}@{specifier}`" + ("" if version == specifier else f" (checked {version})")
268
+ label = f"`{spec.label(name, specifier)}`" + ("" if spec.exact(specifier, version) else f" (checked {version})")
165
269
  if advisories is None:
166
270
  lines[(name, specifier)] = f"{label} advisory check incomplete, not verified."
167
271
  elif advisories:
package/freshness.py CHANGED
@@ -73,11 +73,13 @@ def npm_registry_evidence(package):
73
73
 
74
74
 
75
75
  NPM_NAME_RE = re.compile(r"(?:@[a-z0-9._-]+/)?[a-z0-9._-]+")
76
+ NAME_RES = {"npm": NPM_NAME_RE, "PyPI": re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*"),
77
+ "Go": re.compile(r"[A-Za-z0-9][A-Za-z0-9._~/-]*")}
76
78
 
77
79
 
78
- def _osv_query(package, package_version):
79
- """One OSV query object: the package (npm) and the version asked about."""
80
- return {"package": {"name": package, "ecosystem": "npm"}, "version": package_version}
80
+ def _osv_query(ecosystem, package, package_version):
81
+ """One OSV query object: the package in `ecosystem` and the version asked about."""
82
+ return {"package": {"name": package, "ecosystem": ecosystem}, "version": package_version}
81
83
 
82
84
 
83
85
  def _osv_request(endpoint, payload):
@@ -107,7 +109,7 @@ def npm_advisories(package, package_version):
107
109
  """IDs conhecidos pela OSV para a versão; None significa checagem indisponível."""
108
110
  if not package or not package_version or not NPM_NAME_RE.fullmatch(package):
109
111
  return None
110
- request = _osv_request("query", _osv_query(package, package_version))
112
+ request = _osv_request("query", _osv_query("npm", package, package_version))
111
113
  try:
112
114
  with urllib.request.urlopen(request, timeout=7) as response:
113
115
  return _osv_ids(json.load(response))
@@ -115,17 +117,21 @@ def npm_advisories(package, package_version):
115
117
  return None
116
118
 
117
119
 
118
- def npm_advisories_batch(packages, timeout):
120
+ def advisories_batch(ecosystem, packages, timeout):
119
121
  """Advisory IDs for each (package, version) of `packages` from one OSV `querybatch` call.
120
122
 
121
- Parameters: packages (list of (name, version)); timeout (float) seconds for the request.
123
+ Parameters: ecosystem (str) "npm", "PyPI" or "Go"; packages (list of (name, version)); timeout (float) seconds for
124
+ the request.
122
125
  Returns: list in the same order, each the sorted ID list or None when that package's answer is incomplete
123
126
  (next_page_token) or malformed, so it is unverified and not clean.
124
- Raises: RuntimeError when the service cannot be reached, answers badly or answers a different number of results.
127
+ Raises: ValueError for an unknown ecosystem or an invalid name or version; RuntimeError when the service cannot be
128
+ reached, answers badly or answers a different number of results.
125
129
  """
126
- if any(not NPM_NAME_RE.fullmatch(name) or not package_version for name, package_version in packages):
127
- raise ValueError(f"Invalid npm package query: {packages!r}")
128
- request = _osv_request("querybatch", {"queries": [_osv_query(name, package_version)
130
+ if ecosystem not in NAME_RES:
131
+ raise ValueError(f"Unknown OSV ecosystem: {ecosystem!r}")
132
+ if any(not NAME_RES[ecosystem].fullmatch(name) or not package_version for name, package_version in packages):
133
+ raise ValueError(f"Invalid {ecosystem} package query: {packages!r}")
134
+ request = _osv_request("querybatch", {"queries": [_osv_query(ecosystem, name, package_version)
129
135
  for name, package_version in packages]})
130
136
  try:
131
137
  with urllib.request.urlopen(request, timeout=timeout) as response:
package/hook_decision.py CHANGED
@@ -230,20 +230,21 @@ def _edit_review(payload, doc_mode, duplicate_mode, dependency_mode):
230
230
  """Checks of an edit before it runs. Docstrings follow doc_mode, in code files of registered projects only (tests,
231
231
  docs and files outside a project are left out, as in the docs coverage): missing ones deny the edit in require mode and become a reminder in remind mode; a
232
232
  documented function whose signature changed always gets a reminder. Copies of indexed functions follow
233
- duplicate_mode and only add context, never deny. An edit of a package.json follows dependency_mode: advisories
233
+ duplicate_mode and only add context, never deny. An edit of a package.json, requirements*.txt or go.mod follows dependency_mode: advisories
234
234
  of the added or changed dependencies are added as context, never a denial."""
235
235
  with code_graph.pending_scope():
236
236
  return _edit_review_scoped(payload, doc_mode, duplicate_mode, dependency_mode)
237
237
 
238
238
 
239
- def _dependency_part(before, after):
240
- """The dependency notice of one package.json edit, or the "dependency check skipped" line when it cannot run.
239
+ def _dependency_part(before, after, ecosystem):
240
+ """The dependency notice of one manifest edit, or the "dependency check skipped" line when it cannot run.
241
241
 
242
- Parameters: before (str or None) text before the edit; after (str) text after it.
242
+ Parameters: before (str or None) text before the edit; after (str) text after it; ecosystem (str) from
243
+ `dependency_notice.ecosystem_of`.
243
244
  Returns: str, empty when there is nothing to say.
244
245
  """
245
246
  try:
246
- return dependency_notice.notice(before, after)
247
+ return dependency_notice.notice(before, after, ecosystem)
247
248
  except dependency_notice.DependencyCheckError as exc:
248
249
  return f"{dependency_notice.PREFIX}dependency check skipped: {exc}"
249
250
 
@@ -252,8 +253,9 @@ def _edit_review_scoped(payload, doc_mode, duplicate_mode, dependency_mode):
252
253
  """`_edit_review` inside one `code_graph.pending_scope`, so the pending-changes scan runs once per review."""
253
254
  doc_findings, duplicate_findings, failures, dependency_parts = [], [], [], []
254
255
  for path, before, after in edit_preview.preview(payload.get("tool_name"), payload.get("tool_input"), payload.get("cwd")):
255
- if dependency_mode != "off" and os.path.basename(path) == "package.json":
256
- dependency_parts += [part for part in (_dependency_part(before, after),) if part]
256
+ ecosystem = dependency_notice.ecosystem_of(path)
257
+ if dependency_mode != "off" and ecosystem:
258
+ dependency_parts += [part for part in (_dependency_part(before, after, ecosystem),) if part]
257
259
  continue
258
260
  if not doc_check.supported(path):
259
261
  continue
package/index_scope.py CHANGED
@@ -40,10 +40,13 @@ _PROJECT_TYPE_EXCLUDES = (
40
40
  (("Podfile",), ("Pods",)),
41
41
  )
42
42
 
43
+ # Files a tool writes while it works and replaces or deletes right after (atomic-write temp, backup, editor swap).
44
+ TRANSIENT_FILES = {"*.tmp", "*.bak", "*.swp", "*~"}
45
+
43
46
  # Mesmo critério do `_ALWAYS_EXCLUDE`, para arquivo: o significado não varia por
44
47
  # projeto. Lockfile é grafo de dependência resolvido por ferramenta — nunca é fonte de
45
48
  # resposta numa busca semântica, e é grande o bastante pra dominar o índice.
46
- _ALWAYS_EXCLUDE_FILES = {
49
+ _ALWAYS_EXCLUDE_FILES = TRANSIENT_FILES | {
47
50
  "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "npm-shrinkwrap.json",
48
51
  "poetry.lock", "pdm.lock", "uv.lock", "Pipfile.lock", "Cargo.lock",
49
52
  "composer.lock", "go.sum", "Gemfile.lock", "packages.lock.json",
@@ -110,6 +113,11 @@ def normalize_include(include):
110
113
  return bases, dropped
111
114
 
112
115
 
116
+ def is_transient(name):
117
+ """Whether a file name is a temporary, backup or editor-swap file (`*.tmp`, `*.bak`, `*.swp`, `*~`)."""
118
+ return any(fnmatch.fnmatch(name.casefold(), pattern) for pattern in TRANSIENT_FILES)
119
+
120
+
113
121
  def always_excluded(name):
114
122
  """Directory name in _ALWAYS_EXCLUDE, entries may be globs (e.g. *.egg-info)."""
115
123
  folded = name.casefold()
package/indexer.py CHANGED
@@ -31,8 +31,10 @@ CHUNK_MAX_CHARS = 8000
31
31
  CHUNK_OVERLAP_LINES = 12
32
32
  CHUNKER_VERSION = 3
33
33
  SCHEMA_VERSION = 2
34
- REINDEX_BATCH_MAX_CHARS = 32_000
35
- REINDEX_BATCH_MAX_ITEMS = 32
34
+ # An embeddings request holds up to 128 items and 120,000 characters (about 30,000 tokens): far under the 2048 items and
35
+ # 300,000 tokens per request that OpenAI and Azure accept (docs/research/2026-10-27-excesso-de-chamadas-embed-rerank.md).
36
+ REINDEX_BATCH_MAX_CHARS = 120_000
37
+ REINDEX_BATCH_MAX_ITEMS = 128
36
38
  # Embedding requests in flight per flush. Measured 2026-10-03 (docs/spikes/embed_parallel_spike.py, 30 batches,
37
39
  # 3 interleaved rounds): 1 worker 33-50 s, 2 workers 19-27 s, 4 workers 11-20 s (~2.8x), no 429 from the gateway.
38
40
  EMBED_WORKERS = 4
@@ -0,0 +1,70 @@
1
+ """Protection of the model gateway, per model id: a circuit that opens after consecutive 429/503 answers and a cap on
2
+ the requests in flight.
3
+
4
+ Pattern: a circuit breaker (closed, open for a pause, then one trial call decides). Retrying a gateway that answers
5
+ busy only adds load (AWS Bedrock throughput guide: honor Retry-After, back off with jitter); once it keeps answering
6
+ busy the caller stops asking for a while and serves what it can without the model.
7
+ """
8
+ import contextlib
9
+ import threading
10
+ import time
11
+ import urllib.error
12
+
13
+ FAILURES_TO_OPEN = 3
14
+ PAUSE_S = 30
15
+ MAX_IN_FLIGHT = 6
16
+ _BUSY_CODES = (429, 503)
17
+ _lock = threading.Lock()
18
+ _failures = {}
19
+ _open_until = {}
20
+ _slots = {}
21
+
22
+
23
+ class CircuitOpen(RuntimeError):
24
+ """The model is paused after repeated busy answers; no request was sent."""
25
+
26
+
27
+ def guard(model):
28
+ """Raises CircuitOpen while `model` is paused. Nothing happens for a call without a model id."""
29
+ if not model:
30
+ return
31
+ with _lock:
32
+ left = _open_until.get(model, 0) - time.monotonic()
33
+ if left > 0:
34
+ raise CircuitOpen(f"Model {model} is paused for {int(left) + 1} s after {FAILURES_TO_OPEN} consecutive "
35
+ "HTTP 429/503 answers from the gateway; no request was sent.")
36
+
37
+
38
+ def note(model, failure=None):
39
+ """Records the outcome of a request: a success closes the circuit, a 429/503 counts toward opening it, any other
40
+ failure changes nothing. After the pause the first failure opens it again."""
41
+ if not model:
42
+ return
43
+ busy = isinstance(failure, urllib.error.HTTPError) and failure.code in _BUSY_CODES
44
+ with _lock:
45
+ if failure is None:
46
+ _failures[model] = 0
47
+ elif busy:
48
+ _failures[model] = _failures.get(model, 0) + 1
49
+ if _failures[model] >= FAILURES_TO_OPEN:
50
+ _open_until[model] = time.monotonic() + PAUSE_S
51
+
52
+
53
+ @contextlib.contextmanager
54
+ def slot(model):
55
+ """Holds one of the `MAX_IN_FLIGHT` places of `model` for the duration of a request."""
56
+ if not model:
57
+ yield
58
+ return
59
+ with _lock:
60
+ semaphore = _slots.setdefault(model, threading.BoundedSemaphore(MAX_IN_FLIGHT))
61
+ with semaphore:
62
+ yield
63
+
64
+
65
+ def reset():
66
+ """Forgets every circuit and cap (tests)."""
67
+ with _lock:
68
+ _failures.clear()
69
+ _open_until.clear()
70
+ _slots.clear()
package/model_client.py CHANGED
@@ -9,6 +9,7 @@ import usage_meter
9
9
 
10
10
  import config
11
11
  import gateway
12
+ import model_breaker
12
13
  import model_health
13
14
 
14
15
  OFFLINE = contextvars.ContextVar("model_offline", default=None)
@@ -26,18 +27,24 @@ def get_token():
26
27
 
27
28
 
28
29
  def fetch(path, token, method="GET", body=None, timeout=15):
30
+ """One gateway request; returns the parsed JSON. A call that names a model first passes that model's circuit and
31
+ request cap (model_breaker, CircuitOpen when paused), and every attempt is metered. HTTP errors propagate."""
29
32
  if OFFLINE.get():
30
33
  raise RuntimeError(OFFLINE.get())
31
- started=time.monotonic();result=None;headers=None;failure=None
32
- try:
33
- result,headers=_fetch(path,token,method,body,timeout)
34
- return result
35
- except Exception as exc:
36
- failure=exc
37
- raise
38
- finally:
39
- usage_meter.record(path,body,result,headers,failure,time.monotonic()-started)
40
- model_health.record(path,body,failure)
34
+ model=body.get("model") if isinstance(body,dict) else None
35
+ model_breaker.guard(model)
36
+ with model_breaker.slot(model):
37
+ started=time.monotonic();result=None;headers=None;failure=None
38
+ try:
39
+ result,headers=_fetch(path,token,method,body,timeout)
40
+ return result
41
+ except Exception as exc:
42
+ failure=exc
43
+ raise
44
+ finally:
45
+ model_breaker.note(model,failure)
46
+ usage_meter.record(path,body,result,headers,failure,time.monotonic()-started)
47
+ model_health.record(path,body,failure)
41
48
 
42
49
 
43
50
  def _fetch(path, _token, method="GET", body=None, timeout=15):
package/model_health.py CHANGED
@@ -3,17 +3,19 @@ rerank). Never stores request content: the model, the role, the tool that was ru
3
3
  text and the time.
4
4
 
5
5
  Three consumers read it: the tool answer (a warning line when a call of that very tool failed), the setup screen
6
- (rate table and recent failures) and the tray (one toast per model per day when the rate passes the limit).
6
+ (rate table, failures by type and calls per purpose) and the tray (one toast per model per day when the rate passes the limit).
7
7
  """
8
8
  import contextlib
9
9
  import contextvars
10
10
  import io
11
11
  import os
12
+ import re
12
13
  import sqlite3
13
14
  import time
14
15
 
15
16
  import config
16
17
  import project_store
18
+ import usage_meter
17
19
 
18
20
  WINDOW_S = 24 * 3600
19
21
  ALERT_MIN_CALLS = 5
@@ -23,10 +25,17 @@ MUTE_S = 7 * 24 * 3600
23
25
  KEEP_S = 7 * 24 * 3600
24
26
  KEEP_FAILURES = 500
25
27
  ERROR_CHARS = 300
28
+ SAMPLE_CHARS = 160
29
+ GROUP_ROWS = 20
30
+ PURPOSES = ("index_embedding", "query_embedding", "rerank", "embedding_probe", "scope")
26
31
  _PATHS = ("/v1/chat/completions", "/v1/embeddings", "/v1/rerank")
27
32
  _TOOL = contextvars.ContextVar("model_health_tool", default=None)
28
33
  _COLLECTED = contextvars.ContextVar("model_health_collected", default=None)
29
34
  _SCHEMA = set()
35
+ _STATUS = re.compile(r"HTTP(?: Error)? (\d{3})")
36
+ _TIMEOUT = re.compile(r"timed out|timeout|WinError 10060|Errno 110\b", re.I)
37
+ _CONNECTION = re.compile(r"URLError|ConnectionError|ConnectionRefused|ConnectionReset|refused|reset|getaddrinfo|"
38
+ r"WinError 1006[01]|WinError 10054|Name or service|unreachable", re.I)
30
39
 
31
40
 
32
41
  @contextlib.contextmanager
@@ -56,6 +65,22 @@ def _tables(db):
56
65
  _SCHEMA.add(project_store.DB_PATH)
57
66
 
58
67
 
68
+ def classify(error):
69
+ """Failure type of a stored error text: '429', '503', '5xx' (any other 5xx), 'auth' (401/403), 'timeout',
70
+ 'connection' or 'other'. An HTTP status in the text wins over words in the response body."""
71
+ status = _STATUS.search(error or "")
72
+ if status:
73
+ code = int(status.group(1))
74
+ if code in (429, 503):
75
+ return str(code)
76
+ if code in (401, 403):
77
+ return "auth"
78
+ return "5xx" if code >= 500 else "other"
79
+ if _TIMEOUT.search(error or ""):
80
+ return "timeout"
81
+ return "connection" if _CONNECTION.search(error or "") else "other"
82
+
83
+
59
84
  def role_of(model, cfg=None):
60
85
  """Name of the config field that selects `model` ('other' when none does)."""
61
86
  cfg = cfg if cfg is not None else config.load_config()
@@ -111,8 +136,24 @@ def warning_text(failures, setup_url):
111
136
  f"{first['error']}. Pick another model: {setup_url}/setup#models")
112
137
 
113
138
 
139
+ def _groups(rows):
140
+ """Failure rows (latest first) as one entry per (type, role): count, first and last time, the latest message cut to
141
+ SAMPLE_CHARS and the latest GROUP_ROWS raw rows. Largest group first."""
142
+ groups = {}
143
+ for row in rows:
144
+ group = groups.setdefault((classify(row["error"]), row["role"]), {
145
+ "type": classify(row["error"]), "role": row["role"], "count": 0, "first_at": row["at"], "last_at": row["at"],
146
+ "sample": row["error"][:SAMPLE_CHARS], "rows": []})
147
+ group["count"] += 1
148
+ group["first_at"] = row["at"]
149
+ if len(group["rows"]) < GROUP_ROWS:
150
+ group["rows"].append(row)
151
+ return sorted(groups.values(), key=lambda group: (-group["count"], -group["last_at"]))
152
+
153
+
114
154
  def summary(now=None):
115
- """Per model and role over the last 24 h, plus the 20 latest failures."""
155
+ """Per model and role over the last 24 h, every kept failure of the window grouped by type and role, and the calls
156
+ per purpose of the usage meter (its days are UTC, so that part covers the window's days, not its exact hours)."""
116
157
  now = now or time.time()
117
158
  with project_store._LOCK, _db() as db:
118
159
  _tables(db)
@@ -121,7 +162,7 @@ def summary(now=None):
121
162
  "SELECT model,role,SUM(calls) AS calls,SUM(errors) AS errors FROM model_calls WHERE hour>=? "
122
163
  "GROUP BY model,role ORDER BY SUM(errors) DESC,SUM(calls) DESC", (int((now - WINDOW_S) // 3600),))]
123
164
  failures = [dict(row) for row in db.execute(
124
- "SELECT at,model,role,tool,path,error FROM model_failures WHERE at>=? ORDER BY at DESC LIMIT 20", (now - WINDOW_S,))]
165
+ "SELECT at,model,role,tool,path,error FROM model_failures WHERE at>=? ORDER BY at DESC", (now - WINDOW_S,))]
125
166
  for row in models:
126
167
  last = db.execute("SELECT error FROM model_failures WHERE model=? AND role=? ORDER BY at DESC LIMIT 1",
127
168
  (row["model"], row["role"])).fetchone()
@@ -129,7 +170,8 @@ def summary(now=None):
129
170
  for row in models:
130
171
  row["rate"] = round(row["errors"] / row["calls"], 3) if row["calls"] else 0.0
131
172
  row["failing"] = row["calls"] >= ALERT_MIN_CALLS and row["rate"] >= ALERT_RATE
132
- return {"window_hours": WINDOW_S // 3600, "models": models, "failures": failures}
173
+ return {"window_hours": WINDOW_S // 3600, "models": models, "groups": _groups(failures),
174
+ "calls_by_purpose": usage_meter.calls_by_purpose(now - WINDOW_S, PURPOSES)}
133
175
 
134
176
 
135
177
  def alerts_due(now=None):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allansantos-dev/smart-tool",
3
- "version": "0.9.40",
3
+ "version": "0.9.41",
4
4
  "description": "Local MCP server that gives coding agents (Claude Code, Codex) cheaper, sharper tools than their built-in search. Windows.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Allan Santos",
@@ -20,6 +20,8 @@ IDLE_TICK_S = 5
20
20
  # A folder that changes all the time (a tool's state folder) is indexed at most this often by the monitor; the changes
21
21
  # keep accumulating and one job covers them, and a search meanwhile reads the stale files straight from disk.
22
22
  MIN_JOB_GAP_S = 60
23
+ # While the changes do not stop, each job lengthens the wait for the next one; a wait that passes unused resets it.
24
+ JOB_GAP_STEPS_S = (MIN_JOB_GAP_S, 120, 240, 480, 900)
23
25
  FULL_CHECK_S = 86400
24
26
  MAX_EVENT_PATHS = 1000
25
27
 
@@ -27,7 +29,8 @@ MAX_EVENT_PATHS = 1000
27
29
  def relevant_path(path):
28
30
  parts = path.replace("\\", "/").strip("/").split("/")
29
31
  return bool(parts and all(part not in ("", ".", "..") and not part.startswith(".")
30
- and not index_scope.always_excluded(part) for part in parts))
32
+ and not index_scope.always_excluded(part) for part in parts)
33
+ and not index_scope.is_transient(parts[-1]))
31
34
 
32
35
 
33
36
  def snapshot(root, scope):
@@ -58,6 +61,7 @@ class ProjectMonitor:
58
61
  self._git_checked = {}
59
62
  self._pending = {}
60
63
  self._next_job = {}
64
+ self._gap_level = {}
61
65
  self._filters = {}
62
66
  self._views = {}
63
67
  self._lock = threading.RLock()
@@ -155,6 +159,17 @@ class ProjectMonitor:
155
159
  project_store.update(key, watch_backend="windows_events", watch_error="")
156
160
  watcher.start()
157
161
 
162
+ def _job_gap(self, key, now):
163
+ """Wait before the next job of a project, called when a job is enqueued at `now`: one step longer than the last
164
+ one when the previous wait was used up at once (at most twice its length went by), the first step otherwise."""
165
+ level, at = self._gap_level.get(key, (0, None))
166
+ if at is None or now - at >= 2 * JOB_GAP_STEPS_S[level]:
167
+ level = 0
168
+ else:
169
+ level = min(level + 1, len(JOB_GAP_STEPS_S) - 1)
170
+ self._gap_level[key] = (level, now)
171
+ return JOB_GAP_STEPS_S[level]
172
+
158
173
  def tick(self):
159
174
  now = time.monotonic()
160
175
  projects = project_store.all_projects()
@@ -223,7 +238,7 @@ class ProjectMonitor:
223
238
  except Exception as exc:
224
239
  project_store.update(key, status="error", last_error=str(exc)[:300], next_retry_at=time.time() + 60)
225
240
  continue
226
- self._next_job[key] = now + MIN_JOB_GAP_S
241
+ self._next_job[key] = now + self._job_gap(key, now)
227
242
  with self._lock:
228
243
  # Um evento recebido durante o submit continua pendente para a próxima geração.
229
244
  if self._pending.get(key) is pending and pending.get("revision", 0) == revision:
package/setup_ui.py CHANGED
@@ -255,6 +255,10 @@ def handle_integration_get():
255
255
  duplicate_mode, duplicate_error = config.duplicate_mode(), ""
256
256
  except ValueError as exc:
257
257
  duplicate_mode, duplicate_error = "", str(exc)
258
+ try:
259
+ dependency_mode, dependency_error = config.dependency_mode(), ""
260
+ except ValueError as exc:
261
+ dependency_mode, dependency_error = "", str(exc)
258
262
  try:
259
263
  proposals_mode, proposals_error = config.pattern_proposals(), ""
260
264
  except ValueError as exc:
@@ -267,6 +271,8 @@ def handle_integration_get():
267
271
  "hook_modes": list(config.HOOK_MODES), "doc_mode": doc_mode, "doc_mode_error": doc_error,
268
272
  "doc_modes": list(config.DOC_MODES), "duplicate_mode": duplicate_mode,
269
273
  "duplicate_mode_error": duplicate_error, "duplicate_modes": list(config.DUPLICATE_MODES),
274
+ "dependency_mode": dependency_mode, "dependency_mode_error": dependency_error,
275
+ "dependency_modes": list(config.DEPENDENCY_MODES),
270
276
  "pattern_proposals": proposals_mode, "pattern_proposals_error": proposals_error,
271
277
  "block_patterns": {"proposals": stored["proposals"], "accepted": stored["accepted"],
272
278
  "rejected": len(stored["rejected"]), "error": patterns_error},
@@ -285,10 +291,11 @@ def handle_integration_save(body):
285
291
  except (OSError, ValueError) as exc:
286
292
  raise SetupError(str(exc))
287
293
  return handle_integration_get()
288
- if isinstance(body, dict) and body.get("action") in ("hook_mode", "doc_mode", "duplicate_mode", "pattern_proposals"):
294
+ if isinstance(body, dict) and body.get("action") in ("hook_mode", "doc_mode", "duplicate_mode", "dependency_mode", "pattern_proposals"):
289
295
  action = body["action"]
290
296
  allowed = {"hook_mode": config.HOOK_MODES, "doc_mode": config.DOC_MODES,
291
- "duplicate_mode": config.DUPLICATE_MODES, "pattern_proposals": config.PATTERN_PROPOSAL_MODES}[action]
297
+ "duplicate_mode": config.DUPLICATE_MODES, "dependency_mode": config.DEPENDENCY_MODES,
298
+ "pattern_proposals": config.PATTERN_PROPOSAL_MODES}[action]
292
299
  if body.get("mode") not in allowed:
293
300
  raise SetupError(f"Invalid mode: use {', '.join(allowed)}.")
294
301
  try:
@@ -740,7 +747,7 @@ _PAGE_TEMPLATE = """<!doctype html>
740
747
 
741
748
  <div class="card" id="health-card">
742
749
  <div class="card-head"><h3>Model failures (last 24 h)</h3><span class="pill" id="health-pill" data-tone="busy">Checking</span></div>
743
- <p class="lede">Every failed call to a gateway model, by role and tool. A model that fails half of its calls (5 or more) also raises a Windows notification.</p>
750
+ <p class="lede">Failed calls to a gateway model, grouped by type (429, 503, timeout, ...) and role, with the latest lines of each group. A model that fails half of its calls (5 or more) also raises a Windows notification.</p>
744
751
  <div id="health-table"></div>
745
752
  </div>
746
753
 
@@ -1153,15 +1160,29 @@ for (const [id, action] of [["btn-upgrade-apply", "apply"], ["btn-upgrade-declin
1153
1160
  }
1154
1161
  });
1155
1162
  }
1163
+ const FAILURE_TYPES = { "429": "429 Too Many Requests", "503": "503 Service Unavailable", "5xx": "Other 5xx", timeout: "Timeout",
1164
+ connection: "Connection", auth: "Authentication (401/403)", other: "Other" };
1165
+ const PURPOSE_LABELS = { index_embedding: "Indexing", query_embedding: "Query", rerank: "Rerank", embedding_probe: "Model check", scope: "Scope" };
1166
+ function healthHtml(data) {
1167
+ const when = (at) => esc(new Date(at * 1000).toLocaleString());
1168
+ const models = data.models.map((row) => `<tr><td>${esc(row.role)}</td><td>${esc(row.model)}</td><td>${row.errors} / ${row.calls}</td><td>${Math.round(row.rate * 100)}%</td></tr>`).join("");
1169
+ const purposes = data.calls_by_purpose.map((row) => `<tr><td>${esc(PURPOSE_LABELS[row.purpose] || row.purpose)}</td><td>${row.attempts}</td><td>${row.errors}</td></tr>`).join("");
1170
+ const groups = data.groups.map((group) => {
1171
+ const rows = group.rows.map((row) => `<tr><td>${when(row.at)}</td><td>${esc(row.model)}</td><td>${esc(row.tool || "-")}</td><td>${esc(row.error)}</td></tr>`).join("");
1172
+ return `<details class="more health-group" data-type="${esc(group.type)}"><summary>${esc(FAILURE_TYPES[group.type] || group.type)} · ${esc(group.role)} · ${plural(group.count, "failure")} · ${when(group.first_at)} to ${when(group.last_at)}</summary>`
1173
+ + `<p class="mono">${esc(group.sample)}</p><table><tr><th>When</th><th>Model</th><th>Tool</th><th>Error</th></tr>${rows}</table>`
1174
+ + (group.count > group.rows.length ? `<p>Showing the latest ${group.rows.length} of ${group.count}.</p>` : "") + "</details>";
1175
+ }).join("");
1176
+ return (models ? `<table><tr><th>Role</th><th>Model</th><th>Failed / calls</th><th>Rate</th></tr>${models}</table>` : "<p>No model call in the last 24 hours.</p>")
1177
+ + (purposes ? `<h4>Calls by purpose</h4><table><tr><th>Purpose</th><th>Calls</th><th>Failed</th></tr>${purposes}</table>` : "")
1178
+ + (groups ? `<h4>Failures by type</h4>${groups}` : "");
1179
+ }
1156
1180
  async function loadHealth() {
1157
1181
  try {
1158
1182
  const data = await getJSON("/setup/models/health");
1159
1183
  const failing = data.models.filter((row) => row.failing).length;
1160
1184
  setPill("health-pill", failing ? "bad" : "ok", failing ? plural(failing, "model") + " failing" : "No problem");
1161
- const models = data.models.map((row) => `<tr><td>${esc(row.role)}</td><td>${esc(row.model)}</td><td>${row.errors} / ${row.calls}</td><td>${Math.round(row.rate * 100)}%</td></tr>`).join("");
1162
- const failures = data.failures.map((row) => `<tr><td>${esc(new Date(row.at * 1000).toLocaleString())}</td><td>${esc(row.role)}</td><td>${esc(row.model)}</td><td>${esc(row.tool || "-")}</td><td>${esc(row.error)}</td></tr>`).join("");
1163
- $("health-table").innerHTML = (models ? `<table><tr><th>Role</th><th>Model</th><th>Failed / calls</th><th>Rate</th></tr>${models}</table>` : "<p>No model call in the last 24 hours.</p>")
1164
- + (failures ? `<table><tr><th>When</th><th>Role</th><th>Model</th><th>Tool</th><th>Error</th></tr>${failures}</table>` : "");
1185
+ $("health-table").innerHTML = healthHtml(data);
1165
1186
  } catch (error) {
1166
1187
  setPill("health-pill", "bad", "Unavailable");
1167
1188
  $("health-table").textContent = "Could not read the failure log: " + error.message;
@@ -1270,6 +1291,10 @@ const RULES = [
1270
1291
  intro: "Compares the functions an edit writes with the indexed code: identical bodies, and near-identical ones with only local names changed. It never blocks.",
1271
1292
  options: [["warn", "Warn", "The edit runs; the agent is told when a function copies one already in the project.", true],
1272
1293
  ["off", "Off", "No duplicate checks."]] },
1294
+ { key: "dependency_mode", title: "Vulnerable dependencies on edit", applies: "Applies on the next edit.",
1295
+ intro: "Looks up the dependencies an edit of a dependency manifest adds or changes in the OSV advisory database. It never blocks.",
1296
+ options: [["warn", "Warn", "The edit runs; the agent is told which added or changed dependencies have known advisories.", true],
1297
+ ["off", "Off", "No dependency checks and no network call."]] },
1273
1298
  { key: "pattern_proposals", title: "Block patterns proposed by a model", applies: "Applies to the next review.",
1274
1299
  intro: "Afterwards, never delaying a call, the scope model (else the router model) reviews searches the fixed rule let through and may propose block patterns. Nothing blocks until you accept it on the Redirects page.",
1275
1300
  options: [["on", "On", "Review in the background and list proposals for you.", true],
@@ -676,69 +676,97 @@ def _embed_retry_delay(exc, attempt):
676
676
  return min(8, 2 ** (attempt - 1)) + random.uniform(0, .25)
677
677
 
678
678
 
679
+ def _wait_to_retry(exc, attempt, deadline=None, cancel_check=None):
680
+ """Sleeps the wait the gateway asked for (`Retry-After`, else exponential with jitter) before the next attempt;
681
+ raises TimeoutError when that wait does not fit in `deadline`."""
682
+ delay = _embed_retry_delay(exc, attempt)
683
+ if deadline is not None and delay >= _research_time_left(deadline):
684
+ raise TimeoutError("Not enough time left to honor the gateway backoff.")
685
+ if cancel_check:
686
+ until = time.monotonic() + delay
687
+ while time.monotonic() < until:
688
+ cancel_check()
689
+ time.sleep(min(.2, max(0, until - time.monotonic())))
690
+ else:
691
+ time.sleep(delay)
692
+
693
+
694
+ def _embed_request(model, batch, token, deadline=None, on_retry=None, cancel_check=None):
695
+ """Vectors of one embeddings request in input order, repeating a transient failure up to EMBED_MAX_ATTEMPTS times."""
696
+ for attempt in range(1, EMBED_MAX_ATTEMPTS + 1):
697
+ if cancel_check:
698
+ cancel_check()
699
+ timeout = (min(EMBED_REQUEST_TIMEOUT_S, _research_time_left(deadline))
700
+ if deadline is not None else EMBED_REQUEST_TIMEOUT_S)
701
+ try:
702
+ response = model_client.fetch(
703
+ "/v1/embeddings", token, method="POST", body={"model": model, "input": batch},
704
+ timeout=timeout,
705
+ )
706
+ except Exception as exc:
707
+ if attempt >= EMBED_MAX_ATTEMPTS or not _transient_embed_error(exc):
708
+ raise
709
+ if on_retry:
710
+ on_retry(attempt + 1, len(batch))
711
+ _wait_to_retry(exc, attempt, deadline, cancel_check)
712
+ continue
713
+ if cancel_check:
714
+ cancel_check()
715
+ rows = response.get("data", []) if isinstance(response, dict) else []
716
+ if not isinstance(rows, list) or len(rows) != len(batch):
717
+ raise RuntimeError("The gateway returned a different number of embeddings than the input.")
718
+ ordered = [None] * len(batch)
719
+ for row in rows:
720
+ if not isinstance(row, dict):
721
+ raise indexer.IndexVectorError("Invalid embedding row.")
722
+ idx = row.get("index", 0 if len(batch) == 1 else None)
723
+ if type(idx) is not int or not 0 <= idx < len(batch) or ordered[idx] is not None:
724
+ raise indexer.IndexVectorError("Embedding indexes missing, duplicated or outside the batch.")
725
+ ordered[idx] = row.get("embedding")
726
+ return ordered
727
+
728
+
679
729
  def _embed(model, texts, token, deadline=None, on_retry=None, cancel_check=None):
730
+ """One vector per text. A request the gateway refuses for its size (HTTP 400 or 413) is split in two halves, each
731
+ sent once; a half that is refused again raises."""
680
732
  if local_embedder.is_local(model):
681
733
  return local_embedder.embed(model, texts, deadline=deadline, cancel_check=cancel_check, on_retry=on_retry)
682
734
  vectors = []
683
735
  dimensions = None
684
736
  for batch in _embed_batches(texts):
685
- max_attempts = EMBED_MAX_ATTEMPTS if deadline is None or on_retry is not None else 1
686
- for attempt in range(1, max_attempts + 1):
687
- if cancel_check:
688
- cancel_check()
689
- timeout = (min(EMBED_REQUEST_TIMEOUT_S, _research_time_left(deadline))
690
- if deadline is not None else EMBED_REQUEST_TIMEOUT_S)
691
- try:
692
- response = model_client.fetch(
693
- "/v1/embeddings", token, method="POST", body={"model": model, "input": batch},
694
- timeout=timeout,
695
- )
696
- except Exception as exc:
697
- if attempt >= max_attempts or not _transient_embed_error(exc):
698
- raise
699
- if on_retry:
700
- on_retry(attempt + 1, len(batch))
701
- delay = _embed_retry_delay(exc, attempt)
702
- if deadline is not None and delay >= _research_time_left(deadline):
703
- raise TimeoutError("Not enough time left to honor the gateway backoff.")
704
- if cancel_check:
705
- until = time.monotonic() + delay
706
- while time.monotonic() < until:
707
- cancel_check()
708
- time.sleep(min(.2, max(0, until - time.monotonic())))
709
- else:
710
- time.sleep(delay)
711
- continue
712
- if cancel_check:
713
- cancel_check()
714
- rows = response.get("data", []) if isinstance(response, dict) else []
715
- if not isinstance(rows, list) or len(rows) != len(batch):
716
- raise RuntimeError("The gateway returned a different number of embeddings than the input.")
717
- ordered = [None] * len(batch)
718
- for row in rows:
719
- if not isinstance(row, dict):
720
- raise indexer.IndexVectorError("Invalid embedding row.")
721
- idx = row.get("index", 0 if len(batch) == 1 else None)
722
- if type(idx) is not int or not 0 <= idx < len(batch) or ordered[idx] is not None:
723
- raise indexer.IndexVectorError("Embedding indexes missing, duplicated or outside the batch.")
724
- vector = row.get("embedding")
725
- dimensions = indexer.validate_vector(vector, dimensions)
726
- ordered[idx] = vector
727
- vectors.extend(ordered)
728
- break
737
+ try:
738
+ ordered = _embed_request(model, batch, token, deadline, on_retry, cancel_check)
739
+ except urllib.error.HTTPError as exc:
740
+ if exc.code not in (400, 413) or len(batch) < 2:
741
+ raise
742
+ half = len(batch) // 2
743
+ ordered = (_embed_request(model, batch[:half], token, deadline, on_retry, cancel_check)
744
+ + _embed_request(model, batch[half:], token, deadline, on_retry, cancel_check))
745
+ for vector in ordered:
746
+ dimensions = indexer.validate_vector(vector, dimensions)
747
+ vectors.extend(ordered)
729
748
  return vectors
730
749
 
731
750
 
732
751
  def _rerank(model, query, documents, top_n, token, deadline=None):
752
+ """Rows `{index, relevance_score}` of the best `top_n` documents. Only an HTTP answer that is worth repeating
753
+ (429, 5xx) is asked again, after the wait of `_wait_to_retry`; a timeout fails at once so a search does not stall."""
733
754
  if not documents:
734
755
  return []
735
756
  top_n = min(top_n, len(documents))
736
- timeout = min(15, _research_time_left(deadline)) if deadline is not None else 15
737
- response = model_client.fetch(
738
- "/v1/rerank", token, method="POST",
739
- body={"model": model, "query": query, "documents": documents, "top_n": top_n},
740
- timeout=timeout,
741
- )
757
+ for attempt in range(1, EMBED_MAX_ATTEMPTS + 1):
758
+ timeout = min(15, _research_time_left(deadline)) if deadline is not None else 15
759
+ try:
760
+ response = model_client.fetch(
761
+ "/v1/rerank", token, method="POST",
762
+ body={"model": model, "query": query, "documents": documents, "top_n": top_n},
763
+ timeout=timeout,
764
+ )
765
+ break
766
+ except urllib.error.HTTPError as exc:
767
+ if attempt >= EMBED_MAX_ATTEMPTS or not _transient_embed_error(exc):
768
+ raise
769
+ _wait_to_retry(exc, attempt, deadline)
742
770
  rows = response.get("results", []) if isinstance(response, dict) else []
743
771
  if not isinstance(rows, list) or not rows:
744
772
  raise ValueError("The reranker returned no valid results.")
@@ -1054,7 +1082,9 @@ def _prepare_project_index(arguments, job_id, cfg, token, query_vector=None, pre
1054
1082
  return result
1055
1083
  model = model or local_embedder.resolve(cfg.get("embedding_model"), indexer.info(root).get("model_id"))
1056
1084
  lexical = model == indexer.LEXICAL_MODEL
1057
- if not lexical and query_vector is None:
1085
+ current = indexer.info(root)
1086
+ known_dimensions = current.get("dimensions") if current.get("model_id") == model else None
1087
+ if not lexical and query_vector is None and not known_dimensions:
1058
1088
  _update_job(job_id, phase="model_probe")
1059
1089
  query_vector = _embed(model, ["Smart Tool: indexing dimension check."], token,
1060
1090
  deadline=_job_deadline(job_id), cancel_check=checkpoint,
@@ -1062,7 +1092,6 @@ def _prepare_project_index(arguments, job_id, cfg, token, query_vector=None, pre
1062
1092
  retry_attempt=attempt, batch_items=items))[0]
1063
1093
  checkpoint()
1064
1094
  _update_job(job_id, phase="indexing")
1065
- current = indexer.info(root)
1066
1095
  full_check = bool(arguments.get("full_check") or project.get("needs_full_check") or
1067
1096
  time.time() - current.get("full_scan_at", 0) >= project_monitor.FULL_CHECK_S)
1068
1097
  stats = indexer.reindex(
@@ -1072,7 +1101,7 @@ def _prepare_project_index(arguments, job_id, cfg, token, query_vector=None, pre
1072
1101
  job_id, phase="indexing_retry", retry_attempt=attempt, batch_items=items)),
1073
1102
  on_progress=lambda processed, total: _update_job(
1074
1103
  job_id, phase="indexing", processed_files=processed, total_files=total),
1075
- model_id=model, expected_dimensions=None if lexical else len(query_vector),
1104
+ model_id=model, expected_dimensions=None if lexical else known_dimensions or len(query_vector),
1076
1105
  force_rebuild=bool(arguments.get("force_rebuild")), full_check=full_check,
1077
1106
  changed_paths=set(arguments.get("changed_paths") or []) | set(project.get('dirty_paths', [])), cancel_check=checkpoint,
1078
1107
  )
@@ -1110,6 +1139,33 @@ LEXICAL_WARNING = ("No embedding model: index and search are lexical only (exact
1110
1139
  "Configure an embedding_model for semantic search.")
1111
1140
 
1112
1141
 
1142
+ def _rerank_blocks(model, groups, queries, found, vectors, token, deadline=None):
1143
+ """`({group: rows}, {group: error})` of the blocks that have candidates and a query vector. One rerank request goes
1144
+ out per distinct query, not per block: the candidates of the blocks that share a query travel together and come back
1145
+ split by block (`index` is the position inside the block's candidates). A reranker scores each document against the
1146
+ query on its own, so a block gets the order and scores a request of its own would give."""
1147
+ ranked, failed = {}, {}
1148
+ by_query = {}
1149
+ for group in groups:
1150
+ if found[group] and vectors.get(queries[group]) is not None:
1151
+ by_query.setdefault(queries[group], []).append(group)
1152
+ for query, members in by_query.items():
1153
+ owners = [(group, position) for group in members for position in range(len(found[group]))]
1154
+ documents = [found[group][position][4] for group, position in owners]
1155
+ try:
1156
+ rows = _rerank(model, query, documents, len(documents), token, deadline=deadline)
1157
+ except Exception as exc:
1158
+ failed.update({group: _project_error(exc) for group in members})
1159
+ continue
1160
+ ranked.update({group: [] for group in members})
1161
+ for row in rows:
1162
+ group, position = owners[row["index"]]
1163
+ ranked[group].append({"index": position, "relevance_score": row["relevance_score"]})
1164
+ failed.update({group: "ValueError: The reranker returned no result for this block."
1165
+ for group in members if not ranked[group]})
1166
+ return ranked, failed
1167
+
1168
+
1113
1169
  def _perform_smart_search(arguments, job_id):
1114
1170
  return contextvars.copy_context().run(_perform_smart_search_isolated, arguments, job_id)
1115
1171
 
@@ -1220,22 +1276,22 @@ def _perform_smart_search_isolated(arguments, job_id):
1220
1276
  include=lambda path, group=group: index_profile.kind(path, profile) == group)
1221
1277
  for group in groups}
1222
1278
 
1279
+ reranked, rerank_failed = {}, {}
1280
+ if cfg.get("rerank_model"):
1281
+ _update_job(job_id, phase="reranking")
1282
+ reranked, rerank_failed = _rerank_blocks(cfg["rerank_model"], groups, queries, found, vectors, token,
1283
+ deadline=_job_deadline(job_id))
1284
+
1223
1285
  def search_block(group):
1224
1286
  top_k = limits[group]
1225
1287
  query, vector, candidates = queries[group], vectors.get(queries[group]), found[group]
1226
1288
  ordered = [(score, "hybrid" if vector is not None else "lexical", row) for score, *row in candidates[:top_k]]
1227
1289
  problem = None
1228
- if cfg.get("rerank_model") and candidates and vector is not None:
1229
- _update_job(job_id, phase="reranking")
1230
- try:
1231
- reranked = _rerank(cfg["rerank_model"], query, [c[4] for c in candidates], top_k, token,
1232
- deadline=_job_deadline(job_id))
1233
- if not reranked or any(type(item.get("index")) is not int or not 0 <= item["index"] < len(candidates)
1234
- for item in reranked):
1235
- raise ValueError("Invalid index in the reranker response.")
1236
- ordered = [(float(item["relevance_score"]), "rerank", candidates[item["index"]][1:]) for item in reranked]
1237
- except Exception as exc:
1238
- problem = f"Rerank unavailable for {group}; hybrid lexical/vector order kept. " + _project_error(exc)
1290
+ if group in rerank_failed:
1291
+ problem = f"Rerank unavailable for {group}; hybrid lexical/vector order kept. " + rerank_failed[group]
1292
+ elif group in reranked:
1293
+ ordered = [(float(item["relevance_score"]), "rerank", candidates[item["index"]][1:])
1294
+ for item in reranked[group][:top_k]]
1239
1295
  exact = indexer.overlay_exact_keys(overlay, query)
1240
1296
  if exact:
1241
1297
  pinned = [(c[0], "overlay", c[1:]) for c in candidates if tuple(c[1:4]) in exact][:2]
package/usage_meter.py CHANGED
@@ -97,6 +97,19 @@ def _record_job(job_id,payload):
97
97
  db.execute('DELETE FROM usage_index_jobs WHERE at<?',(time.time()-366*86400,))
98
98
 
99
99
 
100
+ def calls_by_purpose(since,purposes):
101
+ """Attempts and errors of every project per purpose in `purposes`, from the UTC day of the epoch time `since` on
102
+ (the table keeps days, not hours). Purposes with no call are left out."""
103
+ day=datetime.datetime.fromtimestamp(since,datetime.timezone.utc).date().isoformat()
104
+ marks=','.join('?' for _ in purposes)
105
+ with project_store._LOCK,project_store._db() as db:
106
+ _tables(db)
107
+ rows=db.execute('SELECT purpose,SUM(attempts),SUM(errors) FROM usage_daily WHERE day>=? AND purpose IN ('+marks+') GROUP BY purpose',
108
+ (day,*purposes)).fetchall()
109
+ found={purpose:{'purpose':purpose,'attempts':attempts,'errors':errors} for purpose,attempts,errors in rows}
110
+ return [found[purpose] for purpose in purposes if purpose in found]
111
+
112
+
100
113
  def summary(project_id,days=30,view_id=None):
101
114
  if type(days) is not int or not 1<=days<=366:raise ValueError('Choose a period from 1 to 366 days.')
102
115
  with project_store._LOCK,project_store._db() as db:
package/version.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """Single source of the Smart Tool version and of the User-Agent sent to public APIs."""
2
2
  import re
3
3
 
4
- VERSION = "0.9.40"
4
+ VERSION = "0.9.41"
5
5
  _CONTACT_RE = re.compile(r"^(?:[^\s@()<>;]+@[^\s@()<>;]+\.[^\s@()<>;]+|https?://[^\s()<>;]+)$")
6
6
 
7
7
 
package/web_fetch.py CHANGED
@@ -38,8 +38,7 @@ SELECT_BUDGET_CHARS = 15_000
38
38
  CHUNK_CHARS = 2_000
39
39
  PAGE_FORMAT = 2
40
40
  MAX_DOWNLOAD_BYTES = 5 * 1024 * 1024
41
- PDF_MAX_PAGES = 300
42
- PDF_TIMEOUT_S = 30
41
+ PDF_INTERACTIVE_TIMEOUT_S = 30 # Override of document_text.PDF_TIMEOUT_S: a person waits for this answer, the indexer does not.
43
42
  MAX_REDIRECTS = 5
44
43
  HTTP_TIMEOUT_S = 20
45
44
  READ_CHUNK = 64 * 1024
@@ -212,12 +211,13 @@ def thin(text):
212
211
 
213
212
 
214
213
  def pdf_text(body, url, deadline):
215
- """Text of a PDF (pypdf, pure Python), page by page up to PDF_MAX_PAGES. The native WebFetch reads PDFs and the hook
216
- sends it here, so a PDF used to be a dead end (9 times in 10 days of transcripts). A hostile PDF can make pypdf
217
- parse for minutes, so it runs in a subprocess killed at the request's deadline (at most PDF_TIMEOUT_S)."""
218
- timeout = min(PDF_TIMEOUT_S, _left(deadline))
214
+ """Text of a PDF (pypdf, pure Python), page by page, with the page and character caps of document_text. The native
215
+ WebFetch reads PDFs and the hook sends it here, so a PDF used to be a dead end (9 times in 10 days of transcripts).
216
+ A hostile PDF can make pypdf parse for minutes, so it runs in a subprocess killed at the request's deadline (at most
217
+ PDF_INTERACTIVE_TIMEOUT_S, passed to document_text.run_pdf instead of the indexer's document_text.PDF_TIMEOUT_S)."""
218
+ timeout = min(PDF_INTERACTIVE_TIMEOUT_S, _left(deadline))
219
219
  try:
220
- return document_text.run_pdf(body, timeout, PDF_MAX_PAGES, PAGE_STORE_MAX_CHARS)
220
+ return document_text.run_pdf(body, timeout, document_text.PDF_MAX_PAGES, document_text.PDF_MAX_CHARS)
221
221
  except document_text.PdfTimeout:
222
222
  raise FetchError(f"PDF not read in {timeout:.0f}s ({url}).") from None
223
223
  except document_text.PdfUnreadable as exc: