@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.
Files changed (78) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +32 -0
  4. package/README.md +116 -2
  5. package/atomic_io.py +94 -0
  6. package/autostart.py +199 -0
  7. package/bin/smart-tool.mjs +86 -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 +353 -0
  32. package/install_runtime.py +283 -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 +33 -4
  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/fetch_camoufox.py +28 -0
  66. package/web_adapters/browser/requirements.txt +2 -0
  67. package/web_adapters/node/adapter-common.mjs +40 -0
  68. package/web_adapters/node/ddg-search.mjs +89 -0
  69. package/web_adapters/node/open-websearch-adapter.mjs +77 -0
  70. package/web_adapters/node/package-lock.json +2720 -0
  71. package/web_adapters/node/package.json +12 -0
  72. package/web_adapters/node/wikipedia-search.mjs +44 -0
  73. package/web_document_graph.py +71 -0
  74. package/web_fetch.py +242 -0
  75. package/web_search_adapters.py +193 -0
  76. package/web_search_ddgs.py +42 -0
  77. package/web_search_external.py +288 -0
  78. package/web_search_health.py +88 -0
@@ -0,0 +1,131 @@
1
+ """ReadDirectoryChangesW assíncrono: eventos nativos com parada cooperativa."""
2
+ import os
3
+ import struct
4
+ import threading
5
+
6
+
7
+ class DirectoryWatcher:
8
+ def __init__(self, root, on_events, on_error):
9
+ self.root = root
10
+ self.on_events = on_events
11
+ self.on_error = on_error
12
+ self._stop = threading.Event()
13
+ self._thread = None
14
+ self.ready = threading.Event()
15
+
16
+ def start(self):
17
+ if self._thread and self._thread.is_alive():
18
+ return
19
+ self._thread = threading.Thread(target=self._run, name="directory-events", daemon=True)
20
+ self._thread.start()
21
+
22
+ @property
23
+ def alive(self):
24
+ return bool(self._thread and self._thread.is_alive())
25
+
26
+ def stop(self):
27
+ self._stop.set()
28
+ if self._thread and self._thread is not threading.current_thread():
29
+ self._thread.join(timeout=3)
30
+
31
+ @staticmethod
32
+ def decode(buffer, length):
33
+ rows, offset = [], 0
34
+ while offset < length:
35
+ if offset + 12 > length:
36
+ raise ValueError("Incomplete directory event.")
37
+ next_offset, action, size = struct.unpack_from("<III", buffer, offset)
38
+ if size % 2 or offset + 12 + size > length:
39
+ raise ValueError("Invalid file name in event.")
40
+ name = buffer[offset + 12:offset + 12 + size].decode("utf-16-le")
41
+ rows.append({"path": name.replace("\\", "/"), "action": action})
42
+ if not next_offset:
43
+ break
44
+ if next_offset < 12 or offset + next_offset >= length:
45
+ raise ValueError("Invalid event offset.")
46
+ offset += next_offset
47
+ return rows
48
+
49
+ def _run(self):
50
+ if os.name != "nt":
51
+ self.on_error("Native events unavailable on this platform; periodic reconciliation is active.")
52
+ return
53
+ import ctypes
54
+ from ctypes import wintypes
55
+
56
+ class Overlapped(ctypes.Structure):
57
+ _fields_ = [("Internal", ctypes.c_size_t), ("InternalHigh", ctypes.c_size_t),
58
+ ("Offset", wintypes.DWORD), ("OffsetHigh", wintypes.DWORD),
59
+ ("hEvent", wintypes.HANDLE)]
60
+
61
+ kernel = ctypes.WinDLL("kernel32", use_last_error=True)
62
+ kernel.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD,
63
+ ctypes.c_void_p, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
64
+ kernel.CreateFileW.restype = wintypes.HANDLE
65
+ kernel.CreateEventW.argtypes = [ctypes.c_void_p, wintypes.BOOL, wintypes.BOOL, wintypes.LPCWSTR]
66
+ kernel.CreateEventW.restype = wintypes.HANDLE
67
+ kernel.ReadDirectoryChangesW.argtypes = [wintypes.HANDLE, ctypes.c_void_p, wintypes.DWORD,
68
+ wintypes.BOOL, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD), ctypes.POINTER(Overlapped), ctypes.c_void_p]
69
+ kernel.ReadDirectoryChangesW.restype = wintypes.BOOL
70
+ kernel.GetOverlappedResult.argtypes = [wintypes.HANDLE, ctypes.POINTER(Overlapped),
71
+ ctypes.POINTER(wintypes.DWORD), wintypes.BOOL]
72
+ kernel.GetOverlappedResult.restype = wintypes.BOOL
73
+ kernel.CancelIoEx.argtypes = [wintypes.HANDLE, ctypes.c_void_p]
74
+ kernel.CancelIoEx.restype = wintypes.BOOL
75
+ kernel.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
76
+ kernel.WaitForSingleObject.restype = wintypes.DWORD
77
+ kernel.ResetEvent.argtypes = [wintypes.HANDLE]
78
+ kernel.CloseHandle.argtypes = [wintypes.HANDLE]
79
+ handle, event, operation = None, None, None
80
+ pending = False
81
+ try:
82
+ handle = kernel.CreateFileW(self.root, 0x0001, 0x0007, None, 3,
83
+ 0x02000000 | 0x40000000, None)
84
+ if handle == ctypes.c_void_p(-1).value:
85
+ handle = None
86
+ raise OSError(ctypes.get_last_error(), "Could not watch the folder.")
87
+ event = kernel.CreateEventW(None, True, False, None)
88
+ if not event:
89
+ raise OSError(ctypes.get_last_error(), "Could not create the watch event.")
90
+ buffer = ctypes.create_string_buffer(64 * 1024)
91
+ count = wintypes.DWORD()
92
+ while not self._stop.is_set():
93
+ kernel.ResetEvent(event)
94
+ operation = Overlapped()
95
+ operation.hEvent = event
96
+ ok = kernel.ReadDirectoryChangesW(handle, buffer, len(buffer), True,
97
+ 0x0001 | 0x0002 | 0x0008 | 0x0010 | 0x0040,
98
+ None, ctypes.byref(operation), None)
99
+ if not ok and ctypes.get_last_error() != 997:
100
+ raise OSError(ctypes.get_last_error(), "Failed to receive folder changes.")
101
+ pending = True
102
+ self.ready.set()
103
+ while not self._stop.is_set():
104
+ status = kernel.WaitForSingleObject(event, 250)
105
+ if status == 0:
106
+ break
107
+ if status != 258:
108
+ raise OSError(ctypes.get_last_error(), "Failed while waiting for folder events.")
109
+ if self._stop.is_set():
110
+ break
111
+ ok = kernel.GetOverlappedResult(handle, ctypes.byref(operation), ctypes.byref(count), False)
112
+ pending = False
113
+ if not ok:
114
+ raise OSError(ctypes.get_last_error(), "Folder event interrupted.")
115
+ try:
116
+ rows = self.decode(buffer.raw, count.value) if count.value else []
117
+ except (ValueError, UnicodeError):
118
+ rows = []
119
+ self.on_events(rows, not bool(rows))
120
+ except Exception as exc:
121
+ if not self._stop.is_set():
122
+ self.on_error(f"{type(exc).__name__}: {exc}")
123
+ finally:
124
+ if handle and pending and operation is not None:
125
+ kernel.CancelIoEx(handle, ctypes.byref(operation))
126
+ count = wintypes.DWORD()
127
+ kernel.GetOverlappedResult(handle, ctypes.byref(operation), ctypes.byref(count), True)
128
+ if handle:
129
+ kernel.CloseHandle(handle)
130
+ if event:
131
+ kernel.CloseHandle(event)
@@ -0,0 +1,69 @@
1
+ """Extração local de texto DOCX, sem Office, execução de macros ou acesso externo."""
2
+ import io
3
+ import re
4
+ import zipfile
5
+ import xml.etree.ElementTree as ET
6
+
7
+ VERSION = 1
8
+ MAX_ARCHIVE_BYTES = 10 * 1024 * 1024
9
+ MAX_XML_BYTES = 8 * 1024 * 1024
10
+ MAX_TEXT_BYTES = 1024 * 1024
11
+ W = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
12
+
13
+
14
+ class DocumentError(ValueError):
15
+ pass
16
+
17
+
18
+ class _DocumentTreeBuilder(ET.TreeBuilder):
19
+ def doctype(self, name, pubid, system):
20
+ # O callback também recusa DTD em XML UTF-16, antes de expandir entidades.
21
+ raise DocumentError('DOCX contains disallowed entities.')
22
+
23
+
24
+ def extract_docx(data):
25
+ if len(data)>MAX_ARCHIVE_BYTES:
26
+ raise DocumentError('DOCX over the 10 MiB limit.')
27
+ try:
28
+ with zipfile.ZipFile(io.BytesIO(data)) as archive:
29
+ listing=archive.infolist()
30
+ entries={info.filename:info for info in listing}
31
+ if len(listing)>2000 or 'word/document.xml' not in entries:
32
+ raise DocumentError('Invalid DOCX or excessive structure.')
33
+ names=['word/document.xml']+sorted(name for name in entries if re.fullmatch(r'word/(?:header\d+|footer\d+|footnotes|endnotes)\.xml',name))
34
+ paragraphs=[];xml_bytes=0;text_bytes=0
35
+ for name in names:
36
+ info=entries[name]
37
+ if info.flag_bits&1 or info.compress_type not in (zipfile.ZIP_STORED,zipfile.ZIP_DEFLATED):
38
+ raise DocumentError('Encrypted DOCX or unsupported compression.')
39
+ if info.file_size>MAX_XML_BYTES-xml_bytes:
40
+ raise DocumentError('DOCX XML content over the limit.')
41
+ with archive.open(info) as stream:
42
+ raw=stream.read(MAX_XML_BYTES-xml_bytes+1)
43
+ xml_bytes+=len(raw)
44
+ if xml_bytes>MAX_XML_BYTES or b'<!DOCTYPE' in raw.upper() or b'<!ENTITY' in raw.upper():
45
+ raise DocumentError('DOCX contains excessive XML or disallowed entities.')
46
+ root=ET.fromstring(raw,parser=ET.XMLParser(target=_DocumentTreeBuilder()))
47
+ namespace=W if root.tag.startswith(W) else '{http://purl.oclc.org/ooxml/wordprocessingml/main}'
48
+ for paragraph in root.iter(namespace+'p'):
49
+ def parts(node):
50
+ pending=list(reversed(node))
51
+ while pending:
52
+ child=pending.pop()
53
+ if child.tag==namespace+'p':continue
54
+ if child.tag==namespace+'t':yield child.text or ''
55
+ elif child.tag==namespace+'tab':yield '\t'
56
+ elif child.tag in (namespace+'br',namespace+'cr'):yield '\n'
57
+ else:pending.extend(reversed(child))
58
+ text=''.join(parts(paragraph))
59
+ if text.strip():
60
+ text_bytes+=len(text.encode('utf-8'))+1
61
+ if text_bytes>MAX_TEXT_BYTES:raise DocumentError('Text extracted from DOCX exceeds 1 MiB.')
62
+ paragraphs.append(text)
63
+ return ('\n'.join(paragraphs)+'\n' if paragraphs else '').encode('utf-8')
64
+ except (zipfile.BadZipFile,ET.ParseError,RuntimeError,NotImplementedError,OSError) as exc:
65
+ raise DocumentError('Could not extract text from this DOCX.') from exc
66
+
67
+
68
+ def location_kind(path):
69
+ return 'extracted_text_line' if str(path).lower().endswith('.docx') else 'source_line'
package/duplicates.py ADDED
@@ -0,0 +1,313 @@
1
+ import array
2
+ import contextvars
3
+ import hashlib
4
+ import heapq
5
+ import math
6
+ import os
7
+ import re
8
+ import threading
9
+ import time
10
+
11
+ import code_graph
12
+ import embedding_cache
13
+ import index_inventory
14
+ import index_profile
15
+ import index_scope
16
+ import indexer
17
+
18
+ DEFAULT_MIN_SIMILARITY = 0.90
19
+ NEAR_JACCARD = 0.80
20
+ MIN_LINES = 5
21
+ MAX_TEXT = 3000
22
+ MAX_SEMANTIC_FUNCTIONS = 4000
23
+ SEMANTIC_DEADLINE_S = 45
24
+ EMBED_SLICE = 64
25
+ CUTOFF_NOTE = ("Default semantic cutoff 0.90: in the 2026-10-02 measurement (claude-code, independent judge) pairs above "
26
+ "0.95 were 10/10 actionable and those from 0.80 to 0.94 only 7/20; below 0.90 most are false positives "
27
+ "and reviewing them costs time without leading to a refactor.")
28
+ _HASH_COMMENT = {"py", "rb", "sh"}
29
+ _TOKEN = re.compile(r"[A-Za-z_]\w*|\d+|[^\s\w]")
30
+ _CONTAINER_KINDS = {"module", "template", "class", "interface"}
31
+ _GENERIC_NAMES = {"callback", "anonymous", "constructor"}
32
+ _WARMING = set()
33
+ _WARM_ERRORS = {}
34
+ LAST_FINDINGS = {}
35
+ MAX_REMEMBERED_FINDINGS = 20000
36
+ NOTE_MAX = 500
37
+ TYPE_BY_PREFIX = {"e": "exact", "n": "near", "s": "semantic"}
38
+ DISMISS_REASONS = {"false_positive", "intentional"}
39
+ _LOCK = threading.Lock()
40
+
41
+
42
+ def _strip_comments(text, ext):
43
+ hash_comments = ext in _HASH_COMMENT
44
+ out, i, n = [], 0, len(text)
45
+ while i < n:
46
+ ch = text[i]
47
+ if ch in "'\"`":
48
+ j = i + 1
49
+ while j < n and text[j] != ch:
50
+ j += 2 if text[j] == "\\" else 1
51
+ out.append(text[i:j + 1]); i = j + 1
52
+ elif hash_comments and ch == "#":
53
+ while i < n and text[i] != "\n":
54
+ i += 1
55
+ elif not hash_comments and text.startswith("//", i):
56
+ while i < n and text[i] != "\n":
57
+ i += 1
58
+ elif not hash_comments and text.startswith("/*", i):
59
+ end = text.find("*/", i + 2)
60
+ i = n if end < 0 else end + 2
61
+ else:
62
+ out.append(ch); i += 1
63
+ return "".join(out)
64
+
65
+
66
+ def _body(text, ext):
67
+ if ext in _HASH_COMMENT:
68
+ lines = text.splitlines()
69
+ start = next((k for k, line in enumerate(lines) if re.match(r"\s*(async\s+)?def\s", line)), 0)
70
+ end = next((k for k in range(start, len(lines)) if lines[k].rstrip().endswith(":")), start)
71
+ return "\n".join(lines[end + 1:])
72
+ brace = text.find("{")
73
+ return text[brace + 1:] if brace >= 0 else text
74
+
75
+
76
+ def _shingles(text):
77
+ tokens = ["ID" if re.match(r"[A-Za-z_]", t) else t for t in _TOKEN.findall(text)]
78
+ return frozenset(tuple(tokens[i:i + 5]) for i in range(max(len(tokens) - 4, 1)))
79
+
80
+
81
+ def _index_state(path):
82
+ stat = os.stat(path)
83
+ return stat.st_mtime_ns, stat.st_size
84
+
85
+
86
+ def _functions(root, include_tests):
87
+ inventory = index_inventory.inspect(root)
88
+ if not inventory["selected"]:
89
+ raise ValueError("Project not indexed in this view yet; run a search or index it first.")
90
+ view = inventory["selected"]
91
+ before = _index_state(view["path"])
92
+ graph = code_graph.build(root, storage_id=os.path.basename(view["path"]))
93
+ _files, sources, _diagnostics, _truncated = code_graph._snapshot(view["path"])
94
+ if _index_state(view["path"]) != before:
95
+ raise RuntimeError("The index changed during the analysis (reindex in progress); try again shortly.")
96
+ profile = index_profile.current((index_scope.load_scope(root) or {}).get("profile"))
97
+ candidates = [s for s in graph["symbols"] if s.get("kind") not in _CONTAINER_KINDS
98
+ and s["end_line"] - s["start_line"] + 1 >= MIN_LINES
99
+ and s["name"].split("(")[0].split(".")[-1] not in _GENERIC_NAMES and "@" not in s["name"]]
100
+ by_file = {}
101
+ for s in candidates:
102
+ by_file.setdefault(s["path"], []).append(s)
103
+ functions = []
104
+ for path, symbols in by_file.items():
105
+ kind = index_profile.kind(path, profile)
106
+ if kind == "doc" or (kind == "test" and not include_tests):
107
+ continue
108
+ ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
109
+ file_lines = sources.get(path, "").splitlines()
110
+ occurrences = {}
111
+ for s in sorted(symbols, key=lambda item: (item["start_line"], item["end_line"])):
112
+ if any(o is not s and o["start_line"] <= s["start_line"] and s["end_line"] <= o["end_line"]
113
+ and (o["start_line"], o["end_line"]) != (s["start_line"], s["end_line"]) for o in symbols):
114
+ continue
115
+ text = "\n".join(file_lines[s["start_line"] - 1:s["end_line"]])
116
+ if not text.strip():
117
+ continue
118
+ normalized = re.sub(r"\s+", " ", _strip_comments(_body(text, ext), ext)).strip()
119
+ functions.append({"name": s["name"].split("(")[0].split(".")[-1], "path": path, "line": s["start_line"],
120
+ "end": s["end_line"], "lines": s["end_line"] - s["start_line"] + 1, "text": text[:MAX_TEXT],
121
+ "hash": hashlib.sha1(normalized.encode("utf-8")).hexdigest(), "shingles": _shingles(normalized)})
122
+ occurrence = occurrences[functions[-1]["hash"]] = occurrences.get(functions[-1]["hash"], 0) + 1
123
+ functions[-1]["kind"] = kind
124
+ functions[-1]["fp"] = hashlib.sha1(f"{path}\0{functions[-1]['hash']}\0{occurrence}".encode("utf-8")).hexdigest()
125
+ notes = []
126
+ if inventory.get("message"):
127
+ notes.append(inventory["message"])
128
+ if graph.get("truncated"):
129
+ notes.append("Graph truncated by analysis limits; some functions were left out.")
130
+ if graph.get("retryable"):
131
+ notes.append("Partial analysis: " + "; ".join(d.get("reason", "") for d in graph.get("diagnostics", [])[:2]))
132
+ return functions, notes, view
133
+
134
+
135
+ def _ref(f):
136
+ return {"name": f["name"], "path": f["path"], "lines": f"{f['line']}-{f['end']}"}
137
+
138
+
139
+ def _finding_id(prefix, members):
140
+ production = [f for f in members if f["kind"] != "test"]
141
+ basis = production if len(production) >= 2 else members
142
+ return prefix + hashlib.sha1("|".join(sorted(f["fp"] for f in basis)).encode("utf-8")).hexdigest()[:16]
143
+
144
+
145
+ def _cache(root, view):
146
+ if not view.get("model") or not view.get("dimensions"):
147
+ raise ValueError("Index has no registered embedding model; reindex the project.")
148
+ return embedding_cache.VectorCache(root, indexer.INDEX_DIR, {"purpose": "function_duplicates", "model": view["model"],
149
+ "dimensions": view["dimensions"]})
150
+
151
+
152
+ def _cached_vectors(root, view, functions):
153
+ cache = _cache(root, view)
154
+ try:
155
+ vectors, missing = [], 0
156
+ for f in functions:
157
+ row = cache.db.execute("SELECT vector FROM vectors WHERE key=?", (cache.key(f["text"]),)).fetchone()
158
+ if row is None:
159
+ missing += 1
160
+ continue
161
+ vector = array.array("f"); vector.frombytes(row[0])
162
+ norm = math.sqrt(indexer._sumprod(vector, vector))
163
+ vectors.append(array.array("f", (x / norm for x in vector)) if norm else None)
164
+ return (vectors if not missing else None), missing
165
+ finally:
166
+ cache.close()
167
+
168
+
169
+ def _warm(root, view, functions, embed):
170
+ try:
171
+ cache = _cache(root, view)
172
+ try:
173
+ for start in range(0, len(functions), EMBED_SLICE):
174
+ cache.embed([f["text"] for f in functions[start:start + EMBED_SLICE]],
175
+ lambda texts: embed(view["model"], texts), indexer.validate_vector)
176
+ finally:
177
+ cache.close()
178
+ except Exception as exc:
179
+ with _LOCK:
180
+ _WARM_ERRORS[root] = f"{type(exc).__name__}: {exc}"[:300]
181
+ finally:
182
+ with _LOCK:
183
+ _WARMING.discard(root)
184
+
185
+
186
+ def _near(functions):
187
+ order = sorted(range(len(functions)), key=lambda i: len(functions[i]["shingles"]))
188
+ found = []
189
+ for position, a in enumerate(order):
190
+ sa = functions[a]["shingles"]
191
+ for b in order[position + 1:]:
192
+ sb = functions[b]["shingles"]
193
+ if len(sb) * NEAR_JACCARD > len(sa):
194
+ break
195
+ if functions[a]["hash"] == functions[b]["hash"]:
196
+ continue
197
+ inter = len(sa & sb)
198
+ score = inter / (len(sa) + len(sb) - inter)
199
+ if score >= NEAR_JACCARD:
200
+ found.append((score, a, b))
201
+ found.sort(reverse=True)
202
+ return found
203
+
204
+
205
+ def _semantic(root, view, functions, embed, configured_model, min_similarity, limit, lexical, skip):
206
+ if embed is None or not configured_model:
207
+ return "unavailable", [], 0, ["Semantic level unavailable: no embedding_model configured."]
208
+ if configured_model != view.get("model"):
209
+ return "unavailable", [], 0, [f"Semantic level unavailable: the configured model ({configured_model}) differs from the "
210
+ f"index model ({view.get('model')}); reindex the project to compare vectors from the same model."]
211
+ pool = sorted(range(len(functions)), key=lambda i: -functions[i]["lines"])[:MAX_SEMANTIC_FUNCTIONS]
212
+ notes = []
213
+ if len(functions) > MAX_SEMANTIC_FUNCTIONS:
214
+ notes.append(f"Semantic level limited to the {MAX_SEMANTIC_FUNCTIONS} largest of {len(functions)} functions.")
215
+ chosen = [functions[i] for i in pool]
216
+ with _LOCK:
217
+ error = _WARM_ERRORS.pop(root, None)
218
+ vectors, missing = _cached_vectors(root, view, chosen)
219
+ if vectors is None:
220
+ with _LOCK:
221
+ start = root not in _WARMING
222
+ if start:
223
+ _WARMING.add(root)
224
+ if start:
225
+ context = contextvars.copy_context()
226
+ threading.Thread(target=context.run, args=(_warm, root, view, chosen, embed), daemon=True).start()
227
+ pending = f"{missing} functions without embedding" if missing else "preparation in progress"
228
+ notes.append(f"Semantic level being prepared ({pending}); available on the next call."
229
+ + (f" The last preparation failed: {error}" if error else ""))
230
+ return "preparing", [], 0, notes
231
+ heap, total, deadline = [], 0, time.monotonic() + SEMANTIC_DEADLINE_S
232
+ for a in range(len(chosen)):
233
+ if time.monotonic() > deadline:
234
+ notes.append(f"Semantic comparison stopped at the {SEMANTIC_DEADLINE_S} s deadline; partial result.")
235
+ break
236
+ va, fa = vectors[a], chosen[a]
237
+ if va is None:
238
+ continue
239
+ for b in range(a + 1, len(chosen)):
240
+ vb, fb = vectors[b], chosen[b]
241
+ if vb is None or fa["hash"] == fb["hash"] or (pool[a], pool[b]) in lexical or (pool[b], pool[a]) in lexical:
242
+ continue
243
+ score = indexer._sumprod(va, vb)
244
+ if score >= min_similarity:
245
+ if _finding_id("s", (fa, fb)) in skip:
246
+ skip[_finding_id("s", (fa, fb))] = (score, fa, fb)
247
+ continue
248
+ total += 1
249
+ item = (score, pool[a], pool[b])
250
+ if len(heap) < limit:
251
+ heapq.heappush(heap, item)
252
+ elif item > heap[0]:
253
+ heapq.heapreplace(heap, item)
254
+ return "ready", sorted(heap, reverse=True), total, notes
255
+
256
+
257
+ def find(root, embed, configured_model, min_similarity=DEFAULT_MIN_SIMILARITY, include_tests=False, limit=30,
258
+ dismissed=None, include_dismissed=False):
259
+ started = time.monotonic()
260
+ dismissed = dismissed or {}
261
+ functions, notes, view = _functions(root, include_tests)
262
+ groups = {}
263
+ for f in functions:
264
+ groups.setdefault(f["hash"], []).append(f)
265
+ exact_all = sorted((g for g in groups.values() if len(g) > 1), key=lambda g: (-g[0]["lines"], -len(g)))
266
+ near_all = _near(functions)
267
+ lexical = {(a, b) for _s, a, b in near_all}
268
+ hidden = []
269
+ exact = []
270
+ for g in exact_all:
271
+ fid = _finding_id("e", g)
272
+ (hidden.append(("exact", fid, None, g)) if fid in dismissed else exact.append((fid, g)))
273
+ near = []
274
+ for s, a, b in near_all:
275
+ fid = _finding_id("n", (functions[a], functions[b]))
276
+ (hidden.append(("near", fid, s, (functions[a], functions[b]))) if fid in dismissed else near.append((fid, s, a, b)))
277
+ skip = {fid: None for fid in dismissed if fid.startswith("s")}
278
+ try:
279
+ status, semantic, semantic_total, semantic_notes = _semantic(root, view, functions, embed, configured_model,
280
+ min_similarity, limit, lexical, skip)
281
+ except Exception as exc:
282
+ status, semantic, semantic_total, semantic_notes = "error", [], 0, [f"Semantic level failed: {type(exc).__name__}: {exc}"[:300]]
283
+ notes += semantic_notes
284
+ if min_similarity < DEFAULT_MIN_SIMILARITY:
285
+ notes.append(f"Semantic cutoff below the default ({min_similarity:.2f}): expect many false positives.")
286
+ semantic_rows = [(_finding_id("s", (functions[a], functions[b])), s, a, b) for s, a, b in semantic]
287
+ hidden_semantic = [fid for fid, score in skip.items() if score is not None]
288
+ with _LOCK:
289
+ remembered = LAST_FINDINGS.setdefault(root, {})
290
+ remembered.update({fid: "exact" for fid, _g in exact})
291
+ remembered.update({fid: "near" for fid, *_r in near})
292
+ remembered.update({fid: "semantic" for fid, *_r in semantic_rows})
293
+ remembered.update({fid: kind for kind, fid, *_r in hidden})
294
+ remembered.update({fid: "semantic" for fid in hidden_semantic})
295
+ while len(remembered) > MAX_REMEMBERED_FINDINGS:
296
+ remembered.pop(next(iter(remembered)))
297
+ result = {
298
+ "view": view["label"], "functions_analyzed": len(functions), "include_tests": include_tests,
299
+ "min_similarity": min_similarity, "cutoff_note": CUTOFF_NOTE, "semantic_status": status, "notes": notes,
300
+ "counts": {"exact_groups": len(exact), "near_pairs": len(near), "semantic_pairs": semantic_total,
301
+ "dismissed_hidden": len(hidden) + len(hidden_semantic)},
302
+ "exact": [{"id": fid, "lines": g[0]["lines"], "copies": [_ref(f) for f in g]} for fid, g in exact[:limit]],
303
+ "near": [{"id": fid, "score": round(s, 3), "a": _ref(functions[a]), "b": _ref(functions[b])} for fid, s, a, b in near[:limit]],
304
+ "semantic": [{"id": fid, "score": round(s, 3), "a": _ref(functions[a]), "b": _ref(functions[b])} for fid, s, a, b in semantic_rows],
305
+ "elapsed_s": round(time.monotonic() - started, 1),
306
+ }
307
+ if include_dismissed:
308
+ result["dismissed"] = ([{"id": fid, "type": kind, **({"score": round(s, 3)} if s is not None else {}),
309
+ "members": [_ref(f) for f in members], **dismissed[fid]} for kind, fid, s, members in hidden]
310
+ + [{"id": fid, "type": "semantic", "score": round(skip[fid][0], 3),
311
+ "members": [_ref(skip[fid][1]), _ref(skip[fid][2])], **dismissed[fid]}
312
+ for fid in hidden_semantic])
313
+ return result
@@ -0,0 +1,99 @@
1
+ """Reúso de vetores por texto exato e contrato; não guarda texto nem faz rede."""
2
+ import array
3
+ import concurrent.futures
4
+ import contextvars
5
+ import hashlib
6
+ import json
7
+ import math
8
+ import os
9
+ import sqlite3
10
+ import time
11
+
12
+ import project_identity
13
+
14
+
15
+ class VectorCache:
16
+ def __init__(self, root, directory, contract):
17
+ os.makedirs(directory, exist_ok=True)
18
+ path = os.path.join(directory, project_identity.project_id(root) + '.vectors.sqlite3')
19
+ self.db = sqlite3.connect(path, timeout=10)
20
+ self.db.execute('PRAGMA secure_delete=ON')
21
+ self.db.execute('CREATE TABLE IF NOT EXISTS vectors(key TEXT PRIMARY KEY, vector BLOB NOT NULL, used_at REAL NOT NULL)')
22
+ self.contract = json.dumps(contract, sort_keys=True, ensure_ascii=False)
23
+ self.dimensions = contract.get('dimensions')
24
+ self.hits = self.misses = self.characters = 0
25
+
26
+ def key(self, text):
27
+ return hashlib.sha256((self.contract + '\0' + text).encode('utf-8')).hexdigest()
28
+
29
+ def put(self, text, vector):
30
+ if not self.dimensions or len(vector) != self.dimensions:
31
+ return
32
+ packed = array.array('f', vector)
33
+ if not all(math.isfinite(value) for value in packed):
34
+ raise ValueError('Embedding does not fit in float32; cache not written.')
35
+ self.db.execute('INSERT OR REPLACE INTO vectors VALUES(?,?,?)',
36
+ (self.key(text), packed.tobytes(), time.time()))
37
+
38
+ def embed(self, texts, embed_batch, validate, use_cache=True, split=None, workers=1):
39
+ """Cache lookups and writes stay on the calling thread (sqlite); only embed_batch runs in workers."""
40
+ output = [None] * len(texts)
41
+ missing = {}
42
+ for i, text in enumerate(texts):
43
+ row = self.db.execute('SELECT vector FROM vectors WHERE key=?', (self.key(text),)).fetchone() if use_cache else None
44
+ if row and self.dimensions:
45
+ vector = array.array('f'); vector.frombytes(row[0])
46
+ validate(vector, self.dimensions)
47
+ output[i] = list(vector)
48
+ self.hits += 1
49
+ else:
50
+ missing.setdefault(text, []).append(i)
51
+ if missing:
52
+ unique = list(missing)
53
+ batches = split(unique) if split else [unique]
54
+ dimensions = [self.dimensions]
55
+
56
+ def store(part, vectors):
57
+ # Each batch is cached as soon as it returns: a later failure never discards paid embeddings.
58
+ if len(vectors) != len(part):
59
+ raise ValueError('Vector count differs from the chunks sent.')
60
+ dimensions[0] = dimensions[0] or (len(vectors[0]) if vectors else None)
61
+ for vector in vectors:
62
+ validate(vector, dimensions[0])
63
+ for text, vector in zip(part, vectors):
64
+ self.put(text, vector)
65
+ positions = missing[text]
66
+ for i in positions:
67
+ output[i] = vector
68
+ self.hits += len(positions) - 1
69
+ self.misses += len(part)
70
+ self.characters += sum(map(len, part))
71
+ self.db.commit()
72
+
73
+ if workers > 1 and len(batches) > 1:
74
+ pool = concurrent.futures.ThreadPoolExecutor(min(workers, len(batches)))
75
+ futures = {pool.submit(contextvars.copy_context().run, embed_batch, part): part for part in batches}
76
+ error = None
77
+ try:
78
+ for future in concurrent.futures.as_completed(futures):
79
+ if future.cancelled():
80
+ continue
81
+ try:
82
+ store(futures[future], list(future.result()))
83
+ except Exception as exc:
84
+ if error is None:
85
+ error = exc
86
+ for other in futures:
87
+ other.cancel()
88
+ finally:
89
+ pool.shutdown(wait=True, cancel_futures=True)
90
+ if error is not None:
91
+ raise error
92
+ else:
93
+ for part in batches:
94
+ store(part, list(embed_batch(part)))
95
+ return output
96
+
97
+ def close(self):
98
+ self.db.commit()
99
+ self.db.close()