@allansantos-dev/smart-tool 0.9.40 → 0.9.42

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/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.42",
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/scout.py CHANGED
@@ -102,7 +102,7 @@ def is_intent_question(question):
102
102
 
103
103
  def disk_lines(root, path, start, end):
104
104
  """`[(line_number, text)]` of lines `start..end` of the file as it is on disk now, or None when the file cannot be
105
- read by the index rules (secret, dotfile, too large, binary, gone) or holds no line text (docx, pdf)."""
105
+ read by the index rules (secret, dotfile, too large, binary, gone) or holds no line text (docx, pdf, pptx, xlsx)."""
106
106
  if document_text.location_kind(path) != "source_line":
107
107
  return None
108
108
  data, encoding = indexer.read_indexable(root, path)
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:
@@ -318,21 +325,25 @@ def handle_integration_save(body):
318
325
  def handle_audit(body):
319
326
  """Setup audit panel: `index` lists the registered projects with their last audit summary; `report` returns the
320
327
  last stored report of one of them; `run` audits it now (MCP servers contacted unless probe is false). Only
321
- registered project folders are accepted. The opt-in session-start report in Claude Code: `hook_state`,
328
+ registered project folders are accepted. The opt-in session-start report in Claude Code or Codex (`client`, default
329
+ claude; Codex runs it only after /hooks approval, state `waiting_approval`): `hook_state`,
322
330
  `hook_preview` (the exact entry and a token), `hook_install` (with that token, after a backup) and `hook_remove`
323
331
  (only Smart Tool's entry, after a backup)."""
324
332
  if not isinstance(body, dict):
325
333
  raise SetupError("Invalid request.")
326
334
  roots = sorted({p["root"] for p in project_store.all_projects() if p.get("root")}, key=str.casefold)
327
335
  try:
336
+ client = body.get("client", "claude")
328
337
  if body.get("action") == "hook_state":
329
- return {"state": client_hooks.audit_state(), "file": client_hooks.CLIENTS["claude"]["files"][0]}
338
+ state = client_hooks.audit_state(client)
339
+ detail = {"detail": "installed, waiting for your approval in /hooks"} if state == client_hooks.AUDIT_WAITING else {}
340
+ return {"state": state, "file": client_hooks.CLIENTS[client]["files"][0], **detail}
330
341
  if body.get("action") == "hook_preview":
331
- return client_hooks.audit_preview()
342
+ return client_hooks.audit_preview(client)
332
343
  if body.get("action") == "hook_install":
333
- return client_hooks.audit_install(body.get("token"), True)
344
+ return client_hooks.audit_install(body.get("token"), True, client)
334
345
  if body.get("action") == "hook_remove":
335
- return client_hooks.audit_remove()
346
+ return client_hooks.audit_remove(client)
336
347
  if body.get("action") == "index":
337
348
  stored = setup_audit.index()
338
349
  found = {root: stored[setup_audit.index_key(root)] for root in roots if setup_audit.index_key(root) in stored}
@@ -740,7 +751,7 @@ _PAGE_TEMPLATE = """<!doctype html>
740
751
 
741
752
  <div class="card" id="health-card">
742
753
  <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>
754
+ <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
755
  <div id="health-table"></div>
745
756
  </div>
746
757
 
@@ -802,8 +813,9 @@ _PAGE_TEMPLATE = """<!doctype html>
802
813
  <header class="page-head"><h2 id="h-audit" tabindex="-1">Setup audit</h2>
803
814
  <p>What Claude Code and Codex load for a project, and where they collide: hooks deciding on the same tool at once, hooks or MCP servers that do not work, tools offered twice, skills and instructions one agent never sees. It runs on its own for the registered projects and never changes anything.</p></header>
804
815
  <div class="card">
805
- <div class="card-head"><h3>Report at session start (Claude Code)</h3><span class="pill" id="audit-hook-pill" data-tone="busy">Checking</span></div>
806
- <p class="lede">Off by default. When on, every new Claude Code session starts with one short line about the stored audit of its folder (errors and warnings only, nothing runs). It adds a SessionStart entry to <span class="mono" id="audit-hook-file"></span>, written after a backup, and turning it off removes only that entry. Without it, the agent gets the same line once, on its first search.</p>
816
+ <div class="card-head"><h3>Report at session start</h3><span class="pill" id="audit-hook-pill" data-tone="busy">Checking</span></div>
817
+ <div class="field"><label for="sel-audit-hook-client">Client</label><select id="sel-audit-hook-client"><option value="claude">Claude Code</option><option value="codex">Codex</option></select></div>
818
+ <p class="lede">Off by default. When on, every new session starts with one short line about the stored audit of its folder (errors and warnings only, nothing runs). It adds a SessionStart entry to <span class="mono" id="audit-hook-file"></span>, written after a backup, and turning it off removes only that entry. Codex runs a new hook only after you approve it in /hooks. Without it, the agent gets the same line once, on its first search.</p>
807
819
  <div class="actions"><button type="button" class="primary" id="btn-audit-hook-on" hidden>Turn on</button><button type="button" id="btn-audit-hook-off" hidden>Turn off</button></div>
808
820
  <div id="audit-hook-confirm" hidden>
809
821
  <p class="hint" id="audit-hook-where"></p>
@@ -1153,15 +1165,29 @@ for (const [id, action] of [["btn-upgrade-apply", "apply"], ["btn-upgrade-declin
1153
1165
  }
1154
1166
  });
1155
1167
  }
