@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 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.37 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
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(_our_hook, group["hooks"]))
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 _our_hook(hook)]
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
- kept = []
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 _audit_ours(group)] if isinstance(groups, list) else []
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
- _TERM = re.compile(r"[\w]{2,}", re.UNICODE)
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 _terms(query):
24
- """Up to 24 distinct casefolded word terms of the query."""
25
- return list(dict.fromkeys(term.casefold() for term in _TERM.findall(query or "")))[:24]
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 = _terms(text)
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 _terms(text):
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
- version = data.get("version") if isinstance(data, dict) else None
66
- if not isinstance(version, str) or not version or len(version) > 80:
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: {version}",
70
- "tier": "npm_registry", "npm_package": package, "npm_version": 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, version):
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 version
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": version}).encode("utf-8")
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.37",
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
@@ -1,7 +1,7 @@
1
1
  """Single source of the Smart Tool version and of the User-Agent sent to public APIs."""
2
2
  import re
3
3
 
4
- VERSION = "0.9.37"
4
+ VERSION = "0.9.39"
5
5
  _CONTACT_RE = re.compile(r"^(?:[^\s@()<>;]+@[^\s@()<>;]+\.[^\s@()<>;]+|https?://[^\s()<>;]+)$")
6
6
 
7
7