@allansantos-dev/smart-tool 0.0.0-stage → 0.9.0

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.
Files changed (77) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +32 -0
  4. package/README.md +116 -3
  5. package/atomic_io.py +94 -0
  6. package/autostart.py +199 -0
  7. package/bin/smart-tool.mjs +82 -0
  8. package/browser_service_client.py +232 -0
  9. package/capabilities.py +63 -0
  10. package/client_hooks.py +192 -0
  11. package/code_graph.py +403 -0
  12. package/code_graph_java.mjs +49 -0
  13. package/code_graph_js.cjs +135 -0
  14. package/config.py +241 -0
  15. package/daemon_launcher.py +165 -0
  16. package/directory_watch.py +131 -0
  17. package/document_text.py +69 -0
  18. package/duplicates.py +313 -0
  19. package/embedding_cache.py +99 -0
  20. package/endpoint_sync.py +179 -0
  21. package/folder_picker.py +73 -0
  22. package/freshness.py +278 -0
  23. package/gateway.py +220 -0
  24. package/hook_decision.py +96 -0
  25. package/hooks/pretool_router.py +45 -0
  26. package/index_inventory.py +75 -0
  27. package/index_profile.py +167 -0
  28. package/index_scope.py +458 -0
  29. package/index_views.py +121 -0
  30. package/indexer.py +878 -0
  31. package/install.py +251 -0
  32. package/install_runtime.py +249 -0
  33. package/integration_metrics.py +82 -0
  34. package/local_embedder.py +172 -0
  35. package/model_adapters/__init__.py +0 -0
  36. package/model_adapters/openai_compatible.py +57 -0
  37. package/model_catalog.py +74 -0
  38. package/model_client.py +38 -0
  39. package/model_defaults.py +70 -0
  40. package/package.json +35 -6
  41. package/paths.py +6 -0
  42. package/project_identity.py +42 -0
  43. package/project_insights_ui.py +38 -0
  44. package/project_maps_ui.py +96 -0
  45. package/project_monitor.py +204 -0
  46. package/project_scheduler.py +61 -0
  47. package/project_storage.py +165 -0
  48. package/project_store.py +220 -0
  49. package/projects_ui.py +146 -0
  50. package/requirements.txt +8 -0
  51. package/research_cache.py +171 -0
  52. package/research_metrics.py +110 -0
  53. package/router.py +582 -0
  54. package/secret_store.py +121 -0
  55. package/setup_ui.py +986 -0
  56. package/site_memory.py +53 -0
  57. package/smart_tool_daemon.py +3604 -0
  58. package/tray.py +245 -0
  59. package/update_check.py +22 -0
  60. package/usage_meter.py +124 -0
  61. package/version.py +18 -0
  62. package/view_compare.py +68 -0
  63. package/web_adapters/browser/browser_service.py +302 -0
  64. package/web_adapters/browser/crawl4ai_search.py +236 -0
  65. package/web_adapters/browser/requirements.txt +2 -0
  66. package/web_adapters/node/adapter-common.mjs +40 -0
  67. package/web_adapters/node/ddg-search.mjs +89 -0
  68. package/web_adapters/node/open-websearch-adapter.mjs +77 -0
  69. package/web_adapters/node/package-lock.json +2720 -0
  70. package/web_adapters/node/package.json +12 -0
  71. package/web_adapters/node/wikipedia-search.mjs +44 -0
  72. package/web_document_graph.py +71 -0
  73. package/web_fetch.py +242 -0
  74. package/web_search_adapters.py +193 -0
  75. package/web_search_ddgs.py +42 -0
  76. package/web_search_external.py +288 -0
  77. package/web_search_health.py +88 -0
@@ -0,0 +1,75 @@
1
+ """Inventário somente leitura de visões, arquivos e trechos já persistidos."""
2
+ import re
3
+ from pathlib import Path
4
+
5
+ import indexer
6
+ import index_views
7
+ import project_identity
8
+ import document_text
9
+
10
+
11
+ def views(root):
12
+ key = project_identity.project_id(root)
13
+ current = index_views.describe(root)
14
+ paths = list(Path(indexer.INDEX_DIR).glob(key + '.v-*.sqlite3'))
15
+ paths += [Path(indexer.INDEX_DIR)/(item+'.sqlite3') for item in
16
+ dict.fromkeys([key, *project_identity.legacy_ids(root)])]
17
+ rows = []
18
+ for path in dict.fromkeys(paths):
19
+ if not path.is_file() or not re.fullmatch(r'[a-f0-9]{16}(?:\.v-[a-f0-9]{16})?\.sqlite3', path.name):
20
+ continue
21
+ conn = indexer._readonly(str(path))
22
+ try:
23
+ meta = indexer._metadata(conn)
24
+ view_id = meta.get('view_id') or ('legacy' if current['git'] else 'workspace')
25
+ rows.append({'view_id': view_id, 'label': meta.get('view_label') or 'Legacy, no identified branch',
26
+ 'branch': meta.get('git_branch'), 'commit': meta.get('git_commit'),
27
+ 'current': view_id == current['view_id'], 'path': str(path),
28
+ 'files': conn.execute('SELECT count(*) FROM manifest').fetchone()[0],
29
+ 'chunks': conn.execute('SELECT count(*) FROM chunks').fetchone()[0],
30
+ 'model': meta.get('model_id'), 'dimensions': meta.get('dimensions'),
31
+ 'updated_at': meta.get('updated_at'), 'bytes': path.stat().st_size})
32
+ finally:
33
+ conn.close()
34
+ return sorted(rows, key=lambda row: (not row['current'], row['label']))
35
+
36
+
37
+ def inspect(root, view_id=None, file_path=None, storage_id=None):
38
+ if file_path is not None:
39
+ if not isinstance(file_path, str):
40
+ raise ValueError("file_path must be a string.")
41
+ file_path = file_path.replace(chr(92), "/").removeprefix("./")
42
+ available = views(root)
43
+ current = index_views.describe(root)
44
+ explicit_view = view_id is not None or storage_id is not None
45
+ view_id = view_id or current['view_id']
46
+ selected = next((row for row in available if (Path(row['path']).name==storage_id if storage_id else row['view_id']==view_id)), None)
47
+ if selected is None and available and not explicit_view:
48
+ selected = available[0]
49
+ if not selected:
50
+ return {'views': available, 'selected': None, 'files': [], 'chunks': [],
51
+ 'message': 'This view has not been indexed yet. Run a search or use Index now.'}
52
+ conn = indexer._readonly(selected['path'])
53
+ try:
54
+ columns = {row[1] for row in conn.execute('PRAGMA table_info(manifest)')}
55
+ # Índices anteriores ao manifest incremental não têm size/mtime_ns.
56
+ # A inspeção é somente leitura; não migrar nem medir o arquivo de outra branch.
57
+ size_column = 'm.size' if 'size' in columns else 'NULL'
58
+ files = [{'path': path, 'chunks': count, 'hash': digest, 'size': size} for path, count, digest, size in
59
+ conn.execute(f'SELECT m.path,count(c.id),m.hash,{size_column} FROM manifest m LEFT JOIN chunks c ON c.path=m.path GROUP BY m.path ORDER BY m.path LIMIT 1001')]
60
+ chunks = []
61
+ if file_path is not None:
62
+ if not conn.execute('SELECT 1 FROM manifest WHERE path=?', (file_path,)).fetchone():
63
+ raise ValueError(f'File is outside the indexed view: {file_path}')
64
+ files = []
65
+ chunks = [{'start_line': start, 'end_line': end, 'text': text, 'dimensions': int(size or 0)//4,
66
+ 'location_kind':document_text.location_kind(file_path)}
67
+ for start, end, text, size in conn.execute(
68
+ 'SELECT start_line,end_line,text,length(embedding) FROM chunks WHERE path=? ORDER BY start_line LIMIT 100', (file_path,))]
69
+ return {'views': available, 'selected': selected, 'files': files[:1000],
70
+ 'truncated': len(files)>1000, 'chunks': chunks, 'max_chunks_displayed': 100,
71
+ 'current_view': index_views.public(current),
72
+ 'message': '' if selected['current'] else 'The current view is not shown. You are inspecting the stored index of ' + selected['label'] + '.',
73
+ 'graph_type': 'project_branch_directory_file', 'read_only': True}
74
+ finally:
75
+ conn.close()
@@ -0,0 +1,167 @@
1
+ import collections
2
+ import fnmatch
3
+ import json
4
+ import os
5
+ import re
6
+ import time
7
+
8
+ import model_client
9
+
10
+ PROFILE_VERSION = 2
11
+ KINDS = ("code", "test", "doc")
12
+ RETRY_AFTER_S = 3600
13
+ MAX_DIRS = 90
14
+ PROSE = {"md", "txt", "docx", "rst", "adoc"}
15
+ # Data and markup inside a documentation folder are reports, not code (GitHub Linguist treats docs/ as documentation).
16
+ DOC_DATA = {"html", "htm", "json", "csv", "xml", "yaml", "yml", "svg"}
17
+ _NAME_DOC = re.compile(r"(^|/)(readme|changelog|license|licence|contributing|install|history|authors)(\.[^/]*)?$", re.I)
18
+ _DIR_DOC = re.compile(r"(^|/)(docs?|adrs?|documentation)/", re.I)
19
+ _TEST = re.compile(r"(^|/)(tests?|__tests__|spec|specs|e2e|fixtures?|testdata|test-utils?)(/|$)|(^|/)test_[^/]+\.py$|"
20
+ r"(^|/)conftest\.py$|_test\.(py|go)$|\.(test|spec)\.[cm]?[jt]sx?$|(^|/)[^/]*[a-z0-9](?-i:Tests?)\.java$|"
21
+ r"(^|/)src/test/|(^|/)test[-_][^/]*\.[cm]?[jt]sx?$|[-_]test\.[cm]?[jt]sx?$", re.I)
22
+ _TEST_PROSE = re.compile(r"(^|/)(tests?|__tests__|e2e|fixtures?|testdata|test-utils?)(/|$)|(^|/)src/test/", re.I)
23
+ _TEXT_LINE = re.compile(r"[A-Za-zÀ-ú]{3,}\s+[A-Za-zÀ-ú]{3,}")
24
+
25
+ _TOOL = {"type": "function", "function": {
26
+ "name": "submit_profile", "description": "Submit the search-index profile.",
27
+ "parameters": {"type": "object", "required": ["groups", "languages"], "properties": {
28
+ "groups": {"type": "array", "items": {"type": "object", "required": ["path", "kind"], "properties": {
29
+ "path": {"type": "string", "description": "directory relative to root, '.' for root, or a glob like '**/NOTES.md'"},
30
+ "kind": {"type": "string", "enum": list(KINDS)}}}},
31
+ "languages": {"type": "object", "required": ["identifiers", "comments", "doc"], "properties": {
32
+ "identifiers": {"type": "array", "items": {"type": "string"},
33
+ "description": "natural languages of identifier names (functions, classes, variables), most prevalent first (BCP-47)"},
34
+ "comments": {"type": "array", "items": {"type": "string"},
35
+ "description": "natural languages of code comments and docstrings, most prevalent first (BCP-47)"},
36
+ "doc": {"type": "array", "items": {"type": "string"},
37
+ "description": "natural languages of documentation, most prevalent first (BCP-47)"}}}}}}}
38
+
39
+ _PROMPT = """You are profiling a repository for a code-search index at {root}.
40
+ Classify where things live, by directory, not file by file. Three kinds:
41
+ - code: anything the product runs or loads: source, templates, HTML pages, config, scripts, and prompt/instruction files that the product itself executes (e.g. agent/skill/command definitions of a plugin).
42
+ - test: automated tests and their fixtures, mocks and helpers (unit, integration, e2e), even in folders with unusual names.
43
+ - doc: material written for people: README, CHANGELOG, docs/, ADRs, plans, guides, notes.
44
+ Use the most specific path needed: a directory, or a glob for files that break the rule of their directory. Paths not listed inherit from their closest listed parent.
45
+ Also report the natural languages used, most prevalent first, separately for identifier names, for code comments and docstrings, and for docs. Identifiers are often English even when comments are not. Use the text samples as evidence.
46
+ Directories (dir, file count, extensions, sample names, text sample):
47
+ {rows}
48
+ (total directories with indexed files: {total}; only the {shown} largest are shown)"""
49
+
50
+
51
+ def _ext(path):
52
+ name = path.rsplit("/", 1)[-1]
53
+ return name.rsplit(".", 1)[-1].lower() if "." in name else ""
54
+
55
+
56
+ def _sample(root, rel):
57
+ try:
58
+ with open(os.path.join(root, rel), "rb") as stream:
59
+ raw = stream.read(4000).decode("utf-8", "ignore")
60
+ except OSError:
61
+ return ""
62
+ return " | ".join(line.strip() for line in raw.splitlines() if _TEXT_LINE.search(line))[:260]
63
+
64
+
65
+ def _evidence(root, included):
66
+ by_dir = collections.defaultdict(list)
67
+ for rel in included:
68
+ by_dir[rel.rsplit("/", 1)[0] if "/" in rel else "."].append(rel)
69
+ rows = []
70
+ for rel_dir, files in sorted(by_dir.items(), key=lambda item: -len(item[1]))[:MAX_DIRS]:
71
+ names = [path.rsplit("/", 1)[-1] for path in files]
72
+ text = next((path for path in files if _ext(path) in PROSE | {"py", "js", "ts", "java", "html", "mjs", "cjs"}), None)
73
+ rows.append({"dir": rel_dir, "files": len(files),
74
+ "ext": dict(collections.Counter(_ext(path) or "(none)" for path in files).most_common(6)),
75
+ "sample": names[:5], "text_sample": _sample(root, text) if text else ""})
76
+ return rows, len(by_dir)
77
+
78
+
79
+ def _valid(args):
80
+ groups, languages = args.get("groups"), args.get("languages")
81
+ return (isinstance(groups, list) and all(isinstance(g, dict) and isinstance(g.get("path"), str)
82
+ and g.get("kind") in KINDS for g in groups)
83
+ and isinstance(languages, dict) and all(isinstance(languages.get(key), list) and
84
+ all(isinstance(item, str) for item in languages[key])
85
+ for key in ("identifiers", "comments", "doc")))
86
+
87
+
88
+ def decide(root, included, model, deadline=None):
89
+ rows, total = _evidence(root, included)
90
+ remaining = deadline - time.monotonic() if deadline is not None else 120
91
+ if remaining <= 0:
92
+ raise TimeoutError("Profile analysis deadline exceeded.")
93
+ response = model_client.fetch("/v1/chat/completions", model_client.get_token(), method="POST",
94
+ timeout=min(120, remaining), body={
95
+ "model": model, "temperature": 0, "tools": [_TOOL], "tool_choice": "required",
96
+ "messages": [{"role": "user", "content": _PROMPT.format(
97
+ root=root, rows=json.dumps(rows, ensure_ascii=False), total=total, shown=len(rows))}]})
98
+ calls = response["choices"][0]["message"].get("tool_calls") or []
99
+ try:
100
+ args = next((json.loads(call["function"]["arguments"] or "{}") for call in calls
101
+ if call["function"]["name"] == "submit_profile"), None)
102
+ except json.JSONDecodeError as exc:
103
+ raise RuntimeError(f"The model returned a profile with invalid JSON: {exc}") from exc
104
+ if not isinstance(args, dict) or not _valid(args):
105
+ raise RuntimeError("The model did not define a valid profile of groups and languages.")
106
+ return {"version": PROFILE_VERSION, "groups": args["groups"], "languages": args["languages"],
107
+ "model": model, "decided_at": time.time()}
108
+
109
+
110
+ def _explicit_kind(path, groups):
111
+ """Kind of the most specific profile group that names this path; None when only the root group covers it."""
112
+ covering = [group for group in groups if _covers(path, group)]
113
+ return _model_kind(path, covering) if covering else None
114
+
115
+
116
+ def _covers(path, group):
117
+ pattern = group["path"].replace(chr(92), "/").removeprefix("./").strip("/") or "."
118
+ if pattern == ".":
119
+ return False
120
+ if any(char in pattern for char in "*?["):
121
+ return fnmatch.fnmatch(path, pattern) or fnmatch.fnmatch(path, pattern.replace("**/", ""))
122
+ return path.startswith(pattern + "/")
123
+
124
+
125
+ def _model_kind(path, groups):
126
+ best = None
127
+ for group in groups:
128
+ pattern = group["path"].replace(chr(92), "/").removeprefix("./").strip("/") or "."
129
+ if any(char in pattern for char in "*?["):
130
+ if fnmatch.fnmatch(path, pattern) or fnmatch.fnmatch(path, pattern.replace("**/", "")):
131
+ return group["kind"]
132
+ elif pattern == "." or path.startswith(pattern + "/"):
133
+ depth = 0 if pattern == "." else len(pattern)
134
+ if best is None or depth > best[0]:
135
+ best = (depth, group["kind"])
136
+ return best[1] if best else "doc"
137
+
138
+
139
+ def current(profile):
140
+ return profile if (profile or {}).get("version") == PROFILE_VERSION else None
141
+
142
+
143
+ def kind(path, profile):
144
+ groups = (current(profile) or {}).get("groups") or []
145
+ ext = _ext(path)
146
+ if ext in PROSE:
147
+ if _NAME_DOC.search(path) or _DIR_DOC.search(path):
148
+ return "doc"
149
+ if _TEST_PROSE.search(path):
150
+ return "test"
151
+ return _model_kind(path, groups or [{"path": ".", "kind": "doc"}])
152
+ if _TEST.search(path):
153
+ return "test"
154
+ if ext in DOC_DATA and _DIR_DOC.search(path) and _explicit_kind(path, groups) != "code":
155
+ return "doc"
156
+ if not ext and _NAME_DOC.search(path):
157
+ return "doc"
158
+ return "test" if groups and _model_kind(path, groups) == "test" else "code"
159
+
160
+
161
+ def public(profile):
162
+ profile = current(profile)
163
+ if not profile:
164
+ return {"source": "rules", "identifier_languages": [], "comment_languages": [], "doc_languages": []}
165
+ languages = profile["languages"]
166
+ return {"source": "model", "identifier_languages": languages["identifiers"],
167
+ "comment_languages": languages["comments"], "doc_languages": languages["doc"]}