@allansantos-dev/smart-tool 0.9.37 → 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 CHANGED
@@ -1,5 +1,15 @@
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
+
3
13
  ## 0.9.37 - beta
4
14
 
5
15
  - 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.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(_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,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
- _TERM = re.compile(r"[\w]{2,}", re.UNICODE)
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 _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]
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 = _terms(text)
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 _terms(text):
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.37",
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
@@ -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.38"
5
5
  _CONTACT_RE = re.compile(r"^(?:[^\s@()<>;]+@[^\s@()<>;]+\.[^\s@()<>;]+|https?://[^\s()<>;]+)$")
6
6
 
7
7