@allansantos-dev/smart-tool 0.0.0-stage → 0.9.1
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 +35 -0
- package/LICENSE +202 -0
- package/NOTICE +32 -0
- package/README.md +116 -2
- package/atomic_io.py +94 -0
- package/autostart.py +199 -0
- package/bin/smart-tool.mjs +86 -0
- package/browser_service_client.py +232 -0
- package/capabilities.py +63 -0
- package/client_hooks.py +192 -0
- package/code_graph.py +403 -0
- package/code_graph_java.mjs +49 -0
- package/code_graph_js.cjs +135 -0
- package/config.py +241 -0
- package/daemon_launcher.py +165 -0
- package/directory_watch.py +131 -0
- package/document_text.py +69 -0
- package/duplicates.py +313 -0
- package/embedding_cache.py +99 -0
- package/endpoint_sync.py +179 -0
- package/folder_picker.py +73 -0
- package/freshness.py +278 -0
- package/gateway.py +220 -0
- package/hook_decision.py +96 -0
- package/hooks/pretool_router.py +45 -0
- package/index_inventory.py +75 -0
- package/index_profile.py +167 -0
- package/index_scope.py +458 -0
- package/index_views.py +121 -0
- package/indexer.py +878 -0
- package/install.py +353 -0
- package/install_runtime.py +283 -0
- package/integration_metrics.py +82 -0
- package/local_embedder.py +172 -0
- package/model_adapters/__init__.py +0 -0
- package/model_adapters/openai_compatible.py +57 -0
- package/model_catalog.py +74 -0
- package/model_client.py +38 -0
- package/model_defaults.py +70 -0
- package/package.json +33 -4
- package/paths.py +6 -0
- package/project_identity.py +42 -0
- package/project_insights_ui.py +38 -0
- package/project_maps_ui.py +96 -0
- package/project_monitor.py +204 -0
- package/project_scheduler.py +61 -0
- package/project_storage.py +165 -0
- package/project_store.py +220 -0
- package/projects_ui.py +146 -0
- package/requirements.txt +8 -0
- package/research_cache.py +171 -0
- package/research_metrics.py +110 -0
- package/router.py +582 -0
- package/secret_store.py +121 -0
- package/setup_ui.py +986 -0
- package/site_memory.py +53 -0
- package/smart_tool_daemon.py +3604 -0
- package/tray.py +245 -0
- package/update_check.py +22 -0
- package/usage_meter.py +124 -0
- package/version.py +18 -0
- package/view_compare.py +68 -0
- package/web_adapters/browser/browser_service.py +302 -0
- package/web_adapters/browser/crawl4ai_search.py +236 -0
- package/web_adapters/browser/fetch_camoufox.py +28 -0
- package/web_adapters/browser/requirements.txt +2 -0
- package/web_adapters/node/adapter-common.mjs +40 -0
- package/web_adapters/node/ddg-search.mjs +89 -0
- package/web_adapters/node/open-websearch-adapter.mjs +77 -0
- package/web_adapters/node/package-lock.json +2720 -0
- package/web_adapters/node/package.json +12 -0
- package/web_adapters/node/wikipedia-search.mjs +44 -0
- package/web_document_graph.py +71 -0
- package/web_fetch.py +242 -0
- package/web_search_adapters.py +193 -0
- package/web_search_ddgs.py +42 -0
- package/web_search_external.py +288 -0
- package/web_search_health.py +88 -0
package/index_scope.py
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Escopo de indexação decidido por LLM, por repositório (stdlib only).
|
|
3
|
+
|
|
4
|
+
Cada repositório tem convenções diferentes (ex.: `.gitignore` tira `docs/` do controle de
|
|
5
|
+
versão, mas `docs/` é ótima fonte pra busca) — em vez de um classificador mecânico de
|
|
6
|
+
exclusão, um LLM analisa a estrutura do repo uma vez e decide o que entra/sai do índice.
|
|
7
|
+
A decisão é cacheada por path do projeto; re-scan automático só ocorre se surgirem
|
|
8
|
+
diretórios de topo novos desde a última decisão, ou via comando manual (`force=True`).
|
|
9
|
+
"""
|
|
10
|
+
import fnmatch
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import paths
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
import atomic_io
|
|
18
|
+
import project_identity
|
|
19
|
+
import index_views
|
|
20
|
+
|
|
21
|
+
import model_client
|
|
22
|
+
|
|
23
|
+
SCOPE_DIR = paths.DATA_DIR
|
|
24
|
+
|
|
25
|
+
# Names safe to exclude everywhere without inspection: dependency/tooling caches whose
|
|
26
|
+
# purpose does not vary by project convention. Ambiguous names (dist, build, out, bin...)
|
|
27
|
+
# are deliberately NOT here — those must be inspected, since their meaning is project-specific.
|
|
28
|
+
_ALWAYS_EXCLUDE = {
|
|
29
|
+
".git", "node_modules", "__pycache__", ".venv", "venv", "env",
|
|
30
|
+
".tox", ".mypy_cache", ".pytest_cache", "*.egg-info",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Mesmo critério do `_ALWAYS_EXCLUDE`, para arquivo: o significado não varia por
|
|
34
|
+
# projeto. Lockfile é grafo de dependência resolvido por ferramenta — nunca é fonte de
|
|
35
|
+
# resposta numa busca semântica, e é grande o bastante pra dominar o índice.
|
|
36
|
+
_ALWAYS_EXCLUDE_FILES = {
|
|
37
|
+
"package-lock.json", "yarn.lock", "pnpm-lock.yaml", "npm-shrinkwrap.json",
|
|
38
|
+
"poetry.lock", "pdm.lock", "uv.lock", "Pipfile.lock", "Cargo.lock",
|
|
39
|
+
"composer.lock", "go.sum", "Gemfile.lock", "packages.lock.json",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
_ROOT_DOC_NAMES = ("AGENTS.md", "CLAUDE.md", "README.md", "README")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def always_excluded(name):
|
|
46
|
+
"""Directory name in _ALWAYS_EXCLUDE, entries may be globs (e.g. *.egg-info)."""
|
|
47
|
+
folded = name.casefold()
|
|
48
|
+
return any(fnmatch.fnmatch(folded, pattern.casefold()) for pattern in _ALWAYS_EXCLUDE)
|
|
49
|
+
|
|
50
|
+
# Versão do contrato de decisão de escopo. Mudar o que é perguntado ao decisor exige
|
|
51
|
+
# incrementar: sem isso, um repositório com escopo já cacheado nunca é reavaliado, porque
|
|
52
|
+
# `needs_rescan` só olha diretório de topo novo.
|
|
53
|
+
SCOPE_VERSION = 4
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _within_root(root, candidate):
|
|
57
|
+
"""`candidate` está dentro de `root` depois de resolver link/junction. Comparar só
|
|
58
|
+
`abspath` (sem resolver) deixa passar junction do Windows, que aponta pra fora sem
|
|
59
|
+
que `os.path.islink()` acuse."""
|
|
60
|
+
return project_identity.within_root(root, candidate)
|
|
61
|
+
|
|
62
|
+
_INSPECT_TOOL = {
|
|
63
|
+
"type": "function",
|
|
64
|
+
"function": {
|
|
65
|
+
"name": "inspect_directory",
|
|
66
|
+
"description": (
|
|
67
|
+
"Inspect a directory's real contents (file count, extension histogram, sample "
|
|
68
|
+
"filenames) before deciding whether to include or exclude it from the search "
|
|
69
|
+
"index. Call this for every directory whose purpose is not already settled by "
|
|
70
|
+
"the project documentation — never decide from the directory name alone."
|
|
71
|
+
),
|
|
72
|
+
"parameters": {
|
|
73
|
+
"type": "object",
|
|
74
|
+
"properties": {
|
|
75
|
+
"path": {"type": "string", "description": "Path relative to the project root"},
|
|
76
|
+
},
|
|
77
|
+
"required": ["path"],
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
_SCOPE_TOOL = {
|
|
83
|
+
"type": "function",
|
|
84
|
+
"function": {
|
|
85
|
+
"name": "submit_scope",
|
|
86
|
+
"description": "Submit the include/exclude scope decision for the search index.",
|
|
87
|
+
"parameters": {
|
|
88
|
+
"type": "object",
|
|
89
|
+
"properties": {
|
|
90
|
+
"include": {"type": "array", "items": {"type": "string"}},
|
|
91
|
+
"exclude": {"type": "array", "items": {"type": "string"}},
|
|
92
|
+
},
|
|
93
|
+
"required": ["include", "exclude"],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _project_hash(root):
|
|
100
|
+
return index_views.storage_key(root)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def scope_path(root):
|
|
104
|
+
return os.path.join(SCOPE_DIR, _project_hash(root) + ".scope.json")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _top_level_dirs(root):
|
|
108
|
+
try:
|
|
109
|
+
names = os.listdir(root)
|
|
110
|
+
except OSError:
|
|
111
|
+
return []
|
|
112
|
+
return sorted(
|
|
113
|
+
name for name in names
|
|
114
|
+
if not name.startswith(".")
|
|
115
|
+
and not always_excluded(name)
|
|
116
|
+
and os.path.isdir(os.path.join(root, name))
|
|
117
|
+
and _within_root(root, os.path.join(root, name))
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def valid_scope(scope):
|
|
122
|
+
return (isinstance(scope, dict) and all(
|
|
123
|
+
isinstance(scope.get(key, []), list) and all(isinstance(item, str) for item in scope.get(key, []))
|
|
124
|
+
for key in ("include", "exclude", "user_exclude", "top_level_dirs", "structure")))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def load_scope(root):
|
|
128
|
+
paths = [scope_path(root)] + [os.path.join(SCOPE_DIR, key + ".scope.json")
|
|
129
|
+
for key in project_identity.legacy_ids(root)]
|
|
130
|
+
for path in dict.fromkeys(paths):
|
|
131
|
+
try:
|
|
132
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
133
|
+
data = json.load(f)
|
|
134
|
+
if valid_scope(data):
|
|
135
|
+
return data
|
|
136
|
+
except (OSError, ValueError, UnicodeError):
|
|
137
|
+
continue
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def save_scope(root, scope):
|
|
142
|
+
if not valid_scope(scope):
|
|
143
|
+
raise ValueError("Scope must contain lists of paths in include/exclude.")
|
|
144
|
+
index_views.assert_current(index_views.describe(root))
|
|
145
|
+
os.makedirs(SCOPE_DIR, exist_ok=True)
|
|
146
|
+
atomic_io.write_secret_text(scope_path(root), json.dumps(
|
|
147
|
+
{**scope, "root": project_identity.canonical_root(root)}, indent=2, ensure_ascii=False))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _structure(root, max_dirs=1500):
|
|
151
|
+
found = []
|
|
152
|
+
for current, dirs, _files in os.walk(root):
|
|
153
|
+
dirs[:] = sorted(d for d in dirs if not d.startswith(".") and not always_excluded(d)
|
|
154
|
+
and _within_root(root, os.path.join(current, d)))
|
|
155
|
+
for name in dirs:
|
|
156
|
+
found.append(os.path.relpath(os.path.join(current, name), root).replace(os.sep, "/"))
|
|
157
|
+
if len(found) >= max_dirs:
|
|
158
|
+
return found
|
|
159
|
+
return found
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _docs_digest(root):
|
|
163
|
+
return hashlib.sha256(_read_root_docs(root).encode("utf-8")).hexdigest()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def needs_rescan(root, cached_scope):
|
|
167
|
+
if not valid_scope(cached_scope):
|
|
168
|
+
return True
|
|
169
|
+
if cached_scope.get("manual"):
|
|
170
|
+
return False
|
|
171
|
+
if cached_scope.get("scope_version") != SCOPE_VERSION:
|
|
172
|
+
return True
|
|
173
|
+
if cached_scope.get("docs_digest") != _docs_digest(root):
|
|
174
|
+
return True
|
|
175
|
+
known = set(cached_scope.get("top_level_dirs", []))
|
|
176
|
+
current = set(_top_level_dirs(root))
|
|
177
|
+
return (not current.issubset(known) or
|
|
178
|
+
not set(_structure(root)).issubset(set(cached_scope.get("structure", []))))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _read_root_docs(root, max_chars=4000):
|
|
182
|
+
parts = []
|
|
183
|
+
budget = max_chars
|
|
184
|
+
for name in _ROOT_DOC_NAMES:
|
|
185
|
+
if budget <= 0:
|
|
186
|
+
break
|
|
187
|
+
full = os.path.join(root, name)
|
|
188
|
+
if not os.path.isfile(full) or not _within_root(root, full):
|
|
189
|
+
continue
|
|
190
|
+
try:
|
|
191
|
+
with open(full, "r", encoding="utf-8", errors="ignore") as f:
|
|
192
|
+
text = f.read(budget)
|
|
193
|
+
except OSError:
|
|
194
|
+
continue
|
|
195
|
+
parts.append(f"--- {name} ---\n{text}")
|
|
196
|
+
budget -= len(text)
|
|
197
|
+
return "\n\n".join(parts)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _inspect_directory(root, rel_path, max_files=4000, sample_size=15, top_ext=12):
|
|
201
|
+
root_full = os.path.normpath(os.path.abspath(root))
|
|
202
|
+
base_full = os.path.normpath(os.path.abspath(os.path.join(root, rel_path)))
|
|
203
|
+
if not _within_root(root_full, base_full):
|
|
204
|
+
return {"error": "path outside the project root"}
|
|
205
|
+
if not os.path.isdir(base_full):
|
|
206
|
+
return {"error": f"directory not found: {rel_path}"}
|
|
207
|
+
|
|
208
|
+
ext_counts = {}
|
|
209
|
+
samples = []
|
|
210
|
+
total = 0
|
|
211
|
+
total_size = 0
|
|
212
|
+
max_depth = 0
|
|
213
|
+
for dirpath, _dirnames, filenames in os.walk(base_full):
|
|
214
|
+
_dirnames[:] = [name for name in _dirnames if not name.startswith(".")
|
|
215
|
+
and not always_excluded(name)
|
|
216
|
+
and _within_root(root, os.path.join(dirpath, name))]
|
|
217
|
+
depth = os.path.relpath(dirpath, base_full).count(os.sep) + (0 if dirpath == base_full else 1)
|
|
218
|
+
max_depth = max(max_depth, depth)
|
|
219
|
+
for name in filenames:
|
|
220
|
+
total += 1
|
|
221
|
+
if total > max_files:
|
|
222
|
+
break
|
|
223
|
+
ext = os.path.splitext(name)[1].lower() or "(no extension)"
|
|
224
|
+
ext_counts[ext] = ext_counts.get(ext, 0) + 1
|
|
225
|
+
full = os.path.join(dirpath, name)
|
|
226
|
+
try:
|
|
227
|
+
total_size += os.path.getsize(full)
|
|
228
|
+
except OSError:
|
|
229
|
+
pass
|
|
230
|
+
if len(samples) < sample_size:
|
|
231
|
+
samples.append(os.path.relpath(full, base_full).replace(os.sep, "/"))
|
|
232
|
+
if total > max_files:
|
|
233
|
+
break
|
|
234
|
+
|
|
235
|
+
top_extensions = dict(sorted(ext_counts.items(), key=lambda kv: -kv[1])[:top_ext])
|
|
236
|
+
return {
|
|
237
|
+
"path": rel_path,
|
|
238
|
+
"file_count": total,
|
|
239
|
+
"truncated": total > max_files,
|
|
240
|
+
"total_size_bytes": total_size,
|
|
241
|
+
"max_depth": max_depth,
|
|
242
|
+
"extensions": top_extensions,
|
|
243
|
+
"sample_files": samples,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _build_scope_prompt(root, ambiguous_dirs, root_files, doc_context):
|
|
248
|
+
return (
|
|
249
|
+
"You are deciding what to include in a semantic code-search index for the "
|
|
250
|
+
f"repository at {root}.\n\n"
|
|
251
|
+
"These directories are already excluded automatically (dependency/tooling caches "
|
|
252
|
+
f"whose meaning does not vary by project): {', '.join(sorted(_ALWAYS_EXCLUDE))}. "
|
|
253
|
+
"Do not decide on them.\n\n"
|
|
254
|
+
f"Directories that still need a decision: {', '.join(ambiguous_dirs) or '(none)'}\n\n"
|
|
255
|
+
"Loose files at the repository ROOT are indexed automatically, because in a flat "
|
|
256
|
+
"repository they are the whole codebase and no directory in `include` can stand "
|
|
257
|
+
f"for them. These are the ones found: {', '.join(root_files) or '(none)'}. "
|
|
258
|
+
"If any of them is generated output, a data dump, a duplicate of another file, or "
|
|
259
|
+
"otherwise useless for code search, list it in `exclude` by name — `exclude` "
|
|
260
|
+
"accepts file paths and glob patterns (e.g. 'notes/*.log'), not only directory "
|
|
261
|
+
"names. Files holding credentials are dropped mechanically elsewhere; you do not "
|
|
262
|
+
"need to decide on those.\n\n"
|
|
263
|
+
"Project documentation found at the root (may state project-specific conventions):\n"
|
|
264
|
+
f"{doc_context or '(none found)'}\n\n"
|
|
265
|
+
"IMPORTANT: names like 'dist', 'build', 'out', 'bin', 'target' do NOT reliably mean "
|
|
266
|
+
"'generated build output' — some projects use them to hold real hand-written source. "
|
|
267
|
+
"Never decide from the directory name alone. For every directory listed above whose "
|
|
268
|
+
"purpose is not already settled by the documentation, call inspect_directory(path) "
|
|
269
|
+
"and look at the real extensions/sample filenames before deciding. If the project "
|
|
270
|
+
"documentation above explicitly states what a directory is for, that statement "
|
|
271
|
+
"overrides any inference you would otherwise draw from file extensions or the "
|
|
272
|
+
"directory's name — do not exclude a directory the documentation identifies as "
|
|
273
|
+
"source, even if it also contains a few generated/log files. Only call submit_scope "
|
|
274
|
+
"once every non-obvious directory is grounded in inspection data or in the "
|
|
275
|
+
"documentation.\n\n"
|
|
276
|
+
"Include sources useful for search even if they are gitignored (e.g. docs/, README, "
|
|
277
|
+
"ADRs). Java, Angular, HTML/CSS, plain text and Markdown are supported sources. "
|
|
278
|
+
"DOCX documents are supported through bounded local text extraction; do not exclude "
|
|
279
|
+
"useful Word documentation merely because its container is binary. "
|
|
280
|
+
"Exclude directories that inspection shows are generated artifacts or "
|
|
281
|
+
"dependencies (compiled binaries, minified bundles, hashed build chunks, vendored "
|
|
282
|
+
"packages)."
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def decide_scope(root, router_model, force=False, deadline=None, cancel_check=None):
|
|
287
|
+
cached = load_scope(root)
|
|
288
|
+
if not force and cached is not None and not needs_rescan(root, cached):
|
|
289
|
+
return cached
|
|
290
|
+
|
|
291
|
+
top_level_dirs = _top_level_dirs(root)
|
|
292
|
+
ambiguous_dirs = [d for d in top_level_dirs if not always_excluded(d)]
|
|
293
|
+
root_files = _root_level_files(root, _exclude_matcher(_ALWAYS_EXCLUDE_FILES))
|
|
294
|
+
doc_context = _read_root_docs(root)
|
|
295
|
+
|
|
296
|
+
token = model_client.get_token()
|
|
297
|
+
messages = [{"role": "user", "content": _build_scope_prompt(root, ambiguous_dirs, root_files, doc_context)}]
|
|
298
|
+
tools = [_INSPECT_TOOL, _SCOPE_TOOL]
|
|
299
|
+
decision = None
|
|
300
|
+
|
|
301
|
+
for _ in range(min(len(ambiguous_dirs) + 5, 30)):
|
|
302
|
+
if cancel_check:
|
|
303
|
+
cancel_check()
|
|
304
|
+
remaining = deadline - time.monotonic() if deadline is not None else 60
|
|
305
|
+
if remaining <= 0:
|
|
306
|
+
raise TimeoutError("Scope analysis deadline exceeded.")
|
|
307
|
+
response = model_client.fetch(
|
|
308
|
+
"/v1/chat/completions",
|
|
309
|
+
token,
|
|
310
|
+
method="POST",
|
|
311
|
+
timeout=min(60, remaining),
|
|
312
|
+
body={
|
|
313
|
+
"model": router_model,
|
|
314
|
+
"messages": messages,
|
|
315
|
+
"tools": tools,
|
|
316
|
+
"tool_choice": "required",
|
|
317
|
+
"temperature": 0,
|
|
318
|
+
},
|
|
319
|
+
)
|
|
320
|
+
message = response["choices"][0]["message"]
|
|
321
|
+
tool_calls = message.get("tool_calls") or []
|
|
322
|
+
if not tool_calls:
|
|
323
|
+
break
|
|
324
|
+
messages.append({"role": "assistant", "content": message.get("content"), "tool_calls": tool_calls})
|
|
325
|
+
|
|
326
|
+
for tool_call in tool_calls:
|
|
327
|
+
name = tool_call["function"]["name"]
|
|
328
|
+
try:
|
|
329
|
+
args = json.loads(tool_call["function"]["arguments"] or "{}")
|
|
330
|
+
except json.JSONDecodeError:
|
|
331
|
+
args = {}
|
|
332
|
+
if not isinstance(args, dict):
|
|
333
|
+
args = {}
|
|
334
|
+
if name == "submit_scope":
|
|
335
|
+
if isinstance(args, dict) and "include" in args and "exclude" in args and valid_scope(args):
|
|
336
|
+
decision = args
|
|
337
|
+
result_text = "ok"
|
|
338
|
+
else:
|
|
339
|
+
result_text = "include/exclude must be arrays of strings"
|
|
340
|
+
elif name == "inspect_directory":
|
|
341
|
+
result_text = json.dumps(_inspect_directory(root, args.get("path", "")), ensure_ascii=False)
|
|
342
|
+
else:
|
|
343
|
+
result_text = json.dumps({"error": f"tool desconhecida: {name}"})
|
|
344
|
+
messages.append({"role": "tool", "tool_call_id": tool_call["id"], "content": result_text})
|
|
345
|
+
|
|
346
|
+
if decision is not None:
|
|
347
|
+
break
|
|
348
|
+
|
|
349
|
+
if decision is None:
|
|
350
|
+
raise RuntimeError("The model did not define a valid scope. Check the model or set the scope manually.")
|
|
351
|
+
scope = {
|
|
352
|
+
"include": (decision or {}).get("include") or [],
|
|
353
|
+
"exclude": (decision or {}).get("exclude") or [],
|
|
354
|
+
"top_level_dirs": top_level_dirs,
|
|
355
|
+
"scope_version": SCOPE_VERSION,
|
|
356
|
+
"structure": _structure(root),
|
|
357
|
+
"docs_digest": _docs_digest(root),
|
|
358
|
+
"user_exclude": (cached or {}).get("user_exclude", []),
|
|
359
|
+
"decided_at": time.time(),
|
|
360
|
+
}
|
|
361
|
+
save_scope(root, scope)
|
|
362
|
+
return scope
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _root_level_files(root, matcher):
|
|
366
|
+
"""Arquivos soltos na raiz do projeto, sem descer em nenhum diretório.
|
|
367
|
+
|
|
368
|
+
Num repositório de fontes planos (Python/PowerShell sem `src/`) eles são o código
|
|
369
|
+
todo, e `include` lista diretórios — então nenhuma entrada de `include` os
|
|
370
|
+
representa. `_build_scope_prompt` mostra esta mesma lista ao decisor, que pode
|
|
371
|
+
nomeá-los em `exclude`."""
|
|
372
|
+
try:
|
|
373
|
+
names = sorted(os.listdir(root))
|
|
374
|
+
except OSError as exc:
|
|
375
|
+
raise OSError("Could not list the root; the existing index was kept.") from exc
|
|
376
|
+
out = []
|
|
377
|
+
for name in names:
|
|
378
|
+
if matcher(name):
|
|
379
|
+
continue
|
|
380
|
+
full = os.path.join(root, name)
|
|
381
|
+
# `isfile` segue link: sem a checagem de contenção, um link na raiz apontando
|
|
382
|
+
# pra fora do projeto entraria com um `rel_path` que parece interno.
|
|
383
|
+
if os.path.isfile(full) and _within_root(root, full):
|
|
384
|
+
out.append(name)
|
|
385
|
+
return out
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _exclude_matcher(exclude):
|
|
389
|
+
"""Predicado "está excluído?" tolerante ao que um LLM de fato emite: glob
|
|
390
|
+
(`logs/*.log`), separador do Windows, caixa diferente da do disco e prefixo
|
|
391
|
+
de diretório. Uma exclusão que não casa é pior que nenhuma — o decisor acredita ter
|
|
392
|
+
tirado o arquivo do índice."""
|
|
393
|
+
patterns = set()
|
|
394
|
+
for raw in exclude:
|
|
395
|
+
norm = str(raw).replace("\\", "/").strip("/").lower()
|
|
396
|
+
if norm.startswith("./"):
|
|
397
|
+
norm = norm[2:]
|
|
398
|
+
if norm:
|
|
399
|
+
patterns.add(norm)
|
|
400
|
+
|
|
401
|
+
def matches(rel_path):
|
|
402
|
+
target = rel_path.replace("\\", "/").lower()
|
|
403
|
+
name = target.rsplit("/", 1)[-1]
|
|
404
|
+
for pattern in patterns:
|
|
405
|
+
if target == pattern or name == pattern:
|
|
406
|
+
return True
|
|
407
|
+
if target.startswith(pattern + "/"):
|
|
408
|
+
return True
|
|
409
|
+
if ("*" in pattern or "?" in pattern or "[" in pattern) and (
|
|
410
|
+
fnmatch.fnmatch(target, pattern) or fnmatch.fnmatch(name, pattern)
|
|
411
|
+
):
|
|
412
|
+
return True
|
|
413
|
+
return False
|
|
414
|
+
|
|
415
|
+
return matches
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def resolve_included_files(root, scope, cancel_check=None):
|
|
419
|
+
if not valid_scope(scope):
|
|
420
|
+
raise ValueError("Invalid scope; request a new project analysis.")
|
|
421
|
+
include = scope.get("include") or [""]
|
|
422
|
+
matcher = _exclude_matcher(set(scope.get("exclude") or []) | set(scope.get("user_exclude") or []) |
|
|
423
|
+
_ALWAYS_EXCLUDE | _ALWAYS_EXCLUDE_FILES)
|
|
424
|
+
files = []
|
|
425
|
+
def walk_error(error):
|
|
426
|
+
raise OSError("Could not list a folder in the scope; try again when it is accessible.") from error
|
|
427
|
+
if not any(base in ("", ".") for base in include):
|
|
428
|
+
files.extend(_root_level_files(root, matcher))
|
|
429
|
+
for base in include:
|
|
430
|
+
base_full = os.path.join(root, base) if base not in ("", ".") else root
|
|
431
|
+
# `include` vem de tool call de um LLM cujo prompt embute documentação do
|
|
432
|
+
# repositório analisado: um valor absoluto ou com `..` faria a varredura sair do
|
|
433
|
+
# projeto e mandar conteúdo de fora pro endpoint de embeddings.
|
|
434
|
+
if not _within_root(root, base_full):
|
|
435
|
+
continue
|
|
436
|
+
if os.path.isfile(base_full):
|
|
437
|
+
rel = os.path.relpath(base_full, root).replace(os.sep, "/")
|
|
438
|
+
if not matcher(rel):
|
|
439
|
+
files.append(rel)
|
|
440
|
+
continue
|
|
441
|
+
for dirpath, dirnames, filenames in os.walk(base_full, onerror=walk_error):
|
|
442
|
+
if cancel_check:
|
|
443
|
+
cancel_check()
|
|
444
|
+
rel_dir = os.path.relpath(dirpath, root).replace(os.sep, "/")
|
|
445
|
+
rel_dir = "" if rel_dir == "." else rel_dir
|
|
446
|
+
dirnames[:] = [
|
|
447
|
+
d for d in dirnames
|
|
448
|
+
if not d.startswith(".")
|
|
449
|
+
and not matcher(f"{rel_dir}/{d}" if rel_dir else d)
|
|
450
|
+
# `os.walk` não desce em symlink de diretório, mas `os.path.islink()` é
|
|
451
|
+
# False para junction do Windows (`mklink /J`, sem admin) — e desce nela.
|
|
452
|
+
and _within_root(root, os.path.join(dirpath, d))
|
|
453
|
+
]
|
|
454
|
+
for name in filenames:
|
|
455
|
+
rel_path = f"{rel_dir}/{name}" if rel_dir else name
|
|
456
|
+
if not matcher(rel_path) and _within_root(root, os.path.join(root, rel_path)):
|
|
457
|
+
files.append(rel_path)
|
|
458
|
+
return sorted(set(files))
|
package/index_views.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Visão Git do índice. Branches isolam manifests; a raiz continua sendo o projeto."""
|
|
2
|
+
import contextlib
|
|
3
|
+
import contextvars
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
import project_identity
|
|
11
|
+
|
|
12
|
+
_PINNED = contextvars.ContextVar('smart_index_view', default=None)
|
|
13
|
+
_CACHE = {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ViewChanged(RuntimeError):
|
|
17
|
+
"""Checkout/commit durante uma leitura: não publicar resultado de outra visão."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _git_marker(root):
|
|
21
|
+
current = project_identity.display_root(root)
|
|
22
|
+
while True:
|
|
23
|
+
if os.path.lexists(os.path.join(current, '.git')):
|
|
24
|
+
return True
|
|
25
|
+
parent = os.path.dirname(current)
|
|
26
|
+
if parent == current:
|
|
27
|
+
return False
|
|
28
|
+
current = parent
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _run(root, *args, optional=False):
|
|
32
|
+
result = subprocess.run(['git', '--no-optional-locks', '-C', root, *args],
|
|
33
|
+
stdin=subprocess.DEVNULL, capture_output=True, timeout=5,
|
|
34
|
+
creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
|
|
35
|
+
if result.returncode:
|
|
36
|
+
if optional and result.returncode in (1, 128):
|
|
37
|
+
return ''
|
|
38
|
+
raise RuntimeError('Could not identify the Git view. Check access to the repository.')
|
|
39
|
+
return result.stdout.decode('utf-8', 'replace').strip()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _stamp(view):
|
|
43
|
+
paths = [os.path.join(view['git_dir'], name) for name in ('HEAD', 'index', 'logs/HEAD')]
|
|
44
|
+
paths += [os.path.join(view['common_dir'], 'packed-refs')]
|
|
45
|
+
if view.get('ref'):
|
|
46
|
+
paths.append(os.path.join(view['common_dir'], view['ref']))
|
|
47
|
+
result = []
|
|
48
|
+
for path in paths:
|
|
49
|
+
try:
|
|
50
|
+
stat = os.stat(path)
|
|
51
|
+
result.append((stat.st_mtime_ns, stat.st_size))
|
|
52
|
+
except OSError:
|
|
53
|
+
result.append(None)
|
|
54
|
+
return tuple(result)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def describe(root, fresh=False):
|
|
58
|
+
root = project_identity.display_root(root)
|
|
59
|
+
key = project_identity.canonical_root(root)
|
|
60
|
+
pinned = _PINNED.get()
|
|
61
|
+
if pinned and pinned['root'] == key and not fresh:
|
|
62
|
+
return pinned
|
|
63
|
+
old = _CACHE.get(key)
|
|
64
|
+
if not fresh and old and time.monotonic()-old[0] < 1:
|
|
65
|
+
return old[1]
|
|
66
|
+
view = {'root': key, 'git': False, 'view_id': 'workspace', 'label': 'Folder without Git',
|
|
67
|
+
'branch': None, 'commit': None}
|
|
68
|
+
if _git_marker(root):
|
|
69
|
+
if not shutil.which('git'):
|
|
70
|
+
raise RuntimeError('This folder contains Git, but the git executable is not available.')
|
|
71
|
+
paths = _run(root, 'rev-parse', '--path-format=absolute', '--show-toplevel', '--absolute-git-dir', '--git-common-dir').splitlines()
|
|
72
|
+
if len(paths) != 3:
|
|
73
|
+
raise RuntimeError('Git did not report the repository directories.')
|
|
74
|
+
view.update(git_dir=paths[1], common_dir=paths[2])
|
|
75
|
+
before = _stamp(view)
|
|
76
|
+
ref = _run(root, 'symbolic-ref', '-q', 'HEAD', optional=True)
|
|
77
|
+
view['ref'] = ref
|
|
78
|
+
ref_before = _stamp(view)
|
|
79
|
+
commit = _run(root, 'rev-parse', '--verify', 'HEAD', optional=True)
|
|
80
|
+
if not ref and not commit:
|
|
81
|
+
raise RuntimeError('Could not identify the Git HEAD.')
|
|
82
|
+
identity = ref or 'detached:' + commit
|
|
83
|
+
view.update(git=True, ref=ref, branch=ref.removeprefix('refs/heads/') if ref else None,
|
|
84
|
+
commit=commit or None, repo_root=paths[0], git_dir=paths[1], common_dir=paths[2],
|
|
85
|
+
view_id=hashlib.sha256(identity.encode()).hexdigest()[:16],
|
|
86
|
+
label=ref.removeprefix('refs/heads/') if ref else 'Detached ' + commit[:10])
|
|
87
|
+
view['stamp'] = _stamp(view)
|
|
88
|
+
if view['stamp'] != ref_before or view['stamp'][:4] != before[:4]:
|
|
89
|
+
raise ViewChanged('Git changed while its view was being identified. Try again after the checkout.')
|
|
90
|
+
_CACHE[key] = (time.monotonic(), view)
|
|
91
|
+
return view
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def storage_key(root):
|
|
95
|
+
view = describe(root)
|
|
96
|
+
key = project_identity.project_id(root)
|
|
97
|
+
return key + '.v-' + view['view_id'] if view['git'] else key
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def assert_current(view):
|
|
101
|
+
if view.get('git') and _stamp(view) != view.get('stamp'):
|
|
102
|
+
_CACHE.pop(view['root'], None)
|
|
103
|
+
raise ViewChanged('The Git view changed during the operation. The result was discarded; search again on the current branch.')
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@contextlib.contextmanager
|
|
107
|
+
def pin(root):
|
|
108
|
+
current = _PINNED.get()
|
|
109
|
+
if current and current['root'] == project_identity.canonical_root(root):
|
|
110
|
+
yield current
|
|
111
|
+
return
|
|
112
|
+
view = describe(root, fresh=True)
|
|
113
|
+
token = _PINNED.set(view)
|
|
114
|
+
try:
|
|
115
|
+
yield view
|
|
116
|
+
finally:
|
|
117
|
+
_PINNED.reset(token)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def public(view):
|
|
121
|
+
return {key: view.get(key) for key in ('git', 'view_id', 'label', 'branch', 'commit')}
|