@allansantos-dev/smart-tool 0.9.2 → 0.9.3
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 +62 -0
- package/README.md +27 -2
- package/affected_tests.py +292 -0
- package/client_hooks.py +2 -2
- package/code_graph.py +75 -14
- package/code_graph_java.mjs +4 -1
- package/code_graph_js.cjs +15 -1
- package/code_impact.py +132 -0
- package/config.py +22 -0
- package/doc_check.py +256 -0
- package/duplicates.py +148 -13
- package/edit_preview.py +92 -0
- package/hook_decision.py +96 -2
- package/index_profile.py +1 -1
- package/package.json +1 -1
- package/router.py +4 -2
- package/setup_ui.py +53 -5
- package/smart_tool_daemon.py +108 -21
- package/version.py +1 -1
- package/web_adapters/browser/fetch_camoufox.py +16 -8
- package/web_adapters/browser/ranged_download.py +78 -0
- package/web_fetch.py +60 -3
- package/web_search_health.py +12 -4
package/edit_preview.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""The files an agent edit would produce, computed before the edit runs: Claude Code Edit, MultiEdit and Write, and
|
|
2
|
+
Codex apply_patch (*** Begin Patch / Update File / Add File). Returns (absolute path, text before or None, text after)
|
|
3
|
+
for each file it can rebuild; an edit it cannot rebuild yields nothing, so a hook built on it never blocks wrongly."""
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _read(path):
|
|
8
|
+
try:
|
|
9
|
+
with open(path, encoding="utf-8") as stream:
|
|
10
|
+
return stream.read()
|
|
11
|
+
except (OSError, UnicodeDecodeError):
|
|
12
|
+
return None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _replace(text, old, new, every):
|
|
16
|
+
if not old or old not in text:
|
|
17
|
+
return None
|
|
18
|
+
return text.replace(old, new) if every else text.replace(old, new, 1)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _claude(tool, data):
|
|
22
|
+
path = data.get("file_path")
|
|
23
|
+
if not isinstance(path, str) or not os.path.isabs(path):
|
|
24
|
+
return []
|
|
25
|
+
before = _read(path)
|
|
26
|
+
if tool == "Write":
|
|
27
|
+
content = data.get("content")
|
|
28
|
+
return [(path, before, content)] if isinstance(content, str) else []
|
|
29
|
+
if before is None:
|
|
30
|
+
return []
|
|
31
|
+
edits = data.get("edits") if tool == "MultiEdit" else [data]
|
|
32
|
+
after = before
|
|
33
|
+
for edit in edits or []:
|
|
34
|
+
after = _replace(after, edit.get("old_string"), edit.get("new_string", ""), edit.get("replace_all") is True)
|
|
35
|
+
if after is None:
|
|
36
|
+
return []
|
|
37
|
+
return [(path, before, after)]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _apply_hunks(text, hunks):
|
|
41
|
+
lines = text.splitlines()
|
|
42
|
+
for old, new in hunks:
|
|
43
|
+
if not old:
|
|
44
|
+
lines.extend(new)
|
|
45
|
+
continue
|
|
46
|
+
at = next((i for i in range(len(lines) - len(old) + 1) if lines[i:i + len(old)] == old), None)
|
|
47
|
+
if at is None:
|
|
48
|
+
return None
|
|
49
|
+
lines[at:at + len(old)] = new
|
|
50
|
+
return "\n".join(lines) + ("\n" if text.endswith("\n") or not text else "")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _codex(patch, cwd):
|
|
54
|
+
if not isinstance(patch, str) or "*** Begin Patch" not in patch:
|
|
55
|
+
return []
|
|
56
|
+
files, current = [], None
|
|
57
|
+
for raw in patch.splitlines():
|
|
58
|
+
if raw.startswith(("*** Update File: ", "*** Add File: ")):
|
|
59
|
+
current = {"action": raw.split(":", 1)[0][4:], "path": raw.split(":", 1)[1].strip(), "hunks": [[[], []]]}
|
|
60
|
+
files.append(current)
|
|
61
|
+
elif raw.startswith(("*** Delete File: ", "*** End Patch", "*** Move to: ")) or current is None:
|
|
62
|
+
current = None if raw.startswith(("*** Delete File: ", "*** End Patch")) else current
|
|
63
|
+
elif raw.startswith("@@"):
|
|
64
|
+
current["hunks"].append([[], []])
|
|
65
|
+
elif raw.startswith("+"):
|
|
66
|
+
current["hunks"][-1][1].append(raw[1:])
|
|
67
|
+
elif raw.startswith("-"):
|
|
68
|
+
current["hunks"][-1][0].append(raw[1:])
|
|
69
|
+
elif raw.startswith(" ") or raw == "":
|
|
70
|
+
current["hunks"][-1][0].append(raw[1:])
|
|
71
|
+
current["hunks"][-1][1].append(raw[1:])
|
|
72
|
+
results = []
|
|
73
|
+
for entry in files:
|
|
74
|
+
path = entry["path"] if os.path.isabs(entry["path"]) else os.path.join(cwd or os.getcwd(), entry["path"])
|
|
75
|
+
hunks = [hunk for hunk in entry["hunks"] if hunk[0] or hunk[1]]
|
|
76
|
+
if entry["action"] == "Add File":
|
|
77
|
+
results.append((path, None, "\n".join(line for hunk in hunks for line in hunk[1]) + "\n"))
|
|
78
|
+
continue
|
|
79
|
+
before = _read(path)
|
|
80
|
+
after = _apply_hunks(before, hunks) if before is not None else None
|
|
81
|
+
if after is not None:
|
|
82
|
+
results.append((path, before, after))
|
|
83
|
+
return results
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def preview(tool_name, tool_input, cwd=None):
|
|
87
|
+
data = tool_input if isinstance(tool_input, dict) else {}
|
|
88
|
+
if tool_name in ("Edit", "MultiEdit", "Write") and "file_path" in data:
|
|
89
|
+
return _claude(tool_name, data)
|
|
90
|
+
if tool_name in ("apply_patch", "Edit", "Write") and "command" in data:
|
|
91
|
+
return _codex(data.get("command"), cwd)
|
|
92
|
+
return []
|
package/hook_decision.py
CHANGED
|
@@ -10,11 +10,20 @@ no Claude Code, e nenhuma decisão aqui é sobre periculosidade, só sobre custo
|
|
|
10
10
|
import json
|
|
11
11
|
import os
|
|
12
12
|
|
|
13
|
+
import code_graph
|
|
14
|
+
import code_impact
|
|
13
15
|
import config
|
|
16
|
+
import doc_check
|
|
17
|
+
import duplicates
|
|
18
|
+
import edit_preview
|
|
14
19
|
import endpoint_sync
|
|
20
|
+
import project_store
|
|
15
21
|
import router
|
|
16
22
|
|
|
17
23
|
WEB_TOOLS = ("WebSearch", "WebFetch")
|
|
24
|
+
EDIT_TOOLS = ("Edit", "MultiEdit", "Write", "apply_patch")
|
|
25
|
+
MAX_DOC_FINDINGS = 5
|
|
26
|
+
MAX_DUPLICATE_FINDINGS = 3
|
|
18
27
|
SMART_TOOL_SERVER = "smart-tool"
|
|
19
28
|
REASON_MAX_CHARS = 200
|
|
20
29
|
|
|
@@ -55,9 +64,94 @@ def advise(reason):
|
|
|
55
64
|
"additionalContext": f"Smart Tool tip (the call was not blocked): {reason}"}}
|
|
56
65
|
|
|
57
66
|
|
|
67
|
+
def _project_of(path):
|
|
68
|
+
target = os.path.normcase(os.path.abspath(path))
|
|
69
|
+
roots = [p["root"] for p in project_store.all_projects()
|
|
70
|
+
if target.startswith(os.path.normcase(os.path.abspath(p["root"])) + os.sep)]
|
|
71
|
+
return max(roots, key=len) if roots else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _impact_line(root, rel, name):
|
|
75
|
+
"""Callers and tests from the code graph, only when its analysis is already cached: the hook never waits for one."""
|
|
76
|
+
if not root or code_graph.cached_symbols(root)[0] is None:
|
|
77
|
+
return ""
|
|
78
|
+
try:
|
|
79
|
+
found = code_impact.impact(root, f"{rel}::{name}", limit=3)
|
|
80
|
+
except ValueError:
|
|
81
|
+
return ""
|
|
82
|
+
counts = found["counts"]
|
|
83
|
+
tests = ", ".join(f"{t['function']} ({t['path']})" for t in found["tests"])
|
|
84
|
+
calls = f"; tests calling it: {tests}" if tests else ""
|
|
85
|
+
return f" {counts['callers']} caller(s){calls}; project_manage affected_tests lists every test to run after the edit."
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _doc_lines(findings):
|
|
89
|
+
lines = []
|
|
90
|
+
for root, rel, item in findings[:MAX_DOC_FINDINGS]:
|
|
91
|
+
if item["issue"] == "missing":
|
|
92
|
+
text = f"{item['name']} ({rel}:{item['start']}) has no docstring: write one (purpose, parameters, return)."
|
|
93
|
+
else:
|
|
94
|
+
text = (f"{item['name']} ({rel}:{item['start']}) changed its signature: update its docstring if the "
|
|
95
|
+
f"contract changed.")
|
|
96
|
+
lines.append(text + _impact_line(root, rel, item["name"]))
|
|
97
|
+
if len(findings) > MAX_DOC_FINDINGS:
|
|
98
|
+
lines.append(f"{len(findings) - MAX_DOC_FINDINGS} more function(s) in the same situation.")
|
|
99
|
+
return "Smart Tool docs: " + " ".join(lines)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _duplicate_lines(findings):
|
|
103
|
+
lines = []
|
|
104
|
+
for rel, item in findings[:MAX_DUPLICATE_FINDINGS]:
|
|
105
|
+
existing = item["existing"]
|
|
106
|
+
where = f"{existing['name']} in {existing['path']}:{existing['lines']}"
|
|
107
|
+
if item["type"] == "exact":
|
|
108
|
+
lines.append(f"{item['name']} ({rel}:{item['lines']}) has the same body as {where}: reuse it instead of a copy.")
|
|
109
|
+
else:
|
|
110
|
+
lines.append(f"{item['name']} ({rel}:{item['lines']}) is near-identical to {where} (score {item['score']}, "
|
|
111
|
+
f"local names ignored): check whether it can be reused.")
|
|
112
|
+
if len(findings) > MAX_DUPLICATE_FINDINGS:
|
|
113
|
+
lines.append(f"{len(findings) - MAX_DUPLICATE_FINDINGS} more copied function(s).")
|
|
114
|
+
return ("Smart Tool duplicates: " + " ".join(lines)
|
|
115
|
+
+ " If the copy is intentional (moving the function, a deliberate fork), go ahead.")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _edit_review(payload, doc_mode, duplicate_mode):
|
|
119
|
+
"""Checks of an edit before it runs. Docstrings follow doc_mode: missing ones deny the edit in require mode and
|
|
120
|
+
become a reminder in remind mode; a documented function whose signature changed always gets a reminder. Copies of
|
|
121
|
+
indexed functions follow duplicate_mode and only add context, never deny."""
|
|
122
|
+
doc_findings, duplicate_findings, failures = [], [], []
|
|
123
|
+
for path, before, after in edit_preview.preview(payload.get("tool_name"), payload.get("tool_input"), payload.get("cwd")):
|
|
124
|
+
if not doc_check.supported(path):
|
|
125
|
+
continue
|
|
126
|
+
root = _project_of(path)
|
|
127
|
+
rel = os.path.relpath(path, root).replace(os.sep, "/") if root else os.path.basename(path)
|
|
128
|
+
edited = doc_check.written(path, before, after)
|
|
129
|
+
if doc_mode != "off":
|
|
130
|
+
doc_findings += [(root, rel, item) for item in doc_check.review(path, before, after, edited)]
|
|
131
|
+
if duplicate_mode != "off" and root and edited["touched"]:
|
|
132
|
+
try:
|
|
133
|
+
duplicate_findings += [(rel, item) for item in duplicates.on_write(root, path, before, after, edited)]
|
|
134
|
+
except (ValueError, RuntimeError) as exc:
|
|
135
|
+
failures.append(f"Smart Tool duplicates check skipped: {exc}")
|
|
136
|
+
parts = ([_doc_lines(doc_findings)] if doc_findings else []) + \
|
|
137
|
+
([_duplicate_lines(duplicate_findings)] if duplicate_findings else []) + failures
|
|
138
|
+
if not parts:
|
|
139
|
+
return {}
|
|
140
|
+
message = " ".join(parts)
|
|
141
|
+
if doc_mode == "require" and any(item["issue"] == "missing" for _root, _rel, item in doc_findings):
|
|
142
|
+
return deny(message + " Redo the edit with the docstring.")
|
|
143
|
+
return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": message}}
|
|
144
|
+
|
|
145
|
+
|
|
58
146
|
def decide(payload, client, web_route):
|
|
59
|
-
"""Hook output for one PreToolUse payload
|
|
147
|
+
"""Hook output for one PreToolUse payload: edits follow doc_mode (require | remind | off) and duplicate_mode
|
|
148
|
+
(warn | off), the other tools follow hook_mode (redirect | advise | off)."""
|
|
60
149
|
cfg = config.load_config()
|
|
150
|
+
if isinstance(payload, dict) and payload.get("tool_name") in EDIT_TOOLS:
|
|
151
|
+
doc_mode, duplicate_mode = config.doc_mode(cfg), config.duplicate_mode(cfg)
|
|
152
|
+
if doc_mode == "off" and duplicate_mode == "off":
|
|
153
|
+
return {}
|
|
154
|
+
return _edit_review(payload, doc_mode, duplicate_mode)
|
|
61
155
|
mode = config.hook_mode(cfg)
|
|
62
156
|
if mode == "off" or not isinstance(payload, dict):
|
|
63
157
|
return {}
|
|
@@ -82,7 +176,7 @@ def _route(payload, client, web_route, cfg):
|
|
|
82
176
|
return {}
|
|
83
177
|
decision = web_route(tool_name, str(tool_input.get("url") or ""))
|
|
84
178
|
return deny(clean_reason(decision.get("reason"))) if decision.get("redirect") else {}
|
|
85
|
-
router.CLIENT
|
|
179
|
+
router.CLIENT.set(client)
|
|
86
180
|
try:
|
|
87
181
|
decision, reason = router.decide(tool_name, tool_input, router_model, cwd=cwd)
|
|
88
182
|
except Exception as exc:
|
package/index_profile.py
CHANGED
|
@@ -17,7 +17,7 @@ DOC_DATA = {"html", "htm", "json", "csv", "xml", "yaml", "yml", "svg"}
|
|
|
17
17
|
_NAME_DOC = re.compile(r"(^|/)(readme|changelog|license|licence|contributing|install|history|authors)(\.[^/]*)?$", re.I)
|
|
18
18
|
_DIR_DOC = re.compile(r"(^|/)(docs?|adrs?|documentation)/", re.I)
|
|
19
19
|
_TEST = re.compile(r"(^|/)(tests?|__tests__|spec|specs|e2e|fixtures?|testdata|test-utils?)(/|$)|(^|/)test_[^/]+\.py$|"
|
|
20
|
-
r"(^|/)conftest\.py$|_test\.(py|go)$|\.(test|spec)
|
|
20
|
+
r"(^|/)conftest\.py$|_test\.(py|go)$|\.(test|spec)(-d)?\.[cm]?[jt]sx?$|(^|/)[^/]*[a-z0-9](?-i:Tests?)\.java$|"
|
|
21
21
|
r"(^|/)src/test/|(^|/)test[-_][^/]*\.[cm]?[jt]sx?$|[-_]test\.[cm]?[jt]sx?$", re.I)
|
|
22
22
|
_TEST_PROSE = re.compile(r"(^|/)(tests?|__tests__|e2e|fixtures?|testdata|test-utils?)(/|$)|(^|/)src/test/", re.I)
|
|
23
23
|
_TEXT_LINE = re.compile(r"[A-Za-zÀ-ú]{3,}\s+[A-Za-zÀ-ú]{3,}")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@allansantos-dev/smart-tool",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
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",
|
package/router.py
CHANGED
|
@@ -5,6 +5,7 @@ modelo pequeno se deve deixar passar ou redirecionar pra `smart_search`. Cada de
|
|
|
5
5
|
logada em `router-metrics.jsonl` pra orientar um classificador mais fino depois, com
|
|
6
6
|
base em uso real — não numa suposição a priori.
|
|
7
7
|
"""
|
|
8
|
+
import contextvars
|
|
8
9
|
import json
|
|
9
10
|
import os
|
|
10
11
|
import paths
|
|
@@ -17,7 +18,8 @@ import index_scope
|
|
|
17
18
|
import indexer
|
|
18
19
|
import web_search_adapters
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
# Per request: the daemon serves each hook call on its own thread, so a module global mixed up clients.
|
|
22
|
+
CLIENT = contextvars.ContextVar("router_client", default=None)
|
|
21
23
|
METRICS_PATH = os.path.join(paths.DATA_DIR, "router-metrics.jsonl")
|
|
22
24
|
|
|
23
25
|
_SYSTEM_PROMPT = (
|
|
@@ -266,7 +268,7 @@ def _log_decision(tool_name, tool_input, decision, reason, extra=None):
|
|
|
266
268
|
"tool_input": _redacted_input(tool_input),
|
|
267
269
|
"decision": decision,
|
|
268
270
|
"reason": reason,
|
|
269
|
-
**({"client": CLIENT} if CLIENT else {}),
|
|
271
|
+
**({"client": CLIENT.get()} if CLIENT.get() else {}),
|
|
270
272
|
}
|
|
271
273
|
record.update(extra or {})
|
|
272
274
|
try:
|
package/setup_ui.py
CHANGED
|
@@ -177,18 +177,31 @@ def handle_integration_get():
|
|
|
177
177
|
mode, mode_error = config.hook_mode(), ""
|
|
178
178
|
except ValueError as exc:
|
|
179
179
|
mode, mode_error = "", str(exc)
|
|
180
|
+
try:
|
|
181
|
+
doc_mode, doc_error = config.doc_mode(), ""
|
|
182
|
+
except ValueError as exc:
|
|
183
|
+
doc_mode, doc_error = "", str(exc)
|
|
184
|
+
try:
|
|
185
|
+
duplicate_mode, duplicate_error = config.duplicate_mode(), ""
|
|
186
|
+
except ValueError as exc:
|
|
187
|
+
duplicate_mode, duplicate_error = "", str(exc)
|
|
180
188
|
return {"daemon_url": setup_origin(), "clients": clients, "hook_mode": mode, "hook_mode_error": mode_error,
|
|
181
|
-
"hook_modes": list(config.HOOK_MODES)
|
|
189
|
+
"hook_modes": list(config.HOOK_MODES), "doc_mode": doc_mode, "doc_mode_error": doc_error,
|
|
190
|
+
"doc_modes": list(config.DOC_MODES), "duplicate_mode": duplicate_mode,
|
|
191
|
+
"duplicate_mode_error": duplicate_error, "duplicate_modes": list(config.DUPLICATE_MODES)}
|
|
182
192
|
|
|
183
193
|
|
|
184
194
|
def handle_integration_save(body):
|
|
185
195
|
"""`hook_mode` sets redirect/advise/off for every client; `register_mcp` runs the client's CLI; hook in two steps:
|
|
186
196
|
`preview` shows the exact change, `install` writes it (with backup)."""
|
|
187
|
-
if isinstance(body, dict) and body.get("action")
|
|
188
|
-
|
|
189
|
-
|
|
197
|
+
if isinstance(body, dict) and body.get("action") in ("hook_mode", "doc_mode", "duplicate_mode"):
|
|
198
|
+
action = body["action"]
|
|
199
|
+
allowed = {"hook_mode": config.HOOK_MODES, "doc_mode": config.DOC_MODES,
|
|
200
|
+
"duplicate_mode": config.DUPLICATE_MODES}[action]
|
|
201
|
+
if body.get("mode") not in allowed:
|
|
202
|
+
raise SetupError(f"Invalid mode: use {', '.join(allowed)}.")
|
|
190
203
|
try:
|
|
191
|
-
config.update_config(lambda cfg: cfg.update(
|
|
204
|
+
config.update_config(lambda cfg: cfg.update({action: body["mode"]}))
|
|
192
205
|
except (OSError, config.ConfigCorruptedError) as exc:
|
|
193
206
|
raise SetupError(str(exc))
|
|
194
207
|
return handle_integration_get()
|
|
@@ -565,6 +578,21 @@ _PAGE_TEMPLATE = """<!doctype html>
|
|
|
565
578
|
</select>
|
|
566
579
|
<span class="hint" id="hook-mode-hint">Applies to every client on the next tool call; nothing is reinstalled.</span></div>
|
|
567
580
|
<div class="actions"><button type="button" id="btn-save-hook-mode">Save behavior</button></div>
|
|
581
|
+
<div class="field" style="margin-top:16px"><label for="sel-doc-mode">Documentation on edit</label>
|
|
582
|
+
<select id="sel-doc-mode">
|
|
583
|
+
<option value="remind">Remind: the edit runs; the agent is told which touched functions lack a docstring (default)</option>
|
|
584
|
+
<option value="require">Require: an edit that leaves a touched function without a docstring is blocked until it adds one</option>
|
|
585
|
+
<option value="off">Off: no documentation checks</option>
|
|
586
|
+
</select>
|
|
587
|
+
<span class="hint">Only public functions the edit touches (private, nested and override functions are exempt, as in pydocstyle, eslint-plugin-jsdoc and Checkstyle). A documented function is mentioned again only when its signature changes; body-only edits stay silent.</span></div>
|
|
588
|
+
<div class="actions"><button type="button" id="btn-save-doc-mode">Save documentation</button></div>
|
|
589
|
+
<div class="field" style="margin-top:16px"><label for="sel-duplicate-mode">Duplicate functions on edit</label>
|
|
590
|
+
<select id="sel-duplicate-mode">
|
|
591
|
+
<option value="warn">Warn: the edit runs; the agent is told when a written function copies one already in the project (default)</option>
|
|
592
|
+
<option value="off">Off: no duplicate checks on edit</option>
|
|
593
|
+
</select>
|
|
594
|
+
<span class="hint">Compares functions the edit writes with the indexed code: identical bodies, and near-identical ones with only local names changed. Never blocks.</span></div>
|
|
595
|
+
<div class="actions"><button type="button" id="btn-save-duplicate-mode">Save duplicates</button></div>
|
|
568
596
|
<div class="msg" id="agents-msg" role="status"></div>
|
|
569
597
|
<div id="agents-list"></div>
|
|
570
598
|
</section>
|
|
@@ -849,7 +877,11 @@ async function loadAgents() {
|
|
|
849
877
|
const data = await resp.json();
|
|
850
878
|
if (!resp.ok || data.error) throw new Error(data.error);
|
|
851
879
|
if (document.activeElement !== $("sel-hook-mode")) $("sel-hook-mode").value = data.hook_mode;
|
|
880
|
+
if (document.activeElement !== $("sel-doc-mode")) $("sel-doc-mode").value = data.doc_mode;
|
|
881
|
+
if (document.activeElement !== $("sel-duplicate-mode")) $("sel-duplicate-mode").value = data.duplicate_mode;
|
|
852
882
|
if (data.hook_mode_error) setMsg("agents-msg", "error", data.hook_mode_error);
|
|
883
|
+
if (data.doc_mode_error) setMsg("agents-msg", "error", data.doc_mode_error);
|
|
884
|
+
if (data.duplicate_mode_error) setMsg("agents-msg", "error", data.duplicate_mode_error);
|
|
853
885
|
const list = $("agents-list");
|
|
854
886
|
list.textContent = "";
|
|
855
887
|
for (const item of data.clients) {
|
|
@@ -913,6 +945,22 @@ async function loadAgents() {
|
|
|
913
945
|
}
|
|
914
946
|
}
|
|
915
947
|
loadAgents();
|
|
948
|
+
$("btn-save-doc-mode").addEventListener("click", async () => {
|
|
949
|
+
try {
|
|
950
|
+
await post("/setup/integration", { action: "doc_mode", mode: $("sel-doc-mode").value });
|
|
951
|
+
setMsg("agents-msg", "ok", "Documentation setting saved; it applies on the next edit.");
|
|
952
|
+
} catch (error) {
|
|
953
|
+
setMsg("agents-msg", "error", error.message);
|
|
954
|
+
}
|
|
955
|
+
});
|
|
956
|
+
$("btn-save-duplicate-mode").addEventListener("click", async () => {
|
|
957
|
+
try {
|
|
958
|
+
await post("/setup/integration", { action: "duplicate_mode", mode: $("sel-duplicate-mode").value });
|
|
959
|
+
setMsg("agents-msg", "ok", "Duplicate setting saved; it applies on the next edit.");
|
|
960
|
+
} catch (error) {
|
|
961
|
+
setMsg("agents-msg", "error", error.message);
|
|
962
|
+
}
|
|
963
|
+
});
|
|
916
964
|
$("btn-save-hook-mode").addEventListener("click", async () => {
|
|
917
965
|
try {
|
|
918
966
|
await post("/setup/integration", { action: "hook_mode", mode: $("sel-hook-mode").value });
|
package/smart_tool_daemon.py
CHANGED
|
@@ -48,6 +48,7 @@ import index_views
|
|
|
48
48
|
import index_inventory
|
|
49
49
|
import usage_meter
|
|
50
50
|
import document_text
|
|
51
|
+
import embedding_cache
|
|
51
52
|
import model_defaults
|
|
52
53
|
import project_identity
|
|
53
54
|
import project_store
|
|
@@ -291,7 +292,7 @@ def _smart_search_job_payload(job_id):
|
|
|
291
292
|
else:
|
|
292
293
|
payload["error"] = job.get("error") or "Search did not complete."
|
|
293
294
|
payload["resumable"] = bool(job.get("arguments"))
|
|
294
|
-
if job.get("stats"):
|
|
295
|
+
if job.get("stats") and job["stats"] != payload.get("result"):
|
|
295
296
|
payload["stats"] = job["stats"]
|
|
296
297
|
if job.get("failure_phase"):
|
|
297
298
|
payload["failure_phase"] = job["failure_phase"]
|
|
@@ -302,10 +303,10 @@ def _smart_search_job_payload(job_id):
|
|
|
302
303
|
TOOLS = [
|
|
303
304
|
{
|
|
304
305
|
"name": "project_manage",
|
|
305
|
-
"description": "Manages folders and indexing. list
|
|
306
|
+
"description": "Manages folders and indexing. Every action takes project_root (the folder) or project_id. list gives a short line per project; status shows storage, scope and jobs. graph with symbol (function, Class.method, class name, or path::name when the name repeats) answers what an edit touches: definition, callers with line, calls, tests that reach it through static calls (depth hops, default 3) and files importing it; use it before changing a function to know what to update. affected_tests answers which tests to run after editing: every test file importing a changed file (git diff against base, default HEAD, untracked included) plus changed tests, likeliest failures first, with the command to run them and run_all when config or unanalyzed code changed. graph without symbol reads the indexed view: coverage, references and static calls in Java, Angular, Python and JS/TS/JSX/TSX; includes HTML/CSS/Markdown references and TXT/DOCX coverage; with file_path, narrows to that file's symbols, imports and calls (lists capped by limit). inspect lists the files of a view_id; with file_path, returns only that file's chunks. usage shows consumption and reuse; docs lists public functions without a docstring (Python docstring, JSDoc, Javadoc; private, nested and override functions excluded) with coverage percent, excluding tests unless include_tests=true, for documenting a project that started without it; duplicates lists duplicated functions (identical, near-identical and semantic bodies, min_similarity default 0.90; below that most pairs measured were false positives), excluding tests unless include_tests=true; fix real duplicates; only dismiss with duplicates_dismiss (finding_id, reason=false_positive|intentional, note) what is not a duplicate or is a copy kept on purpose: the finding stays hidden until the code changes, and duplicates_restore reopens it; integration shows the MCP client, hook and tool usage metrics; install_hook_preview shows the change that would install the hook redirecting Grep/Read to smart_search and WebSearch/WebFetch to web_search/web_fetch in this session's client and returns a plan_id; install_hook writes it with that plan_id and confirm=true, with a backup, only after the user approves the change; search_limits saves the project's default top_k per block ({code, test, doc}, each 1-20, total at most 20); storage shows disk usage and views; pin_view protects a view; cleanup_preview and cleanup_commit remove selected views after confirmation; compare compares two stored views, without checkout or embeddings. register adds a folder; preview checks the scope; index/rebuild update; pause/cancel keep checkpoints; resume continues; policy picks on_search/eager; scope adjusts the scope; remove deletes only the local index. Jobs via smart_search_result.",
|
|
306
307
|
"inputSchema": {
|
|
307
308
|
"type": "object", "properties": {
|
|
308
|
-
"action": {"type": "string", "enum": ["list", "status", "register", "preview", "index", "rebuild", "pause", "cancel", "resume", "watch", "scope", "remove", "relocate", "probe", "policy", "inspect", "graph", "usage", "storage", "pin_view", "cleanup_preview", "cleanup_commit", "compare", "search_limits", "integration", "install_hook_preview", "install_hook", "duplicates", "duplicates_dismiss", "duplicates_restore"]},
|
|
309
|
+
"action": {"type": "string", "enum": ["list", "status", "register", "preview", "index", "rebuild", "pause", "cancel", "resume", "watch", "scope", "remove", "relocate", "probe", "policy", "inspect", "graph", "usage", "storage", "pin_view", "cleanup_preview", "cleanup_commit", "compare", "search_limits", "integration", "install_hook_preview", "install_hook", "duplicates", "duplicates_dismiss", "duplicates_restore", "docs", "affected_tests"]},
|
|
309
310
|
"finding_id": {"type": "string"},
|
|
310
311
|
"reason": {"type": "string", "enum": ["false_positive", "intentional"]},
|
|
311
312
|
"note": {"type": "string"},
|
|
@@ -324,6 +325,9 @@ TOOLS = [
|
|
|
324
325
|
"left_view":{"type":"string"},"right_view":{"type":"string"},"relations":{"type":"boolean"},
|
|
325
326
|
"update_mode": {"type": "string", "enum": ["on_search", "eager"]},
|
|
326
327
|
"view_id": {"type": "string"}, "file_path": {"type": "string"},
|
|
328
|
+
"base": {"type": "string", "default": "HEAD", "description": "affected_tests: git revision the changes are compared with (HEAD = uncommitted work; main or origin/main for a branch)"},
|
|
329
|
+
"symbol": {"type": "string", "description": "graph: function, Class.method, class name or path::name to get its callers, calls, tests and importers"},
|
|
330
|
+
"depth": {"type": "integer", "minimum": 1, "maximum": 4, "default": 3, "description": "graph with symbol: call hops searched for tests"},
|
|
327
331
|
"project_root": {"type": "string"}, "project_id": {"type": "string"},
|
|
328
332
|
"job_id": {"type": "string"}, "watch": {"type": "boolean"},
|
|
329
333
|
"manual": {"type": "boolean"}, "force_scope": {"type": "boolean"},
|
|
@@ -1414,27 +1418,69 @@ def _find_duplicates(root, arguments, dismissed):
|
|
|
1414
1418
|
return duplicates.find(root, embed, configured_model, float(similarity), include_tests, limit, dismissed, include_dismissed)
|
|
1415
1419
|
|
|
1416
1420
|
|
|
1421
|
+
def _with_project_id(arguments):
|
|
1422
|
+
"""Agents know the folder, not the id: project_root resolves to the registered project's project_id."""
|
|
1423
|
+
root = arguments.get("project_root")
|
|
1424
|
+
if arguments.get("project_id") or not isinstance(root, str) or arguments.get("action") in ("register", "relocate"):
|
|
1425
|
+
return arguments
|
|
1426
|
+
wanted = project_identity.canonical_root(root)
|
|
1427
|
+
match = next((p for p in project_store.all_projects() if project_identity.canonical_root(p["root"]) == wanted), None)
|
|
1428
|
+
if not match:
|
|
1429
|
+
raise ValueError(f"No registered project at {root}; register it first (action=register).")
|
|
1430
|
+
return {**arguments, "project_id": match["id"]}
|
|
1431
|
+
|
|
1432
|
+
|
|
1433
|
+
def _project_summary(project):
|
|
1434
|
+
data = _project_payload(project)
|
|
1435
|
+
index = data.get("index") or {}
|
|
1436
|
+
jobs = data.get("jobs") or []
|
|
1437
|
+
return {"project_id": data["id"], "name": data.get("name"), "root": data["root"], "status": data.get("status"),
|
|
1438
|
+
"enabled": data.get("enabled"), "paused": data.get("paused"), "update_mode": data.get("update_mode"),
|
|
1439
|
+
"files": index.get("files"), "chunks": index.get("chunks"), "last_indexed": data.get("last_indexed"),
|
|
1440
|
+
"view": (data.get("view") or {}).get("label"), "active_jobs": len(data.get("active_jobs") or []),
|
|
1441
|
+
"last_job": {k: jobs[0].get(k) for k in ("job_id", "status", "kind")} if jobs else None,
|
|
1442
|
+
"last_error": (data.get("last_error") or None) and str(data["last_error"])[:200]}
|
|
1443
|
+
|
|
1444
|
+
|
|
1417
1445
|
def _project_action(arguments):
|
|
1418
1446
|
if not isinstance(arguments, dict):
|
|
1419
1447
|
raise ValueError("Pass the action in a JSON object.")
|
|
1448
|
+
arguments = _with_project_id(arguments)
|
|
1420
1449
|
action = arguments.get("action", "list")
|
|
1421
1450
|
if action == "probe":
|
|
1422
1451
|
return _probe_index_models()
|
|
1423
|
-
if action in ('graph','compare','duplicates','duplicates_dismiss','duplicates_restore'):
|
|
1452
|
+
if action in ('graph','compare','duplicates','duplicates_dismiss','duplicates_restore','docs','affected_tests'):
|
|
1424
1453
|
# Análise somente leitura pode demorar; não prende o lock global de ações.
|
|
1425
1454
|
key=arguments.get('project_id')
|
|
1426
1455
|
if not isinstance(key,str) or not re.fullmatch(r'[a-f0-9]{16}',key):
|
|
1427
|
-
raise ValueError('Pass a valid project_id.')
|
|
1456
|
+
raise ValueError('Pass project_root (the folder) or a valid project_id.')
|
|
1428
1457
|
project=project_store.get(key)
|
|
1429
1458
|
if not project:
|
|
1430
1459
|
raise ValueError('Project not registered.')
|
|
1431
1460
|
if action=='duplicates':
|
|
1432
1461
|
return _find_duplicates(project['root'], arguments, project.get('dismissed_duplicates') or {})
|
|
1462
|
+
if action=='docs':
|
|
1463
|
+
import doc_check
|
|
1464
|
+
include_tests=arguments.get('include_tests',False)
|
|
1465
|
+
if type(include_tests) is not bool:
|
|
1466
|
+
raise ValueError('include_tests must be true or false.')
|
|
1467
|
+
limit=_parse_bounded_int(arguments.get('limit'),default=30,min_v=1,max_v=200,field_name='limit')
|
|
1468
|
+
return doc_check.coverage(project['root'],include_tests,limit,arguments.get('view_id'))
|
|
1469
|
+
if action=='affected_tests':
|
|
1470
|
+
import affected_tests
|
|
1471
|
+
limit=_parse_bounded_int(arguments.get('limit'),default=30,min_v=1,max_v=200,field_name='limit')
|
|
1472
|
+
return affected_tests.affected(project['root'],arguments.get('base','HEAD'),limit,arguments.get('view_id'))
|
|
1433
1473
|
if action in ('duplicates_dismiss','duplicates_restore'):
|
|
1434
1474
|
return _change_dismissal(key, project, arguments, action == 'duplicates_dismiss')
|
|
1435
1475
|
if action=='graph':
|
|
1436
1476
|
import code_graph
|
|
1437
|
-
|
|
1477
|
+
import code_impact
|
|
1478
|
+
limit=_parse_bounded_int(arguments.get('limit'),default=30,min_v=1,max_v=200,field_name='limit')
|
|
1479
|
+
if arguments.get('symbol') is not None:
|
|
1480
|
+
return code_impact.impact(project['root'],arguments['symbol'],arguments.get('view_id'),
|
|
1481
|
+
arguments.get('depth',3),limit)
|
|
1482
|
+
graph=code_graph.build(project['root'],arguments.get('view_id'),file_path=arguments.get('file_path'))
|
|
1483
|
+
return code_impact.for_agent(graph,limit) if arguments.get('file_path') else graph
|
|
1438
1484
|
import view_compare
|
|
1439
1485
|
return view_compare.compare(project['root'],arguments.get('left_view'),arguments.get('right_view'),
|
|
1440
1486
|
arguments.get('file_path'),arguments.get('relations',True))
|
|
@@ -1513,6 +1559,9 @@ def _project_action_locked(arguments):
|
|
|
1513
1559
|
_metric("install_hook", target=result["client"], changed=result["changed"])
|
|
1514
1560
|
return result
|
|
1515
1561
|
if action == "list":
|
|
1562
|
+
if not arguments.get("full"):
|
|
1563
|
+
return {"projects": [_project_summary(p) for p in project_store.all_projects()],
|
|
1564
|
+
"detail": "Use action=status with project_id or project_root for scope, jobs and index details."}
|
|
1516
1565
|
return {"projects": [_project_payload(p) for p in project_store.all_projects()],
|
|
1517
1566
|
"storage_dir": indexer.INDEX_DIR,
|
|
1518
1567
|
"gateway": dict(_GATEWAY_STATUS),
|
|
@@ -1526,7 +1575,7 @@ def _project_action_locked(arguments):
|
|
|
1526
1575
|
return {"project": _project_payload(project)}
|
|
1527
1576
|
key = arguments.get("project_id")
|
|
1528
1577
|
if not isinstance(key, str) or not re.fullmatch(r"[a-f0-9]{16}", key):
|
|
1529
|
-
raise ValueError("Pass
|
|
1578
|
+
raise ValueError("Pass project_root (the folder) or a valid project_id.")
|
|
1530
1579
|
project = project_store.get(key)
|
|
1531
1580
|
if not project:
|
|
1532
1581
|
raise ValueError("Project not registered.")
|
|
@@ -1995,10 +2044,8 @@ def _fan_out_web_search(query, num, budget_s=None, site_domains=()):
|
|
|
1995
2044
|
try:
|
|
1996
2045
|
fut.result()
|
|
1997
2046
|
_record_tier_health(tier_name, success=True)
|
|
1998
|
-
except
|
|
1999
|
-
|
|
2000
|
-
except Exception:
|
|
2001
|
-
_record_tier_health(tier_name, success=False)
|
|
2047
|
+
except Exception as exc:
|
|
2048
|
+
_record_tier_failure(tier_name, exc)
|
|
2002
2049
|
return _cb
|
|
2003
2050
|
|
|
2004
2051
|
for fut, tier_name in futures.items():
|
|
@@ -2243,8 +2290,7 @@ def _search_web_once(query, num, scope, deadline=None, assess_semantic=False,
|
|
|
2243
2290
|
if not raw_results:
|
|
2244
2291
|
raise RuntimeError("zero results in the requested domain")
|
|
2245
2292
|
except Exception as exc:
|
|
2246
|
-
|
|
2247
|
-
_record_tier_health(tier_name, success=False)
|
|
2293
|
+
_record_tier_failure(tier_name, exc)
|
|
2248
2294
|
errors.append(_sanitize_text(f"{tier_name}: {type(exc).__name__}: {exc}"))
|
|
2249
2295
|
continue
|
|
2250
2296
|
_record_tier_health(tier_name, success=True)
|
|
@@ -2278,8 +2324,7 @@ def _search_web_once(query, num, scope, deadline=None, assess_semantic=False,
|
|
|
2278
2324
|
if not raw_results:
|
|
2279
2325
|
raise RuntimeError("zero results in the requested domain")
|
|
2280
2326
|
except Exception as exc:
|
|
2281
|
-
|
|
2282
|
-
_record_tier_health(tier_name, success=False)
|
|
2327
|
+
_record_tier_failure(tier_name, exc)
|
|
2283
2328
|
errors.append(_sanitize_text(f"{tier_name}: {type(exc).__name__}: {exc}"))
|
|
2284
2329
|
continue
|
|
2285
2330
|
_record_tier_health(tier_name, success=True)
|
|
@@ -2588,13 +2633,26 @@ def _annotate_quick_results(rows, classification, cache=None):
|
|
|
2588
2633
|
return result
|
|
2589
2634
|
|
|
2590
2635
|
|
|
2591
|
-
def _record_tier_health(tier_name, success):
|
|
2636
|
+
def _record_tier_health(tier_name, success, outcome=None):
|
|
2592
2637
|
try:
|
|
2593
|
-
web_search_health.record(tier_name, success)
|
|
2638
|
+
web_search_health.record(tier_name, success, outcome)
|
|
2594
2639
|
except Exception:
|
|
2595
2640
|
pass # telemetria é best-effort — nunca pode derrubar uma busca já resolvida
|
|
2596
2641
|
|
|
2597
2642
|
|
|
2643
|
+
def _record_tier_failure(tier_name, exc):
|
|
2644
|
+
"""Health entry for a failed tier call, keeping a 429, a captcha and a quota pause apart from other errors."""
|
|
2645
|
+
if isinstance(exc, (TierSkipped, browser_service_client.BrowserSkipped)):
|
|
2646
|
+
outcome = "paused"
|
|
2647
|
+
elif isinstance(exc, WebProviderRateLimited):
|
|
2648
|
+
outcome = "rate_limited"
|
|
2649
|
+
elif isinstance(exc, browser_service_client.BrowserCaptcha):
|
|
2650
|
+
outcome = "captcha"
|
|
2651
|
+
else:
|
|
2652
|
+
outcome = "failed"
|
|
2653
|
+
_record_tier_health(tier_name, False, outcome)
|
|
2654
|
+
|
|
2655
|
+
|
|
2598
2656
|
_is_safe_crawl_target = web_fetch.is_safe_target
|
|
2599
2657
|
|
|
2600
2658
|
|
|
@@ -3073,6 +3131,7 @@ def _tool_result(text, is_error=False):
|
|
|
3073
3131
|
|
|
3074
3132
|
WEB_FETCH_TIMEOUT_S = 90
|
|
3075
3133
|
WEB_FETCH_RENDER_TIMEOUT_S = 60
|
|
3134
|
+
WEB_FETCH_VECTOR_ROOT = os.path.join(paths.DATA_DIR, "web-fetch")
|
|
3076
3135
|
|
|
3077
3136
|
|
|
3078
3137
|
def _render_page(url, deadline):
|
|
@@ -3085,6 +3144,30 @@ def _render_page(url, deadline):
|
|
|
3085
3144
|
return (pages[0].get("markdown") or "") if pages else ""
|
|
3086
3145
|
|
|
3087
3146
|
|
|
3147
|
+
def _web_fetch_embed(texts, deadline):
|
|
3148
|
+
"""Vectors for the pieces of a long page and the prompt, with the pieces cached per embedding model. Fails loudly
|
|
3149
|
+
without an embedding model: a long page is never cut back to its start in silence."""
|
|
3150
|
+
model = local_embedder.resolve(config.load_config().get("embedding_model"), None)
|
|
3151
|
+
if not model or model == indexer.LEXICAL_MODEL:
|
|
3152
|
+
raise RuntimeError("This page is longer than 60,000 characters and choosing its relevant parts needs an "
|
|
3153
|
+
"embedding_model, which is not configured")
|
|
3154
|
+
try:
|
|
3155
|
+
token = model_client.get_token()
|
|
3156
|
+
probe = _embed(model, texts[-1:], token, deadline=deadline)
|
|
3157
|
+
cache = embedding_cache.VectorCache(WEB_FETCH_VECTOR_ROOT, indexer.INDEX_DIR, {
|
|
3158
|
+
"purpose": "web_fetch_pieces", "model": model, "dimensions": len(probe[0])})
|
|
3159
|
+
try:
|
|
3160
|
+
pieces = cache.embed(texts[:-1], lambda batch: _embed(model, batch, token, deadline=deadline),
|
|
3161
|
+
indexer.validate_vector, split=lambda unique: list(_embed_batches(unique)),
|
|
3162
|
+
workers=indexer.EMBED_WORKERS)
|
|
3163
|
+
finally:
|
|
3164
|
+
cache.db.close()
|
|
3165
|
+
except Exception as exc:
|
|
3166
|
+
raise RuntimeError(f"This page is longer than 60,000 characters and the embedding model ({model}) that picks "
|
|
3167
|
+
f"its relevant parts failed: {type(exc).__name__}: {exc}"[:400]) from None
|
|
3168
|
+
return pieces + probe
|
|
3169
|
+
|
|
3170
|
+
|
|
3088
3171
|
def _web_fetch_unavailable():
|
|
3089
3172
|
cfg = config.load_config()
|
|
3090
3173
|
if not cfg.get("router_model"):
|
|
@@ -3105,18 +3188,22 @@ def _handle_web_fetch(arguments):
|
|
|
3105
3188
|
raise RuntimeError(f"Gateway model unavailable ({problem}).")
|
|
3106
3189
|
deadline = started + WEB_FETCH_TIMEOUT_S
|
|
3107
3190
|
page = web_fetch.read_page(url, deadline, _render_page)
|
|
3108
|
-
|
|
3191
|
+
selected, pieces = web_fetch.select(page["text"], prompt, lambda texts: _web_fetch_embed(texts, deadline))
|
|
3192
|
+
text = web_fetch.answer(config.load_config()["router_model"], prompt, {**page, "text": selected}, deadline)
|
|
3109
3193
|
except Exception as exc:
|
|
3110
3194
|
web_fetch.mark_failed(str(url))
|
|
3111
3195
|
_metric("web_fetch", error=type(exc).__name__, elapsed_s=round(time.monotonic() - started, 2))
|
|
3112
3196
|
raise RuntimeError(f"web_fetch failed: {str(exc).rstrip('.')}. The native WebFetch is allowed for this URL for "
|
|
3113
3197
|
f"{web_fetch.FAILED_TTL_S // 60} min.") from None
|
|
3114
3198
|
_metric("web_fetch", cached=page["cached"], rendered=page["rendered"], render_error=page.get("render_error"),
|
|
3115
|
-
page_chars=
|
|
3199
|
+
page_chars=page["chars"], read_chars=len(selected), pieces=pieces,
|
|
3116
3200
|
answer_chars=len(text), elapsed_s=round(time.monotonic() - started, 2))
|
|
3117
3201
|
origin = "from cache" if page["cached"] else "rendered in the browser" if page["rendered"] else "downloaded now"
|
|
3118
3202
|
read_at = datetime.datetime.fromtimestamp(page["fetched_at"]).strftime("%Y-%m-%d %H:%M")
|
|
3119
|
-
cut = "; page
|
|
3203
|
+
cut = (f"; page of {page['chars']:,} characters: the {pieces} parts most related to the request were read"
|
|
3204
|
+
if pieces else "")
|
|
3205
|
+
if page["truncated"]:
|
|
3206
|
+
cut += f"; only the first {web_fetch.PAGE_STORE_MAX_CHARS:,} characters were kept"
|
|
3120
3207
|
return _sanitize_text(f"Source: {page['final_url']} (read at {read_at}, {origin}{cut})\n\n{text}")
|
|
3121
3208
|
|
|
3122
3209
|
|
|
@@ -3395,7 +3482,7 @@ class MCPHandler(BaseHTTPRequestHandler):
|
|
|
3395
3482
|
if self.path == "/setup/projects":
|
|
3396
3483
|
try:
|
|
3397
3484
|
setup_ui.require_token(self.headers)
|
|
3398
|
-
self._write_json(200, _project_action({"action": "list"}))
|
|
3485
|
+
self._write_json(200, _project_action({"action": "list", "full": True}))
|
|
3399
3486
|
except setup_ui.SetupError as exc:
|
|
3400
3487
|
self._write_json(403, {"error": str(exc)})
|
|
3401
3488
|
except Exception as exc:
|
package/version.py
CHANGED