@allansantos-dev/smart-tool 0.9.37 → 0.9.39
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 +19 -0
- package/README.md +1 -1
- package/client_hooks.py +7 -19
- package/dossier.py +24 -6
- package/freshness.py +7 -7
- package/package.json +1 -1
- package/scout.py +1 -23
- package/version.py +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.39 - beta
|
|
4
|
+
|
|
5
|
+
- Two-letter technical terms (`ui`, `db`, `id`) count again in the shared `focus_terms`, used by `smart_search
|
|
6
|
+
mode=dossier` and `smart_scout`. Two-letter English and Portuguese function words (`is`, `of`, `de`, `em`, ...) are
|
|
7
|
+
dropped like the question words. Tests in `tests/test_dossier.py`.
|
|
8
|
+
- The npm registry and OSV checks of `freshness` no longer raise. `npm_registry_evidence` read a local `version` before
|
|
9
|
+
assigning it (UnboundLocalError on every call) and `npm_advisories` shadowed the `version` module with its parameter;
|
|
10
|
+
both return evidence or `None` as documented. Tests in `tests/test_npm_evidence.py`.
|
|
11
|
+
|
|
12
|
+
## 0.9.38 - beta
|
|
13
|
+
|
|
14
|
+
- One definition of a search term. `smart_search mode=dossier` now takes its terms from `dossier.focus_terms`, the
|
|
15
|
+
function `smart_scout` already used for its windows (`scout.focus_terms` is the same function): words of 3+ characters,
|
|
16
|
+
question words dropped, camelCase split. A note is no longer ranked by "why" or "the", an identifier like
|
|
17
|
+
`SessionStart` finds the note that writes "session start", and a query made only of question words is refused.
|
|
18
|
+
Words of 2 characters no longer count in dossier queries. Ranking cases in `tests/test_dossier.py`.
|
|
19
|
+
- One copy of the hook-entry removal in `client_hooks.py`: the session-start audit entry and the redirect entries share
|
|
20
|
+
`_ours` and `_without_ours`, which take the predicate that says which hooks are ours.
|
|
21
|
+
|
|
3
22
|
## 0.9.37 - beta
|
|
4
23
|
|
|
5
24
|
- The site no longer sends a human reader to the repository. The "Release notes" links (hero and footer) go to a
|
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ registered as the `playwright` server where this machine lets a browser be drive
|
|
|
16
16
|
An optional hook routes the agent's native Grep/Glob/Read/Bash/WebSearch/WebFetch calls to these tools when they
|
|
17
17
|
are cheaper, or just tells the agent what Smart Tool would do.
|
|
18
18
|
|
|
19
|
-
**Status:** 0.9.
|
|
19
|
+
**Status:** 0.9.39 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
|
|
20
20
|
|
|
21
21
|
## How it works
|
|
22
22
|
|
package/client_hooks.py
CHANGED
|
@@ -58,18 +58,18 @@ def _entries(client):
|
|
|
58
58
|
return entries
|
|
59
59
|
|
|
60
60
|
|
|
61
|
-
def _ours(group):
|
|
62
|
-
return isinstance(group, dict) and isinstance(group.get("hooks"), list) and any(map(
|
|
61
|
+
def _ours(group, is_ours=_our_hook):
|
|
62
|
+
return isinstance(group, dict) and isinstance(group.get("hooks"), list) and any(map(is_ours, group["hooks"]))
|
|
63
63
|
|
|
64
64
|
|
|
65
|
-
def _without_ours(groups):
|
|
65
|
+
def _without_ours(groups, is_ours=_our_hook):
|
|
66
66
|
"""Drops only our hook entries; a group shared with other hooks keeps them and disappears only when emptied."""
|
|
67
67
|
kept = []
|
|
68
68
|
for group in groups:
|
|
69
|
-
if not _ours(group):
|
|
69
|
+
if not _ours(group, is_ours):
|
|
70
70
|
kept.append(group)
|
|
71
71
|
continue
|
|
72
|
-
others = [hook for hook in group["hooks"] if not
|
|
72
|
+
others = [hook for hook in group["hooks"] if not is_ours(hook)]
|
|
73
73
|
if others:
|
|
74
74
|
kept.append({**group, "hooks": others})
|
|
75
75
|
return kept
|
|
@@ -276,10 +276,6 @@ def _audit_hook(hook):
|
|
|
276
276
|
return isinstance(hook, dict) and AUDIT_MARKER in str(hook.get("command", ""))
|
|
277
277
|
|
|
278
278
|
|
|
279
|
-
def _audit_ours(group):
|
|
280
|
-
return isinstance(group, dict) and isinstance(group.get("hooks"), list) and any(map(_audit_hook, group["hooks"]))
|
|
281
|
-
|
|
282
|
-
|
|
283
279
|
def _audit_entry():
|
|
284
280
|
return {"matcher": AUDIT_MATCHER,
|
|
285
281
|
"hooks": [{"type": "command", "command": f"{_shell_safe(sys.executable)} {_shell_safe(AUDIT_SCRIPT)}",
|
|
@@ -299,15 +295,7 @@ def _audit_plan():
|
|
|
299
295
|
groups = data["hooks"].setdefault("SessionStart", [])
|
|
300
296
|
if not isinstance(groups, list):
|
|
301
297
|
raise ValueError(f"{path}: hooks.SessionStart is not a list; nothing was changed.")
|
|
302
|
-
|
|
303
|
-
for group in groups:
|
|
304
|
-
if not _audit_ours(group):
|
|
305
|
-
kept.append(group)
|
|
306
|
-
continue
|
|
307
|
-
others = [hook for hook in group["hooks"] if not _audit_hook(hook)]
|
|
308
|
-
if others:
|
|
309
|
-
kept.append({**group, "hooks": others})
|
|
310
|
-
groups[:] = kept
|
|
298
|
+
groups[:] = _without_ours(groups, _audit_hook)
|
|
311
299
|
return path, data, groups
|
|
312
300
|
|
|
313
301
|
|
|
@@ -321,7 +309,7 @@ def audit_state():
|
|
|
321
309
|
except FileNotFoundError:
|
|
322
310
|
return "missing"
|
|
323
311
|
groups = ((data.get("hooks") or {}).get("SessionStart") or []) if isinstance(data, dict) else []
|
|
324
|
-
found = [group for group in groups if
|
|
312
|
+
found = [group for group in groups if _ours(group, _audit_hook)] if isinstance(groups, list) else []
|
|
325
313
|
if not found:
|
|
326
314
|
return "missing"
|
|
327
315
|
return "current" if _audit_entry() in found else "outdated"
|
package/dossier.py
CHANGED
|
@@ -16,13 +16,31 @@ TITLE_CHARS = 80
|
|
|
16
16
|
MAX_FILE_BYTES = 512 * 1024
|
|
17
17
|
BACKLOG_FILES = ("docs/BACKLOG.md", "BACKLOG.md")
|
|
18
18
|
INSTRUCTION_FILES = (("CLAUDE.md", "claude_md"), (".claude/CLAUDE.md", "claude_md"), ("AGENTS.md", "agents_md"))
|
|
19
|
-
|
|
19
|
+
_WORD = re.compile(r"[\w]{2,}", re.UNICODE)
|
|
20
|
+
_STOPWORDS = {
|
|
21
|
+
"the", "and", "for", "how", "what", "where", "which", "does", "with", "from", "that", "this", "are", "when", "who",
|
|
22
|
+
"why", "into", "used", "use", "uses", "its", "has", "have", "code", "project", "file", "files",
|
|
23
|
+
"como", "onde", "qual", "quais", "que", "quem", "por", "para", "com", "uma", "dos", "das", "nos", "nas", "pelo",
|
|
24
|
+
"pela", "ele", "ela", "isso", "esse", "essa", "este", "esta", "fica", "ficam", "usa", "usado", "usada", "sobre",
|
|
25
|
+
"entre", "depois", "antes", "quando", "cada", "seu", "sua", "seus", "suas", "não", "sem", "mais", "código",
|
|
26
|
+
"projeto", "arquivo", "arquivos", "decide", "funciona",
|
|
27
|
+
"is", "in", "to", "of", "on", "at", "by", "an", "as", "it", "or", "be", "do", "if", "so", "we", "no", "up", "me", "my",
|
|
28
|
+
"he", "us", "am", "de", "da", "em", "na", "os", "um", "se", "ao", "ou", "eu", "te", "é", "já", "só",
|
|
29
|
+
}
|
|
20
30
|
_UNIT_START = re.compile(r"#{1,6}\s|[-*]\s")
|
|
21
31
|
|
|
22
32
|
|
|
23
|
-
def
|
|
24
|
-
"""
|
|
25
|
-
|
|
33
|
+
def focus_terms(*texts):
|
|
34
|
+
"""Casefolded content words of the texts (2+ characters, common question and function words dropped), camelCase
|
|
35
|
+
split, in order: what a window is scored by."""
|
|
36
|
+
found = []
|
|
37
|
+
for text in texts:
|
|
38
|
+
spaced = re.sub(r"([a-z])([A-Z])", r"\1 \2", text or "")
|
|
39
|
+
for word in _WORD.findall(spaced):
|
|
40
|
+
folded = word.casefold()
|
|
41
|
+
if folded not in _STOPWORDS and folded not in found:
|
|
42
|
+
found.append(folded)
|
|
43
|
+
return found[:24]
|
|
26
44
|
|
|
27
45
|
|
|
28
46
|
def _sources(root):
|
|
@@ -78,7 +96,7 @@ def query(root, text, timeout_ms=DEFAULT_TIMEOUT_MS, max_hits=DEFAULT_MAX_HITS):
|
|
|
78
96
|
was found until then."""
|
|
79
97
|
started = time.monotonic()
|
|
80
98
|
deadline = started + timeout_ms / 1000
|
|
81
|
-
terms =
|
|
99
|
+
terms = focus_terms(text)
|
|
82
100
|
reasons, scanned, unreadable, scored = [], 0, [], []
|
|
83
101
|
for rel, kind in _sources(root):
|
|
84
102
|
if time.monotonic() > deadline:
|
|
@@ -116,7 +134,7 @@ def answer(arguments):
|
|
|
116
134
|
raise ValueError("smart_search mode=dossier requires 'project_root' pointing to an existing folder")
|
|
117
135
|
text = next((arguments[key] for key in ("query_identifiers", "query_comments", "doc_query")
|
|
118
136
|
if isinstance(arguments.get(key), str) and arguments[key].strip()), "")
|
|
119
|
-
if not
|
|
137
|
+
if not focus_terms(text):
|
|
120
138
|
raise ValueError("smart_search mode=dossier requires 'query_identifiers' (or 'doc_query') with at least one word.")
|
|
121
139
|
timeout_ms = _bounded(arguments, "timeout_ms", DEFAULT_TIMEOUT_MS, 1, MAX_TIMEOUT_MS)
|
|
122
140
|
max_hits = _bounded(arguments, "max_hits", DEFAULT_MAX_HITS, 1, MAX_HITS)
|
package/freshness.py
CHANGED
|
@@ -62,23 +62,23 @@ def npm_registry_evidence(package):
|
|
|
62
62
|
try:
|
|
63
63
|
with urllib.request.urlopen(request, timeout=7) as response:
|
|
64
64
|
data = json.load(response)
|
|
65
|
-
|
|
66
|
-
if not isinstance(
|
|
65
|
+
latest = data.get("version") if isinstance(data, dict) else None
|
|
66
|
+
if not isinstance(latest, str) or not latest or len(latest) > 80:
|
|
67
67
|
return None
|
|
68
68
|
return {"title": f"npm registry: {package}", "url": url,
|
|
69
|
-
"snippet": f"Latest version published on the npm registry: {
|
|
70
|
-
"tier": "npm_registry", "npm_package": package, "npm_version":
|
|
69
|
+
"snippet": f"Latest version published on the npm registry: {latest}",
|
|
70
|
+
"tier": "npm_registry", "npm_package": package, "npm_version": latest}
|
|
71
71
|
except Exception:
|
|
72
72
|
return None
|
|
73
73
|
|
|
74
74
|
|
|
75
|
-
def npm_advisories(package,
|
|
75
|
+
def npm_advisories(package, package_version):
|
|
76
76
|
"""IDs conhecidos pela OSV para a versão; None significa checagem indisponível."""
|
|
77
|
-
if (not package or not
|
|
77
|
+
if (not package or not package_version
|
|
78
78
|
or not re.fullmatch(r"(?:@[a-z0-9._-]+/)?[a-z0-9._-]+", package)):
|
|
79
79
|
return None
|
|
80
80
|
body = json.dumps({"package": {"name": package, "ecosystem": "npm"},
|
|
81
|
-
"version":
|
|
81
|
+
"version": package_version}).encode("utf-8")
|
|
82
82
|
request = urllib.request.Request(
|
|
83
83
|
"https://api.osv.dev/v1/query", data=body, method="POST",
|
|
84
84
|
headers={"Content-Type": "application/json", "Accept": "application/json",
|
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.39",
|
|
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/scout.py
CHANGED
|
@@ -12,11 +12,11 @@ Market pattern copied (docs/research/2026-10-24-scout-modelo-leve-no-smart-tool.
|
|
|
12
12
|
excerpts, never an agent prompt, so no agent context is reopened per call.
|
|
13
13
|
"""
|
|
14
14
|
import os
|
|
15
|
-
import re
|
|
16
15
|
|
|
17
16
|
import document_text
|
|
18
17
|
import index_scope
|
|
19
18
|
import indexer
|
|
19
|
+
from dossier import focus_terms # noqa: F401 (one definition of a search term, shared with smart_search mode=dossier)
|
|
20
20
|
|
|
21
21
|
MAX_QUESTION_CHARS = 2000
|
|
22
22
|
MAX_TERMS_CHARS = 400
|
|
@@ -35,15 +35,6 @@ PLAN_MAX_FOLDERS = 6
|
|
|
35
35
|
OUTLINE_MAX_DIRS = 300
|
|
36
36
|
OUTLINE_MAX_ROOT_FILES = 200
|
|
37
37
|
_FIELDS = {"project_root", "question", "terms", "max_files"}
|
|
38
|
-
_WORD = re.compile(r"[\w]{3,}", re.UNICODE)
|
|
39
|
-
_STOPWORDS = {
|
|
40
|
-
"the", "and", "for", "how", "what", "where", "which", "does", "with", "from", "that", "this", "are", "when", "who",
|
|
41
|
-
"why", "into", "used", "use", "uses", "its", "has", "have", "code", "project", "file", "files",
|
|
42
|
-
"como", "onde", "qual", "quais", "que", "quem", "por", "para", "com", "uma", "dos", "das", "nos", "nas", "pelo",
|
|
43
|
-
"pela", "ele", "ela", "isso", "esse", "essa", "este", "esta", "fica", "ficam", "usa", "usado", "usada", "sobre",
|
|
44
|
-
"entre", "depois", "antes", "quando", "cada", "seu", "sua", "seus", "suas", "não", "sem", "mais", "código",
|
|
45
|
-
"projeto", "arquivo", "arquivos", "decide", "funciona",
|
|
46
|
-
}
|
|
47
38
|
|
|
48
39
|
PLAN_SYSTEM = (
|
|
49
40
|
"You plan a keyword scan of a code project that has no search index yet. Given a question and the project's "
|
|
@@ -100,19 +91,6 @@ def arguments(raw):
|
|
|
100
91
|
return {"project_root": root, "question": question.strip(), "terms": (terms or "").strip(), "max_files": max_files}
|
|
101
92
|
|
|
102
93
|
|
|
103
|
-
def focus_terms(*texts):
|
|
104
|
-
"""Casefolded content words of the texts (3+ characters, common question words dropped), camelCase split, in
|
|
105
|
-
order: what a window is scored by."""
|
|
106
|
-
found = []
|
|
107
|
-
for text in texts:
|
|
108
|
-
spaced = re.sub(r"([a-z])([A-Z])", r"\1 \2", text or "")
|
|
109
|
-
for word in _WORD.findall(spaced):
|
|
110
|
-
folded = word.casefold()
|
|
111
|
-
if folded not in _STOPWORDS and folded not in found:
|
|
112
|
-
found.append(folded)
|
|
113
|
-
return found[:24]
|
|
114
|
-
|
|
115
|
-
|
|
116
94
|
def disk_lines(root, path, start, end):
|
|
117
95
|
"""`[(line_number, text)]` of lines `start..end` of the file as it is on disk now, or None when the file cannot be
|
|
118
96
|
read by the index rules (secret, dotfile, too large, binary, gone) or holds no line text (docx)."""
|
package/version.py
CHANGED