1168
+ const FAILURE_TYPES = { "429": "429 Too Many Requests", "503": "503 Service Unavailable", "5xx": "Other 5xx", timeout: "Timeout",
1169
+ connection: "Connection", auth: "Authentication (401/403)", other: "Other" };
1170
+ const PURPOSE_LABELS = { index_embedding: "Indexing", query_embedding: "Query", rerank: "Rerank", embedding_probe: "Model check", scope: "Scope" };
1171
+ function healthHtml(data) {
1172
+ const when = (at) => esc(new Date(at * 1000).toLocaleString());
1173
+ 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("");
1174
+ 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("");
1175
+ const groups = data.groups.map((group) => {
1176
+ 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("");
1177
+ 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>`
1178
+ + `<p class="mono">${esc(group.sample)}</p><table><tr><th>When</th><th>Model</th><th>Tool</th><th>Error</th></tr>${rows}</table>`
1179
+ + (group.count > group.rows.length ? `<p>Showing the latest ${group.rows.length} of ${group.count}.</p>` : "") + "</details>";
1180
+ }).join("");
1181
+ 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>")
1182
+ + (purposes ? `<h4>Calls by purpose</h4><table><tr><th>Purpose</th><th>Calls</th><th>Failed</th></tr>${purposes}</table>` : "")
1183
+ + (groups ? `<h4>Failures by type</h4>${groups}` : "");
1184
+ }
1156
1185
  async function loadHealth() {
1157
1186
  try {
1158
1187
  const data = await getJSON("/setup/models/health");
1159
1188
  const failing = data.models.filter((row) => row.failing).length;
1160
1189
  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>` : "");
1190
+ $("health-table").innerHTML = healthHtml(data);
1165
1191
  } catch (error) {
1166
1192
  setPill("health-pill", "bad", "Unavailable");
1167
1193
  $("health-table").textContent = "Could not read the failure log: " + error.message;
@@ -1270,6 +1296,10 @@ const RULES = [
1270
1296
  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
1297
  options: [["warn", "Warn", "The edit runs; the agent is told when a function copies one already in the project.", true],
1272
1298
  ["off", "Off", "No duplicate checks."]] },
1299
+ { key: "dependency_mode", title: "Vulnerable dependencies on edit", applies: "Applies on the next edit.",
1300
+ intro: "Looks up the dependencies an edit of a dependency manifest adds or changes in the OSV advisory database. It never blocks.",
1301
+ options: [["warn", "Warn", "The edit runs; the agent is told which added or changed dependencies have known advisories.", true],
1302
+ ["off", "Off", "No dependency checks and no network call."]] },
1273
1303
  { key: "pattern_proposals", title: "Block patterns proposed by a model", applies: "Applies to the next review.",
1274
1304
  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
1305
  options: [["on", "On", "Review in the background and list proposals for you.", true],
@@ -1861,14 +1891,17 @@ async function loadAudit() {
1861
1891
  }
1862
1892
  renderOverview();
1863
1893
  }
1894
+ function auditHookClient() { return $("sel-audit-hook-client").value; }
1895
+ $("sel-audit-hook-client").addEventListener("change", () => { setMsg("audit-hook-msg", "", ""); loadAuditHook(); });
1864
1896
  async function loadAuditHook() {
1865
1897
  try {
1866
- const hook = await post("/setup/audit", { action: "hook_state" });
1898
+ const hook = await post("/setup/audit", { action: "hook_state", client: auditHookClient() });
1867
1899
  $("audit-hook-file").textContent = hook.file;
1868
- const on = hook.state === "current";
1900
+ const on = hook.state === "current" || hook.state === "waiting_approval";
1869
1901
  const pillEl = $("audit-hook-pill");
1870
- pillEl.textContent = on ? "On" : hook.state === "outdated" ? "Outdated" : "Off";
1871
- pillEl.dataset.tone = on ? "ok" : hook.state === "outdated" ? "warn" : "idle";
1902
+ pillEl.textContent = hook.state === "waiting_approval" ? "Waiting for approval in /hooks" : on ? "On" : hook.state === "outdated" ? "Outdated" : "Off";
1903
+ pillEl.dataset.tone = hook.state === "waiting_approval" || hook.state === "outdated" ? "warn" : on ? "ok" : "idle";
1904
+ if (hook.detail) setMsg("audit-hook-msg", "", hook.detail + ".");
1872
1905
  $("btn-audit-hook-on").hidden = on;
1873
1906
  $("btn-audit-hook-on").textContent = hook.state === "outdated" ? "Update" : "Turn on";
1874
1907
  $("btn-audit-hook-off").hidden = hook.state === "missing";
@@ -1879,7 +1912,7 @@ async function loadAuditHook() {
1879
1912
  $("btn-audit-hook-on").addEventListener("click", async () => {
1880
1913
  setMsg("audit-hook-msg", "", "Preparing the change…");
1881
1914
  try {
1882
- const plan = await post("/setup/audit", { action: "hook_preview" });
1915
+ const plan = await post("/setup/audit", { action: "hook_preview", client: auditHookClient() });
1883
1916
  setMsg("audit-hook-msg", "", "");
1884
1917
  $("audit-hook-where").textContent = "Adds this entry under " + plan.event + " in " + plan.file + ".";
1885
1918
  $("audit-hook-preview").textContent = JSON.stringify(plan.change, null, 2);
@@ -1887,10 +1920,10 @@ $("btn-audit-hook-on").addEventListener("click", async () => {
1887
1920
  $("btn-audit-hook-write").onclick = async () => {
1888
1921
  $("btn-audit-hook-write").disabled = true;
1889
1922
  try {
1890
- const result = await post("/setup/audit", { action: "hook_install", token: plan.token });
1923
+ const result = await post("/setup/audit", { action: "hook_install", token: plan.token, client: auditHookClient() });
1891
1924
  $("audit-hook-confirm").hidden = true;
1892
1925
  await loadAuditHook();
1893
- setMsg("audit-hook-msg", "ok", "Turned on. Backup: " + (result.backup || "none, the file was new") + ".");
1926
+ setMsg("audit-hook-msg", "ok", "Turned on. Backup: " + (result.backup || "none, the file was new") + "." + (result.after_install ? " " + result.after_install : ""));
1894
1927
  } catch (error) {
1895
1928
  setMsg("audit-hook-msg", "error", error.message);
1896
1929
  } finally {
@@ -1904,7 +1937,7 @@ $("btn-audit-hook-on").addEventListener("click", async () => {
1904
1937
  $("btn-audit-hook-cancel").addEventListener("click", () => { $("audit-hook-confirm").hidden = true; });
1905
1938
  $("btn-audit-hook-off").addEventListener("click", async () => {
1906
1939
  try {
1907
- const result = await post("/setup/audit", { action: "hook_remove" });
1940
+ const result = await post("/setup/audit", { action: "hook_remove", client: auditHookClient() });
1908
1941
  await loadAuditHook();
1909
1942
  setMsg("audit-hook-msg", "ok", "Turned off. Backup: " + (result.backup || "none") + ".");
1910
1943
  } catch (error) {
@@ -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: