@allansantos-dev/smart-tool 0.9.26 → 0.9.27

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.27 - beta
4
+
5
+ - No folder is read before it is classified, and a tool's hidden folder (`.crowd`, `.tokenguard`, `.vscode`, any name
6
+ starting with a dot) is never indexed, whatever the scope says:
7
+ - An empty or missing `include` used to mean "the whole project" (the saved scopes of `sistema` and a Claude memory
8
+ folder had `include: []`), and a search of a folder with no index and no scope read every file of it from disk (one
9
+ registered folder had 715 427). An empty `include` now indexes only the loose files at the root, and that first
10
+ keyword search reads only those.
11
+ - The `scope` action without a saved scope defaulted to `include: ["."]`; it now requires `include`, and refuses an
12
+ include inside a hidden folder.
13
+ - A model that answered with `docs/**`, `*` or `**/*` (the cause of the `error` status of two registered folders)
14
+ now has the entry reduced to the folder it names; an entry that is absolute, climbs out with `..`, sits in a hidden
15
+ folder or keeps a wildcard in the middle is dropped and kept in `dropped_include` of the saved scope.
16
+ - Hidden files at the root and inside any included folder are no longer listed (they were listed and then skipped
17
+ when read).
18
+ - The mechanical exclusion grows: `site-packages`, `bower_components`, `jspm_packages` everywhere, and `target`
19
+ (Cargo.toml or pom.xml at the root) and `Pods` (Podfile). These are never offered to the scope model.
20
+ - Tests for each rule: `tests/test_scope_rules.py`.
21
+
3
22
  ## 0.9.26 - beta
4
23
 
5
24
  - The daemon no longer re-reads and re-hashes every watched folder in a loop. Measured on the owner's machine: one
package/README.md CHANGED
@@ -15,7 +15,7 @@ registered as the `playwright` server where this machine lets a browser be drive
15
15
  An optional hook routes the agent's native Grep/Glob/Read/Bash/WebSearch/WebFetch calls to these tools when they
16
16
  are cheaper, or just tells the agent what Smart Tool would do.
17
17
 
18
- **Status:** 0.9.26 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
18
+ **Status:** 0.9.27 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
19
19
 
20
20
  ## How it works
21
21
 
package/index_scope.py CHANGED
@@ -28,8 +28,18 @@ SCOPE_DIR = paths.DATA_DIR
28
28
  _ALWAYS_EXCLUDE = {
29
29
  ".git", "node_modules", "__pycache__", ".venv", "venv", "env",
30
30
  ".tox", ".mypy_cache", ".pytest_cache", "*.egg-info",
31
+ "site-packages", "bower_components", "jspm_packages",
31
32
  }
32
33
 
34
+ # Folders that are generated output only when the root proves the project type: a root marker file maps to the names
35
+ # it makes unambiguous (Cargo's and Maven's `target`, CocoaPods' `Pods`). Excluded mechanically, before any model sees
36
+ # the folder list.
37
+ _PROJECT_TYPE_EXCLUDES = (
38
+ (("Cargo.toml",), ("target",)),
39
+ (("pom.xml",), ("target",)),
40
+ (("Podfile",), ("Pods",)),
41
+ )
42
+
33
43
  # Mesmo critério do `_ALWAYS_EXCLUDE`, para arquivo: o significado não varia por
34
44
  # projeto. Lockfile é grafo de dependência resolvido por ferramenta — nunca é fonte de
35
45
  # resposta numa busca semântica, e é grande o bastante pra dominar o índice.
@@ -55,6 +65,51 @@ def tool_state_excludes(root):
55
65
  return _TOOL_STATE_DIRS.get(os.path.basename(os.path.normpath(root)).casefold(), ())
56
66
 
57
67
 
