@allansantos-dev/smart-tool 0.9.2 → 0.9.4
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 +86 -0
- package/README.md +27 -2
- package/affected_tests.py +301 -0
- package/client_hooks.py +2 -2
- package/code_graph.py +200 -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 +188 -16
- package/edit_preview.py +92 -0
- package/hook_decision.py +100 -2
- package/index_profile.py +1 -1
- package/index_scope.py +16 -0
- package/indexer.py +10 -0
- package/package.json +1 -1
- package/router.py +19 -11
- package/setup_ui.py +53 -5
- package/smart_tool_daemon.py +110 -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/hook_decision.py
CHANGED
|
@@ -10,11 +10,22 @@ 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
|
|
19
|
+
import index_profile
|
|
20
|
+
import index_scope
|
|
14
21
|
import endpoint_sync
|
|
22
|
+
import project_store
|
|
15
23
|
import router
|
|
16
24
|
|
|
17
25
|
WEB_TOOLS = ("WebSearch", "WebFetch")
|
|
26
|
+
EDIT_TOOLS = ("Edit", "MultiEdit", "Write", "apply_patch")
|
|
27
|
+
MAX_DOC_FINDINGS = 5
|
|
28
|
+
MAX_DUPLICATE_FINDINGS = 3
|
|
18
29
|
SMART_TOOL_SERVER = "smart-tool"
|
|
19
30
|
REASON_MAX_CHARS = 200
|
|
20
31
|
|
|
@@ -55,9 +66,96 @@ def advise(reason):
|
|
|
55
66
|
"additionalContext": f"Smart Tool tip (the call was not blocked): {reason}"}}
|
|
56
67
|
|
|
57
68
|
|
|
69
|
+
def _project_of(path):
|
|
70
|
+
target = os.path.normcase(os.path.abspath(path))
|
|
71
|
+
roots = [p["root"] for p in project_store.all_projects()
|
|
72
|
+
if target.startswith(os.path.normcase(os.path.abspath(p["root"])) + os.sep)]
|
|
73
|
+
return max(roots, key=len) if roots else None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _impact_line(root, rel, name):
|
|
77
|
+
"""Callers and tests from the code graph, only when its analysis is already cached: the hook never waits for one."""
|
|
78
|
+
if not root or code_graph.cached_symbols(root)[0] is None:
|
|
79
|
+
return ""
|
|
80
|
+
try:
|
|
81
|
+
found = code_impact.impact(root, f"{rel}::{name}", limit=3)
|
|
82
|
+
except ValueError:
|
|
83
|
+
return ""
|
|
84
|
+
counts = found["counts"]
|
|
85
|
+
tests = ", ".join(f"{t['function']} ({t['path']})" for t in found["tests"])
|
|
86
|
+
calls = f"; tests calling it: {tests}" if tests else ""
|
|
87
|
+
return f" {counts['callers']} caller(s){calls}; project_manage affected_tests lists every test to run after the edit."
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _doc_lines(findings):
|
|
91
|
+
lines = []
|
|
92
|
+
for root, rel, item in findings[:MAX_DOC_FINDINGS]:
|
|
93
|
+
if item["issue"] == "missing":
|
|
94
|
+
text = f"{item['name']} ({rel}:{item['start']}) has no docstring: write one (purpose, parameters, return)."
|
|
95
|
+
else:
|
|
96
|
+
text = (f"{item['name']} ({rel}:{item['start']}) changed its signature: update its docstring if the "
|
|
97
|
+
f"contract changed.")
|
|
98
|
+
lines.append(text + _impact_line(root, rel, item["name"]))
|
|
99
|
+
if len(findings) > MAX_DOC_FINDINGS:
|
|
100
|
+
lines.append(f"{len(findings) - MAX_DOC_FINDINGS} more function(s) in the same situation.")
|
|
101
|
+
return "Smart Tool docs: " + " ".join(lines)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _duplicate_lines(findings):
|
|
105
|
+
lines = []
|
|
106
|
+
for rel, item in findings[:MAX_DUPLICATE_FINDINGS]:
|
|
107
|
+
existing = item["existing"]
|
|
108
|
+
where = f"{existing['name']} in {existing['path']}:{existing['lines']}"
|
|
109
|
+
if item["type"] == "exact":
|
|
110
|
+
lines.append(f"{item['name']} ({rel}:{item['lines']}) has the same body as {where}: reuse it instead of a copy.")
|
|
111
|
+
else:
|
|
112
|
+
lines.append(f"{item['name']} ({rel}:{item['lines']}) is near-identical to {where} (score {item['score']}, "
|
|
113
|
+
f"local names ignored): check whether it can be reused.")
|
|
114
|
+
if len(findings) > MAX_DUPLICATE_FINDINGS:
|
|
115
|
+
lines.append(f"{len(findings) - MAX_DUPLICATE_FINDINGS} more copied function(s).")
|
|
116
|
+
return ("Smart Tool duplicates: " + " ".join(lines)
|
|
117
|
+
+ " If the copy is intentional (moving the function, a deliberate fork), go ahead.")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _edit_review(payload, doc_mode, duplicate_mode):
|
|
121
|
+
"""Checks of an edit before it runs. Docstrings follow doc_mode, in code files of registered projects only (tests,
|
|
122
|
+
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
|
|
123
|
+
documented function whose signature changed always gets a reminder. Copies of indexed functions follow
|
|
124
|
+
duplicate_mode and only add context, never deny."""
|
|
125
|
+
doc_findings, duplicate_findings, failures = [], [], []
|
|
126
|
+
for path, before, after in edit_preview.preview(payload.get("tool_name"), payload.get("tool_input"), payload.get("cwd")):
|
|
127
|
+
if not doc_check.supported(path):
|
|
128
|
+
continue
|
|
129
|
+
root = _project_of(path)
|
|
130
|
+
rel = os.path.relpath(path, root).replace(os.sep, "/") if root else os.path.basename(path)
|
|
131
|
+
edited = doc_check.written(path, before, after)
|
|
132
|
+
profile = index_profile.current((index_scope.load_scope(root) or {}).get("profile")) if root else None
|
|
133
|
+
if doc_mode != "off" and root and index_profile.kind(rel, profile) == "code":
|
|
134
|
+
doc_findings += [(root, rel, item) for item in doc_check.review(path, before, after, edited)]
|
|
135
|
+
if duplicate_mode != "off" and root and edited["touched"]:
|
|
136
|
+
try:
|
|
137
|
+
duplicate_findings += [(rel, item) for item in duplicates.on_write(root, path, before, after, edited)]
|
|
138
|
+
except (ValueError, RuntimeError) as exc:
|
|
139
|
+
failures.append(f"Smart Tool duplicates check skipped: {exc}")
|
|
140
|
+
parts = ([_doc_lines(doc_findings)] if doc_findings else []) + \
|
|
141
|
+
([_duplicate_lines(duplicate_findings)] if duplicate_findings else []) + failures
|
|
142
|
+
if not parts:
|
|
143
|
+
return {}
|
|
144
|
+
message = " ".join(parts)
|
|
145
|
+
if doc_mode == "require" and any(item["issue"] == "missing" for _root, _rel, item in doc_findings):
|
|
146
|
+
return deny(message + " Redo the edit with the docstring.")
|
|
147
|
+
return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": message}}
|
|
148
|
+
|
|
149
|
+
|
|
58
150
|
def decide(payload, client, web_route):
|
|
59
|
-
"""Hook output for one PreToolUse payload
|
|
151
|
+
"""Hook output for one PreToolUse payload: edits follow doc_mode (require | remind | off) and duplicate_mode
|
|
152
|
+
(warn | off), the other tools follow hook_mode (redirect | advise | off)."""
|
|
60
153
|
cfg = config.load_config()
|
|
154
|
+
if isinstance(payload, dict) and payload.get("tool_name") in EDIT_TOOLS:
|
|
155
|
+
doc_mode, duplicate_mode = config.doc_mode(cfg), config.duplicate_mode(cfg)
|
|
156
|
+
if doc_mode == "off" and duplicate_mode == "off":
|
|
157
|
+
return {}
|
|
158
|
+
return _edit_review(payload, doc_mode, duplicate_mode)
|
|
61
159
|
mode = config.hook_mode(cfg)
|
|
62
160
|
if mode == "off" or not isinstance(payload, dict):
|
|
63
161
|
return {}
|
|
@@ -82,7 +180,7 @@ def _route(payload, client, web_route, cfg):
|
|
|
82
180
|
return {}
|
|
83
181
|
decision = web_route(tool_name, str(tool_input.get("url") or ""))
|
|
84
182
|
return deny(clean_reason(decision.get("reason"))) if decision.get("redirect") else {}
|
|
85
|
-
router.CLIENT
|
|
183
|
+
router.CLIENT.set(client)
|
|
86
184
|
try:
|
|
87
185
|
decision, reason = router.decide(tool_name, tool_input, router_model, cwd=cwd)
|
|
88
186
|
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/index_scope.py
CHANGED
|
@@ -415,6 +415,22 @@ def _exclude_matcher(exclude):
|
|
|
415
415
|
return matches
|
|
416
416
|
|
|
417
417
|
|
|
418
|
+
def in_scope(scope, rel_path):
|
|
419
|
+
"""Whether resolve_included_files would list rel_path: under an include base, outside the exclusions and outside
|
|
420
|
+
hidden folders."""
|
|
421
|
+
rel_path = rel_path.replace("\\", "/")
|
|
422
|
+
include = scope.get("include") or [""]
|
|
423
|
+
matcher = _exclude_matcher(set(scope.get("exclude") or []) | set(scope.get("user_exclude") or []) |
|
|
424
|
+
_ALWAYS_EXCLUDE | _ALWAYS_EXCLUDE_FILES)
|
|
425
|
+
parts = rel_path.split("/")
|
|
426
|
+
if matcher(rel_path) or any(part.startswith(".") for part in parts[:-1]):
|
|
427
|
+
return False
|
|
428
|
+
if any(base in ("", ".") for base in include):
|
|
429
|
+
return True
|
|
430
|
+
return len(parts) == 1 or any(rel_path == base.strip("/") or rel_path.startswith(base.strip("/") + "/")
|
|
431
|
+
for base in include)
|
|
432
|
+
|
|
433
|
+
|
|
418
434
|
def resolve_included_files(root, scope, cancel_check=None):
|
|
419
435
|
if not valid_scope(scope):
|
|
420
436
|
raise ValueError("Invalid scope; request a new project analysis.")
|
package/indexer.py
CHANGED
|
@@ -841,6 +841,12 @@ def search(root, query_vector=None, top_k=20, query=None, model_id=None, include
|
|
|
841
841
|
return [(scores[chunk_id], *by_id[chunk_id][1:5]) for chunk_id in ids[:top_k]]
|
|
842
842
|
|
|
843
843
|
|
|
844
|
+
def graph_cache_path(view_path):
|
|
845
|
+
"""Where code_graph keeps the saved analysis of one index view (graph-cache/<view>.json.gz next to it)."""
|
|
846
|
+
return os.path.join(os.path.dirname(view_path), "graph-cache",
|
|
847
|
+
os.path.basename(view_path).removesuffix(".sqlite3") + ".json.gz")
|
|
848
|
+
|
|
849
|
+
|
|
844
850
|
def remove_index(root):
|
|
845
851
|
"""Remove somente arquivos de índice da raiz conhecida, nunca arquivos do projeto."""
|
|
846
852
|
import glob
|
|
@@ -855,6 +861,10 @@ def remove_index(root):
|
|
|
855
861
|
except PermissionError as exc:
|
|
856
862
|
raise RuntimeError("The index is open in another process. Close the external reader and try removing it again.") from exc
|
|
857
863
|
removed.append(safe)
|
|
864
|
+
try:
|
|
865
|
+
os.remove(graph_cache_path(safe))
|
|
866
|
+
except FileNotFoundError:
|
|
867
|
+
pass
|
|
858
868
|
invalidate_cache(root)
|
|
859
869
|
return removed
|
|
860
870
|
|
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.4",
|
|
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:
|
|
@@ -290,18 +292,21 @@ def log_unavailable(tool_name, tool_input, exc):
|
|
|
290
292
|
)
|
|
291
293
|
|
|
292
294
|
|
|
293
|
-
#
|
|
294
|
-
#
|
|
295
|
-
#
|
|
295
|
+
# Search/read verbs count only where they start a command: at the beginning, after `;`, `&&`, `||`, `$(`, a backtick,
|
|
296
|
+
# a loop/if keyword or a shell invoker (`bash -c`, `powershell -Command`, `cmd /c`). After a single `|` they filter the
|
|
297
|
+
# output of the command before them (`python x.py | tail -5`), which reads no source code.
|
|
296
298
|
_SEARCH_VERB_RE = re.compile(
|
|
297
|
-
|
|
298
|
-
# (`powershell -Command`, `bash -c`, `cmd /c`), que é como o verbo real costuma
|
|
299
|
-
# aparecer no meio de um comando em máquina Windows.
|
|
300
|
-
r"(?:^|[|;&]|\$\(|`|-c\s|-Command\s|/c\s|/k\s)\s*(?:sudo\s+|command\s+)?"
|
|
299
|
+
r"(?:^|[;&]|\|\||\$\(|`|-c\s|-Command\s|/c\s|/k\s)\s*[\"']?\s*(?:(?:do|then|else)\s+)?(?:sudo\s+|command\s+)?"
|
|
301
300
|
r"(grep|egrep|fgrep|rg|ripgrep|find|findstr|fd|cat|type|head|tail|less|more|"
|
|
302
301
|
r"ls|dir|awk|sed|ack|ag|select-string|sls|get-content|gc|get-childitem|gci)\b",
|
|
303
302
|
re.IGNORECASE,
|
|
304
303
|
)
|
|
304
|
+
# Commands that read code or pages without a search verb at their start: their pipe filters still search content.
|
|
305
|
+
_READ_PRODUCER_RE = re.compile(
|
|
306
|
+
r"\bgit\s+(grep|ls-files|show|log\s+-p|diff)\b|\b(curl|wget|iwr|invoke-webrequest)\b|\bgh\s+api\b|"
|
|
307
|
+
r"readFileSync|read_text\(|open\(",
|
|
308
|
+
re.IGNORECASE,
|
|
309
|
+
)
|
|
305
310
|
_READ_TOOLS = frozenset({"Read", "NotebookRead"})
|
|
306
311
|
|
|
307
312
|
BREAKER_PATH = os.path.join(os.path.dirname(METRICS_PATH), "router-breaker.json")
|
|
@@ -316,14 +321,17 @@ def _mechanical_decision(tool_name, tool_input):
|
|
|
316
321
|
Medido em 1973 decisões reais: 97,8% eram `allow`, e 1512 delas (77%) caíam nestes
|
|
317
322
|
casos — cada uma custando uma ida ao gateway antes de toda tool call do agente. Os
|
|
318
323
|
dois únicos `Read` redirecionados nesse histórico eram decisões erradas (um deles com
|
|
319
|
-
`offset`/`limit` explícitos, onde só as linhas pedidas servem).
|
|
324
|
+
`offset`/`limit` explícitos, onde só as linhas pedidas servem). Remedido em 2026-10-08
|
|
325
|
+
sobre 2488 Bash que foram ao modelo (mediana 1,5 s, 7,8% redirecionados): ignorar os
|
|
326
|
+
filtros depois de `|` (salvo quando o produtor lê código ou páginas) decide 439 deles
|
|
327
|
+
sem modelo (13,3 min em 58 h) e perde 1 dos 155 redirecionamentos."""
|
|
320
328
|
if tool_name in _READ_TOOLS:
|
|
321
329
|
return "allow", "Reading a specific file is never replaced by semantic search."
|
|
322
330
|
if tool_name == "Bash":
|
|
323
331
|
command = tool_input.get("command")
|
|
324
332
|
if not isinstance(command, str) or not command.strip():
|
|
325
333
|
return "allow", "Bash without a readable command."
|
|
326
|
-
if not _SEARCH_VERB_RE.search(command):
|
|
334
|
+
if not _SEARCH_VERB_RE.search(command) and not _READ_PRODUCER_RE.search(command):
|
|
327
335
|
return "allow", "The command neither searches nor reads source code."
|
|
328
336
|
return None
|
|
329
337
|
|
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 });
|