@allansantos-dev/smart-tool 0.9.36 → 0.9.38
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 +17 -0
- package/README.md +1 -1
- package/client_hooks.py +7 -19
- package/dossier.py +22 -6
- package/package.json +1 -1
- package/scout.py +1 -23
- package/version.py +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.38 - beta
|
|
4
|
+
|
|
5
|
+
- One definition of a search term. `smart_search mode=dossier` now takes its terms from `dossier.focus_terms`, the
|
|
6
|
+
function `smart_scout` already used for its windows (`scout.focus_terms` is the same function): words of 3+ characters,
|
|
7
|
+
question words dropped, camelCase split. A note is no longer ranked by "why" or "the", an identifier like
|
|
8
|
+
`SessionStart` finds the note that writes "session start", and a query made only of question words is refused.
|
|
9
|
+
Words of 2 characters no longer count in dossier queries. Ranking cases in `tests/test_dossier.py`.
|
|
10
|
+
- One copy of the hook-entry removal in `client_hooks.py`: the session-start audit entry and the redirect entries share
|
|
11
|
+
`_ours` and `_without_ours`, which take the predicate that says which hooks are ours.
|
|
12
|
+
|
|
13
|
+
## 0.9.37 - beta
|
|
14
|
+
|
|
15
|
+
- The site no longer sends a human reader to the repository. The "Release notes" links (hero and footer) go to a
|
|
16
|
+
new release-notes section on the page itself, and "How it works, step by step" jumps to the tools section instead
|
|
17
|
+
of the README on GitHub. Guard tests in `tests/test_pages.py`: no `href`/`src` of the site may contain `/blob/`,
|
|
18
|
+
`README` or the owner's repositories, and the release-notes section must lead with the current version.
|
|
19
|
+
|
|
3
20
|
## 0.9.36 - beta
|
|
4
21
|
|
|
5
22
|
- The "Smart Tool was updated" notice links to the public site (`update_check.SITE_URL`, the GitHub Pages page)
|
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.38 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,29 @@ 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]{3,}", 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
|
+
}
|
|
20
28
|
_UNIT_START = re.compile(r"#{1,6}\s|[-*]\s")
|
|
21
29
|
|
|
22
30
|
|
|
23
|
-
def
|
|
24
|
-
"""
|
|
25
|
-
|
|
31
|
+
def focus_terms(*texts):
|
|
32
|
+
"""Casefolded content words of the texts (3+ characters, common question words dropped), camelCase split, in
|
|
33
|
+
order: what a window is scored by."""
|
|
34
|
+
found = []
|
|
35
|
+
for text in texts:
|
|
36
|
+
spaced = re.sub(r"([a-z])([A-Z])", r"\1 \2", text or "")
|
|
37
|
+
for word in _WORD.findall(spaced):
|
|
38
|
+
folded = word.casefold()
|
|
39
|
+
if folded not in _STOPWORDS and folded not in found:
|
|
40
|
+
found.append(folded)
|
|
41
|
+
return found[:24]
|
|
26
42
|
|
|
27
43
|
|
|
28
44
|
def _sources(root):
|
|
@@ -78,7 +94,7 @@ def query(root, text, timeout_ms=DEFAULT_TIMEOUT_MS, max_hits=DEFAULT_MAX_HITS):
|
|
|
78
94
|
was found until then."""
|
|
79
95
|
started = time.monotonic()
|
|
80
96
|
deadline = started + timeout_ms / 1000
|
|
81
|
-
terms =
|
|
97
|
+
terms = focus_terms(text)
|
|
82
98
|
reasons, scanned, unreadable, scored = [], 0, [], []
|
|
83
99
|
for rel, kind in _sources(root):
|
|
84
100
|
if time.monotonic() > deadline:
|
|
@@ -116,7 +132,7 @@ def answer(arguments):
|
|
|
116
132
|
raise ValueError("smart_search mode=dossier requires 'project_root' pointing to an existing folder")
|
|
117
133
|
text = next((arguments[key] for key in ("query_identifiers", "query_comments", "doc_query")
|
|
118
134
|
if isinstance(arguments.get(key), str) and arguments[key].strip()), "")
|
|
119
|
-
if not
|
|
135
|
+
if not focus_terms(text):
|
|
120
136
|
raise ValueError("smart_search mode=dossier requires 'query_identifiers' (or 'doc_query') with at least one word.")
|
|
121
137
|
timeout_ms = _bounded(arguments, "timeout_ms", DEFAULT_TIMEOUT_MS, 1, MAX_TIMEOUT_MS)
|
|
122
138
|
max_hits = _bounded(arguments, "max_hits", DEFAULT_MAX_HITS, 1, MAX_HITS)
|
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.38",
|
|
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