68
+ def project_type_excludes(root):
69
+ """Names of the generated-output folders `root`'s marker files make unambiguous (empty for an unknown type)."""
70
+ found = []
71
+ for markers, names in _PROJECT_TYPE_EXCLUDES:
72
+ if any(os.path.isfile(os.path.join(root, marker)) for marker in markers):
73
+ found.extend(names)
74
+ return tuple(found)
75
+
76
+
77
+ def mechanical_excludes(root):
78
+ """Everything excluded without asking a model: the tool-runtime folders of a config home and the generated output
79
+ of the project type. The names of `_ALWAYS_EXCLUDE` and every hidden folder are excluded on top of these."""
80
+ return (*tool_state_excludes(root), *project_type_excludes(root))
81
+
82
+
83
+ def is_hidden(rel_path):
84
+ """Whether any part of the relative path starts with a dot (a tool's folder: .git, .vscode, .crowd, .tokenguard)."""
85
+ return any(part.startswith(".") for part in str(rel_path).replace("\\", "/").split("/") if part not in ("", ".", ".."))
86
+
87
+
88
+ def normalize_include(include):
89
+ """`(bases, dropped)` from the include entries of a scope. A base is a folder or file path relative to the root, with
90
+ the shapes a model emits for "everything under it" (`docs/**`, `docs/*`, `docs/`) reduced to the folder; `*`, `**`
91
+ and `**/*` mean the whole project and become ``. An entry that is absolute, climbs out with `..`, sits inside a hidden
92
+ folder or keeps a wildcard in the middle is dropped: it can never name something indexable, and it is returned so the
93
+ caller can report it. No entries means no folder: only the loose files at the root are indexed."""
94
+ bases, dropped = [], []
95
+ for raw in include or []:
96
+ base = str(raw).replace("\\", "/").strip()
97
+ while base.startswith("./"):
98
+ base = base[2:]
99
+ for tail in ("/**/*", "/**", "/*"):
100
+ if base.endswith(tail):
101
+ base = base[:-len(tail)]
102
+ base = base.strip("/")
103
+ if base in ("*", "**", "**/*", "."):
104
+ base = ""
105
+ if (os.path.isabs(str(raw)) or str(raw).strip().startswith(("/", "\\")) or ".." in base.split("/") or is_hidden(base)
106
+ or any(ch in base for ch in "*?[")):
107
+ dropped.append(raw)
108
+ elif base not in bases:
109
+ bases.append(base)
110
+ return bases, dropped
111
+
112
+
58
113
  def always_excluded(name):
59
114
  """Directory name in _ALWAYS_EXCLUDE, entries may be globs (e.g. *.egg-info)."""
60
115
  folded = name.casefold()
@@ -122,10 +177,12 @@ def _top_level_dirs(root):
122
177
  names = os.listdir(root)
123
178
  except OSError:
124
179
  return []
180
+ skipped = mechanical_excludes(root)
125
181
  return sorted(
126
182
  name for name in names
127
183
  if not name.startswith(".")
128
184
  and not always_excluded(name)
185
+ and name not in skipped
129
186
  and os.path.isdir(os.path.join(root, name))
130
187
  and _within_root(root, os.path.join(root, name))
131
188
  )
@@ -293,7 +350,8 @@ def _build_scope_prompt(root, ambiguous_dirs, root_files, doc_context):
293
350
  "You are deciding what to include in a semantic code-search index for the "
294
351
  f"repository at {root}.\n\n"
295
352
  "These directories are already excluded automatically (dependency/tooling caches "
296
- f"whose meaning does not vary by project): {', '.join(sorted(_ALWAYS_EXCLUDE))}. "
353
+ f"whose meaning does not vary by project): {', '.join(sorted({*_ALWAYS_EXCLUDE, *mechanical_excludes(root)}))}. "
354
+ "Every hidden folder (name starting with a dot: .git, .vscode, .crowd, .tokenguard...) is also never indexed. "
297
355
  "Do not decide on them.\n\n"
298
356
  f"Directories that still need a decision: {', '.join(ambiguous_dirs) or '(none)'}\n\n"
299
357
  "Loose files at the repository ROOT are indexed automatically, because in a flat "
@@ -317,6 +375,8 @@ def _build_scope_prompt(root, ambiguous_dirs, root_files, doc_context):
317
375
  "source, even if it also contains a few generated/log files. Only call submit_scope "
318
376
  "once every non-obvious directory is grounded in inspection data or in the "
319
377
  "documentation.\n\n"
378
+ "`include` lists folder paths relative to the root, without wildcards; a folder you leave out of `include` is "
379
+ "not indexed, and an empty `include` indexes only the loose root files. "
320
380
  "Include sources useful for search even if they are gitignored (e.g. docs/, README, "
321
381
  "ADRs). Java, Angular, HTML/CSS, plain text and Markdown are supported sources. "
322
382
  "DOCX documents are supported through bounded local text extraction; do not exclude "
@@ -333,7 +393,7 @@ def decide_scope(root, router_model, force=False, deadline=None, cancel_check=No
333
393
  return cached
334
394
 
335
395
  top_level_dirs = _top_level_dirs(root)
336
- ambiguous_dirs = [d for d in top_level_dirs if not always_excluded(d) and d not in tool_state_excludes(root)]
396
+ ambiguous_dirs = list(top_level_dirs)
337
397
  root_files = _root_level_files(root, _exclude_matcher(_ALWAYS_EXCLUDE_FILES))
338
398
  doc_context = _read_root_docs(root)
339
399
 
@@ -392,11 +452,13 @@ def decide_scope(root, router_model, force=False, deadline=None, cancel_check=No
392
452
 
393
453
  if decision is None:
394
454
  raise RuntimeError("The model did not define a valid scope. Check the model or set the scope manually.")
395
- final = {"include": (decision or {}).get("include") or [],
396
- "exclude": [*((decision or {}).get("exclude") or []), *tool_state_excludes(root)],
455
+ include, dropped = normalize_include(decision.get("include"))
456
+ final = {"include": include,
457
+ "exclude": [*(decision.get("exclude") or []), *mechanical_excludes(root)],
397
458
  "user_exclude": (cached or {}).get("user_exclude", [])}
398
459
  scope = {
399
460
  "include": final["include"],
461
+ "dropped_include": [str(item) for item in dropped],
400
462
  "exclude": final["exclude"],
401
463
  "top_level_dirs": top_level_dirs,
402
464
  "scope_version": SCOPE_VERSION,
@@ -422,7 +484,7 @@ def _root_level_files(root, matcher):
422
484
  raise OSError("Could not list the root; the existing index was kept.") from exc
423
485
  out = []
424
486
  for name in names:
425
- if matcher(name):
487
+ if matcher(name) or name.startswith("."):
426
488
  continue
427
489
  full = os.path.join(root, name)
428
490
  # `isfile` segue link: sem a checagem de contenção, um link na raiz apontando
@@ -466,16 +528,15 @@ def scope_filter(scope, root=None):
466
528
  """Predicate `keep(rel_path)`: whether resolve_included_files would list rel_path (a file) or walk it (a folder) —
467
529
  under an include base, outside the exclusions, outside hidden folders. Built once so a burst of file events does not
468
530
  rebuild the matcher per path; `root` adds the tool-runtime folders of a known config home."""
469
- include = scope.get("include") or [""]
531
+ bases, _dropped = normalize_include(scope.get("include"))
470
532
  matcher = _exclude_matcher(set(scope.get("exclude") or []) | set(scope.get("user_exclude") or []) |
471
- set(tool_state_excludes(root) if root else ()) | _ALWAYS_EXCLUDE | _ALWAYS_EXCLUDE_FILES)
472
- bases = [base.strip("/") for base in include]
473
- whole = any(base in ("", ".") for base in include)
533
+ set(mechanical_excludes(root) if root else ()) | _ALWAYS_EXCLUDE | _ALWAYS_EXCLUDE_FILES)
534
+ whole = "" in bases
474
535
 
475
536
  def keep(rel_path):
476
537
  rel_path = rel_path.replace("\\", "/")
477
538
  parts = rel_path.split("/")
478
- if matcher(rel_path) or any(part.startswith(".") for part in parts[:-1]):
539
+ if matcher(rel_path) or is_hidden(rel_path):
479
540
  return False
480
541
  if whole or len(parts) == 1:
481
542
  return True
@@ -493,16 +554,16 @@ def in_scope(scope, rel_path, root=None):
493
554
  def resolve_included_files(root, scope, cancel_check=None):
494
555
  if not valid_scope(scope):
495
556
  raise ValueError("Invalid scope; request a new project analysis.")
496
- include = scope.get("include") or [""]
557
+ include, _dropped = normalize_include(scope.get("include"))
497
558
  matcher = _exclude_matcher(set(scope.get("exclude") or []) | set(scope.get("user_exclude") or []) |
498
- set(tool_state_excludes(root)) | _ALWAYS_EXCLUDE | _ALWAYS_EXCLUDE_FILES)
559
+ set(mechanical_excludes(root)) | _ALWAYS_EXCLUDE | _ALWAYS_EXCLUDE_FILES)
499
560
  files = []
500
561
  def walk_error(error):
501
562
  raise OSError("Could not list a folder in the scope; try again when it is accessible.") from error
502
- if not any(base in ("", ".") for base in include):
563
+ if "" not in include:
503
564
  files.extend(_root_level_files(root, matcher))
504
565
  for base in include:
505
- base_full = os.path.join(root, base) if base not in ("", ".") else root
566
+ base_full = os.path.join(root, base) if base else root
506
567
  # `include` vem de tool call de um LLM cujo prompt embute documentação do
507
568
  # repositório analisado: um valor absoluto ou com `..` faria a varredura sair do
508
569
  # projeto e mandar conteúdo de fora pro endpoint de embeddings.
@@ -510,7 +571,7 @@ def resolve_included_files(root, scope, cancel_check=None):
510
571
  continue
511
572
  if os.path.isfile(base_full):
512
573
  rel = os.path.relpath(base_full, root).replace(os.sep, "/")
513
- if not matcher(rel):
574
+ if not matcher(rel) and not is_hidden(rel):
514
575
  files.append(rel)
515
576
  continue
516
577
  for dirpath, dirnames, filenames in os.walk(base_full, onerror=walk_error):
@@ -528,6 +589,6 @@ def resolve_included_files(root, scope, cancel_check=None):
528
589
  ]
529
590
  for name in filenames:
530
591
  rel_path = f"{rel_dir}/{name}" if rel_dir else name
531
- if not matcher(rel_path) and _within_root(root, os.path.join(root, rel_path)):
592
+ if not name.startswith(".") and not matcher(rel_path) and _within_root(root, os.path.join(root, rel_path)):
532
593
  files.append(rel_path)
533
594
  return sorted(set(files))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allansantos-dev/smart-tool",
3
- "version": "0.9.26",
3
+ "version": "0.9.27",
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",
@@ -892,11 +892,11 @@ def _cold_index(root, cfg, problem):
892
892
 
893
893
 
894
894
  def _cold_overlay(root, queries):
895
- """`(overlay, warning)` for a project with no index: the in-scope files (the saved scope, else the whole folder minus
896
- the always-excluded names) holding a query term, read from disk and ranked by keyword, plus the notice that the
897
- index is being built. The scan stops after `COLD_SCAN_BUDGET_S`."""
895
+ """`(overlay, warning)` for a project with no index: the in-scope files (the saved scope, else only the loose files
896
+ at the root: no folder is read before it is classified) holding a query term, read from disk and ranked by
897
+ keyword, plus the notice that the index is being built. The scan stops after `COLD_SCAN_BUDGET_S`."""
898
898
  scope = index_scope.load_scope(root)
899
- files = index_scope.resolve_included_files(root, scope if index_scope.valid_scope(scope) else {"include": [""]})
899
+ files = index_scope.resolve_included_files(root, scope if index_scope.valid_scope(scope) else {"include": []})
900
900
  chunks, scanned = indexer.keyword_chunks(root, files, queries, COLD_SCAN_BUDGET_S)
901
901
  reached = f"{scanned} of {len(files)} files" if scanned < len(files) else f"all {len(files)} files"
902
902
  warning = ("Indexing in progress: this folder has no index yet, so the results are keyword matches read straight "
@@ -1958,7 +1958,10 @@ def _project_action_locked(arguments):
1958
1958
  manual = arguments.get("manual", True)
1959
1959
  if type(manual) is not bool:
1960
1960
  raise ValueError("manual must be true or false.")
1961
- scope = dict(index_scope.load_scope(project["root"]) or {"include": ["."], "exclude": []})
1961
+ scope = dict(index_scope.load_scope(project["root"]) or {"include": [], "exclude": []})
1962
+ if "include" not in arguments and not scope.get("include"):
1963
+ raise ValueError("include is required: list the folders with the project's code, tests and docs. "
1964
+ "Nothing is indexed from a folder that is not listed.")
1962
1965
  for field in ("include", "exclude", "user_exclude"):
1963
1966
  values = arguments.get(field, scope.get(field, []))
1964
1967
  if not isinstance(values, list) or len(values) > 200 or any(
@@ -1969,6 +1972,10 @@ def _project_action_locked(arguments):
1969
1972
  raise ValueError("The scope must stay inside the project folder.")
1970
1973
  if field == "include" and not project_identity.within_root(project["root"], os.path.join(project["root"], value)):
1971
1974
  raise ValueError("Include points outside the folder.")
1975
+ if field == "include" and index_scope.is_hidden(value):
1976
+ raise ValueError(f"Include '{value}' is inside a hidden folder: hidden folders are never indexed.")
1977
+ if field == "include":
1978
+ values = index_scope.normalize_include(values)[0]
1972
1979
  scope[field] = values
1973
1980
  scope.update(manual=manual, scope_version=index_scope.SCOPE_VERSION if manual else 0)
1974
1981
  scope.pop("profile", None)
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.26"
4
+ VERSION = "0.9.27"
5
5
  _CONTACT_RE = re.compile(r"^(?:[^\s@()<>;]+@[^\s@()<>;]+\.[^\s@()<>;]+|https?://[^\s()<>;]+)$")
6
6
 
7
7