@allansantos-dev/smart-tool 0.9.0 → 0.9.2

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 (41) hide show
  1. package/CHANGELOG.md +50 -23
  2. package/NOTICE +32 -32
  3. package/README.md +117 -116
  4. package/atomic_io.py +94 -94
  5. package/bin/smart-tool.mjs +5 -1
  6. package/client_hooks.py +192 -192
  7. package/code_graph_java.mjs +49 -49
  8. package/code_graph_js.cjs +135 -135
  9. package/duplicates.py +313 -313
  10. package/embedding_cache.py +99 -99
  11. package/endpoint_sync.py +179 -179
  12. package/gateway.py +220 -220
  13. package/hook_decision.py +96 -96
  14. package/index_inventory.py +75 -75
  15. package/indexer.py +878 -878
  16. package/install.py +113 -11
  17. package/install_runtime.py +101 -35
  18. package/integration_metrics.py +82 -82
  19. package/local_embedder.py +172 -172
  20. package/model_defaults.py +70 -70
  21. package/package.json +35 -35
  22. package/project_insights_ui.py +38 -38
  23. package/project_monitor.py +204 -204
  24. package/project_store.py +220 -220
  25. package/projects_ui.py +146 -146
  26. package/requirements.txt +8 -8
  27. package/research_cache.py +171 -171
  28. package/research_metrics.py +110 -110
  29. package/router.py +582 -582
  30. package/secret_store.py +121 -121
  31. package/setup_ui.py +1011 -986
  32. package/smart_tool_daemon.py +3604 -3604
  33. package/version.py +1 -1
  34. package/web_adapters/browser/fetch_camoufox.py +69 -0
  35. package/web_adapters/node/ddg-search.mjs +89 -89
  36. package/web_adapters/node/open-websearch-adapter.mjs +77 -77
  37. package/web_adapters/node/package.json +12 -12
  38. package/web_adapters/node/wikipedia-search.mjs +44 -44
  39. package/web_search_adapters.py +193 -193
  40. package/web_search_external.py +288 -288
  41. package/web_search_health.py +88 -88
package/indexer.py CHANGED
@@ -1,878 +1,878 @@
1
- #!/usr/bin/env python3
2
- """Indexador hash-manifest do Smart Tool (stdlib only). Não faz nenhuma chamada de rede —
3
- `reindex()` recebe `embed_batch` injetado pelo chamador (o daemon, que sabe qual modelo/
4
- credencial usar). Vetores ficam como BLOB (`array` de float32), sem numpy; a busca faz
5
- similaridade de cosseno em Python puro.
6
- """
7
- import array
8
- import collections
9
- import hashlib
10
- import json
11
- import math
12
- import operator
13
- import os
14
- import paths
15
- import re
16
- import sqlite3
17
- import threading
18
- import time
19
- import uuid
20
- from pathlib import Path
21
-
22
- import atomic_io
23
- import project_identity
24
- import index_views
25
- import embedding_cache
26
- import document_text
27
-
28
- INDEX_DIR = paths.DATA_DIR
29
- CHUNK_LINES = 200
30
- CHUNK_MAX_CHARS = 8000
31
- CHUNK_OVERLAP_LINES = 12
32
- CHUNKER_VERSION = 3
33
- SCHEMA_VERSION = 2
34
- REINDEX_BATCH_MAX_CHARS = 32_000
35
- REINDEX_BATCH_MAX_ITEMS = 32
36
- # Embedding requests in flight per flush. Measured 2026-10-03 (docs/spikes/embed_parallel_spike.py, 30 batches,
37
- # 3 interleaved rounds): 1 worker 33-50 s, 2 workers 19-27 s, 4 workers 11-20 s (~2.8x), no 429 from the gateway.
38
- EMBED_WORKERS = 4
39
- # Weaviate's relativeScoreFusion (default since v1.24, DefaultAlpha 0.75). Measured 2026-10-05 on the rerank gold set
40
- # (42 code targets, docs/spikes/fusion_spike.py) against the previous RRF k=60: target in the 20 candidates 41 -> 42,
41
- # first place without rerank 31 -> 35, final MRR after rerank 0.946 -> 0.969.
42
- FUSION_ALPHA = 0.75
43
-
44
- # `read_indexable` centraliza os filtros entre o escopo e hash → chunk → embed →
45
- # sqlite. `compute_manifest`, prévia e reindexação usam a mesma leitura; arquivos
46
- # inalterados reaproveitam a decisão por stat. As regras são mecânicas de
47
- # propósito — o escopo por LLM decide *diretórios* (convenção varia por repo), mas
48
- # "isto é texto que vale embeddar" e "isto é material de credencial" não variam.
49
- MAX_INDEXABLE_BYTES = 1024 * 1024
50
- MAX_SVG_INDEXABLE_BYTES = 64 * 1024
51
- _SNIFF_BYTES = 8192
52
- # Linha média muito longa é bundle/JSON minificado: passa o sniff de binário (é ASCII),
53
- # mas viraria dezenas de chunks todos com start_line=1, inúteis como citação.
54
- MAX_AVG_LINE_CHARS = 2000
55
-
56
- _UTF16_BOMS = (b"\xff\xfe", b"\xfe\xff")
57
- _UTF8_BOM = b"\xef\xbb\xbf"
58
-
59
- # Nunca indexado: o conteúdo vai para um endpoint externo de embeddings e fica em texto
60
- # puro no sqlite3, e nenhuma das duas coisas se desfaz. Comparação sempre em minúsculas
61
- # — o sistema de arquivos do Windows não distingue caixa, e um `set` distinguiria.
62
- _SECRET_BASENAMES = frozenset({
63
- "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519", "identity", "credentials",
64
- "credentials.json", "service-account.json", "kubeconfig", "netrc", "_netrc",
65
- "npmrc", "pgpass", "htpasswd", "master.key", "secrets.json", "secrets.yaml",
66
- "secrets.yml", "local.settings.json",
67
- })
68
- _SECRET_EXTS = frozenset({
69
- ".pem", ".key", ".pfx", ".p12", ".jks", ".keystore", ".ppk", ".kdbx", ".asc",
70
- ".gpg", ".env", ".tfvars", ".har",
71
- })
72
- # Sobrevive a renome — é a única regra que não depende de adivinhar convenção alheia.
73
- # Só vale no cabeçalho: arquivo de credencial ABRE com o marcador, enquanto código que
74
- # trata desses formatos (este módulo, um scanner de segredo, um teste) menciona o mesmo
75
- # literal no meio — e recusar esse código seria tirar fonte legítima do índice.
76
- _SECRET_CONTENT_MARKERS = (
77
- b"PRIVATE KEY-----", b"PuTTY-User-Key-File", b"aws_secret_access_key",
78
- )
79
- _SECRET_MARKER_WINDOW = 512
80
-
81
- SKIP_DOTFILE = "dotfile"
82
- SKIP_SECRET = "credential"
83
- SKIP_TOO_LARGE = "over size limit"
84
- SKIP_GRAPHIC = "large svg graphic"
85
- SKIP_BINARY = "binary"
86
- SKIP_MINIFIED = "minified"
87
- SKIP_UNREADABLE = "unreadable"
88
-
89
-
90
- LEXICAL_MODEL = "lexical"
91
-
92
- def _secret_name(name):
93
- if name in _SECRET_BASENAMES:
94
- return True
95
- if os.path.splitext(name)[1] in _SECRET_EXTS:
96
- return True
97
- # `local.env`/`prod.env` caem na extensão; `env`/`env.local` não têm extensão nenhuma.
98
- return name == "env" or name.startswith("env.")
99
-
100
-
101
- def _text_encoding(prefix):
102
- """Encoding de leitura deduzido do BOM. UTF-16 é o default de `Out-File` no
103
- PowerShell 5.1: decodificar como UTF-8 entregaria texto intercalado de `\\x00`."""
104
- if prefix.startswith(_UTF16_BOMS):
105
- return "utf-16"
106
- if prefix.startswith(_UTF8_BOM):
107
- return "utf-8-sig"
108
- return "utf-8"
109
-
110
-
111
- def read_indexable(root, rel_path):
112
- """`(bytes, encoding)` do arquivo, ou `(None, motivo)` quando ele não pode ser
113
- indexado. Uma única leitura serve o hash do manifest e o chunking; arquivo grande e
114
- binário são recusados antes de qualquer `read` integral."""
115
- full = os.path.join(root, rel_path)
116
- if not project_identity.within_root(root, full):
117
- return None, SKIP_SECRET
118
- parts = str(rel_path).replace("\\", "/").split("/")
119
- if any(part.startswith(".") for part in parts if part not in (".", "..")):
120
- return None, SKIP_DOTFILE
121
- name = os.path.basename(rel_path).lower()
122
- # Nome iniciado por ponto: mesma regra que `index_scope` aplica a diretório, e é
123
- # onde vive metade dos arquivos de credencial (.env, .npmrc, .netrc).
124
- if name.startswith("."):
125
- return None, SKIP_DOTFILE
126
- if _secret_name(name):
127
- return None, SKIP_SECRET
128
- try:
129
- size = os.path.getsize(full)
130
- if name.endswith(".svg") and size > MAX_SVG_INDEXABLE_BYTES:
131
- return None, SKIP_GRAPHIC
132
- limit = document_text.MAX_ARCHIVE_BYTES if name.endswith('.docx') else MAX_INDEXABLE_BYTES
133
- if size > limit:
134
- return None, SKIP_TOO_LARGE
135
- with open(full, "rb") as f:
136
- data = f.read(limit + 1)
137
- except OSError:
138
- return None, SKIP_UNREADABLE
139
- if len(data) > limit:
140
- return None, SKIP_TOO_LARGE
141
- if name.endswith('.docx'):
142
- try:
143
- data = document_text.extract_docx(data)
144
- except document_text.DocumentError as exc:
145
- return None, str(exc)
146
- prefix = data[:_SNIFF_BYTES]
147
- encoding = _text_encoding(prefix)
148
- if encoding == "utf-16":
149
- try:
150
- prefix = data.decode("utf-16", "ignore")[:_SNIFF_BYTES].encode("utf-8")
151
- except (UnicodeDecodeError, UnicodeEncodeError):
152
- return None, SKIP_BINARY
153
- elif b"\0" in prefix:
154
- return None, SKIP_BINARY
155
- header = prefix[:_SECRET_MARKER_WINDOW]
156
- if any(marker in header for marker in _SECRET_CONTENT_MARKERS):
157
- return None, SKIP_SECRET
158
- newlines = prefix.count(b"\n")
159
- if newlines == 0 and len(prefix) >= _SNIFF_BYTES:
160
- return None, SKIP_MINIFIED
161
- if newlines and len(prefix) / (newlines + 1) > MAX_AVG_LINE_CHARS:
162
- return None, SKIP_MINIFIED
163
- return data, encoding
164
-
165
-
166
- def is_indexable(root, rel_path):
167
- return read_indexable(root, rel_path)[0] is not None
168
-
169
-
170
- def _project_hash(root):
171
- return index_views.storage_key(root)
172
-
173
-
174
- def db_path(root):
175
- os.makedirs(INDEX_DIR, exist_ok=True)
176
- return os.path.join(INDEX_DIR, _project_hash(root) + ".sqlite3")
177
-
178
-
179
- _MIGRATION_LOCK = threading.RLock()
180
- _VECTOR_CACHE_LOCK = threading.RLock()
181
- _VECTOR_CACHE = collections.OrderedDict()
182
- VECTOR_CACHE_MAX_BYTES = 64 * 1024 * 1024
183
- VECTOR_CACHE_MAX_PROJECTS = 3
184
-
185
-
186
- class IndexCompatibilityError(RuntimeError):
187
- """Modelo/dimensão do índice não correspondem à consulta."""
188
-
189
-
190
- class IndexVectorError(RuntimeError):
191
- """Resposta de embeddings fora do contrato; lote não pode ser confirmado."""
192
-
193
-
194
- def existing_db_path(root):
195
- canonical = os.path.join(INDEX_DIR, _project_hash(root) + ".sqlite3")
196
- if os.path.isfile(canonical):
197
- return canonical
198
- if index_views.describe(root).get('git'):
199
- return None # Um banco sem visão conhecida não pode responder por uma branch.
200
- for key in project_identity.legacy_ids(root):
201
- old = os.path.join(INDEX_DIR, key + ".sqlite3")
202
- if os.path.isfile(old):
203
- return old
204
- return None
205
-
206
-
207
- def _readonly(path):
208
- return sqlite3.connect(Path(path).absolute().as_uri() + "?mode=ro", uri=True, timeout=10)
209
-
210
-
211
- def _backup(source, target):
212
- target = project_identity.state_path(target, INDEX_DIR)
213
- temp = atomic_io.tmp_path_for(target)
214
- src, dst = _readonly(source), sqlite3.connect(temp)
215
- try:
216
- src.backup(dst)
217
- dst.commit()
218
- finally:
219
- src.close()
220
- dst.close()
221
- atomic_io.atomic_replace(temp, target)
222
-
223
-
224
- def migrate_legacy(root):
225
- """Cópia consistente para a chave canônica; o banco legado permanece intacto."""
226
- target = db_path(root)
227
- with _MIGRATION_LOCK:
228
- if not os.path.isfile(target):
229
- old = existing_db_path(root)
230
- if not old and index_views.describe(root).get('git'):
231
- old = next((os.path.join(INDEX_DIR, key + '.sqlite3') for key in
232
- [project_identity.project_id(root), *project_identity.legacy_ids(root)]
233
- if os.path.isfile(os.path.join(INDEX_DIR, key + '.sqlite3'))), None)
234
- if old:
235
- _backup(old, target)
236
- return target
237
-
238
-
239
- def _open_path(path):
240
- conn = sqlite3.connect(path, timeout=10)
241
- # `chunks.text` é conteúdo de arquivo em texto puro: sem isto, um DELETE só desliga a
242
- # página e o texto continua legível nas free pages do arquivo até um VACUUM.
243
- conn.execute("PRAGMA secure_delete=ON")
244
- conn.execute(
245
- "CREATE TABLE IF NOT EXISTS manifest (path TEXT PRIMARY KEY, hash TEXT NOT NULL, "
246
- "mtime_ns INTEGER NOT NULL DEFAULT 0, size INTEGER NOT NULL DEFAULT -1)"
247
- )
248
- conn.execute(
249
- "CREATE TABLE IF NOT EXISTS chunks ("
250
- "id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT NOT NULL, "
251
- "start_line INTEGER NOT NULL, end_line INTEGER NOT NULL, "
252
- "text TEXT NOT NULL, embedding BLOB)"
253
- )
254
- conn.execute("CREATE INDEX IF NOT EXISTS idx_chunks_path ON chunks(path)")
255
- conn.execute("CREATE TABLE IF NOT EXISTS file_state(path TEXT PRIMARY KEY, mtime_ns INTEGER NOT NULL, size INTEGER NOT NULL, reason TEXT NOT NULL)")
256
- columns = {row[1] for row in conn.execute("PRAGMA table_info(manifest)")}
257
- for column, sql_type, default in (("mtime_ns", "INTEGER", "0"), ("size", "INTEGER", "-1")):
258
- if column not in columns:
259
- conn.execute(f"ALTER TABLE manifest ADD COLUMN {column} {sql_type} NOT NULL DEFAULT {default}")
260
- conn.execute("CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
261
- try:
262
- existed = conn.execute("SELECT 1 FROM sqlite_master WHERE name='chunks_fts'").fetchone()
263
- conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(path, text, content='chunks', content_rowid='id')")
264
- conn.executescript("""
265
- CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN
266
- INSERT INTO chunks_fts(rowid,path,text) VALUES(new.id,new.path,new.text); END;
267
- CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN
268
- INSERT INTO chunks_fts(chunks_fts,rowid,path,text) VALUES('delete',old.id,old.path,old.text); END;
269
- CREATE TRIGGER IF NOT EXISTS chunks_au AFTER UPDATE ON chunks BEGIN
270
- INSERT INTO chunks_fts(chunks_fts,rowid,path,text) VALUES('delete',old.id,old.path,old.text);
271
- INSERT INTO chunks_fts(rowid,path,text) VALUES(new.id,new.path,new.text); END;
272
- """)
273
- if not existed:
274
- conn.execute("INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild')")
275
- _set_metadata(conn, {"fts": True})
276
- except sqlite3.OperationalError:
277
- _set_metadata(conn, {"fts": False})
278
- conn.commit()
279
- return conn
280
-
281
-
282
- def open_db(root):
283
- return _open_path(migrate_legacy(root))
284
-
285
-
286
- def _metadata(conn):
287
- try:
288
- return {key: json.loads(value) for key, value in conn.execute("SELECT key,value FROM metadata")}
289
- except (sqlite3.Error, ValueError):
290
- return {}
291
-
292
-
293
- def _set_metadata(conn, values):
294
- conn.executemany("INSERT INTO metadata(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value WHERE metadata.value != excluded.value",
295
- [(key, json.dumps(value, ensure_ascii=False)) for key, value in values.items()])
296
-
297
-
298
- def info(root):
299
- path = existing_db_path(root)
300
- if not path:
301
- return {"exists": False, "path": os.path.join(INDEX_DIR, _project_hash(root) + ".sqlite3")}
302
- try:
303
- conn = _readonly(path)
304
- try:
305
- meta = _metadata(conn)
306
- return {**meta, "exists": True, "path": path, "bytes": os.path.getsize(path),
307
- "files": conn.execute("SELECT count(*) FROM manifest").fetchone()[0],
308
- "chunks": conn.execute("SELECT count(*) FROM chunks").fetchone()[0],
309
- "legacy": not bool(meta.get("model_id"))}
310
- finally:
311
- conn.close()
312
- except (OSError, sqlite3.Error) as exc:
313
- return {"exists": True, "path": path, "error": type(exc).__name__, "legacy": True}
314
-
315
-
316
- def manifest_snapshot(root):
317
- path = existing_db_path(root)
318
- if not path:
319
- return {}
320
- conn = _readonly(path)
321
- try:
322
- if conn.execute("SELECT 1 FROM sqlite_master WHERE name='file_state'").fetchone():
323
- return {rel: (mtime, size) for rel, mtime, size in conn.execute("SELECT path,mtime_ns,size FROM file_state")}
324
- return {rel: (mtime, size) for rel, mtime, size in conn.execute("SELECT path,mtime_ns,size FROM manifest")}
325
- except sqlite3.Error:
326
- return {}
327
- finally:
328
- conn.close()
329
-
330
-
331
- def compute_manifest(root, included_paths):
332
- """`(manifest, contagem de descartes por motivo)`. O manifest é a fonte de verdade do
333
- cálculo completo de hashes. A reindexação usa `read_indexable` com os mesmos gates
334
- ao detectar alteração por stat/evento; arquivos que deixam de se qualificar saem
335
- do manifest e têm os chunks removidos."""
336
- manifest = {}
337
- skipped = {}
338
- for rel in included_paths:
339
- data, reason = read_indexable(root, rel)
340
- if data is None:
341
- skipped[reason] = skipped.get(reason, 0) + 1
342
- continue
343
- manifest[rel] = hashlib.sha256(data).hexdigest()
344
- return manifest, skipped
345
-
346
-
347
- def load_manifest(conn):
348
- return dict(conn.execute("SELECT path, hash FROM manifest").fetchall())
349
-
350
-
351
- def diff_manifest(old, new):
352
- added = [p for p in new if p not in old]
353
- changed = [p for p in new if p in old and old[p] != new[p]]
354
- removed = [p for p in old if p not in new]
355
- return added, changed, removed
356
-
357
-
358
- def _split_oversized(start_line, end_line, text, max_chars):
359
- return [
360
- (start_line, end_line, text[i:i + max_chars])
361
- for i in range(0, len(text), max_chars)
362
- ]
363
-
364
-
365
- def chunk_file(root, rel_path, max_lines=CHUNK_LINES, max_chars=CHUNK_MAX_CHARS, data=None, encoding=None):
366
- """`data`/`encoding` já resolvidos por `read_indexable` evitam reler o arquivo. Sem
367
- eles, relê e redecide o encoding — e uma leitura que falhe devolve `None`, distinto
368
- de `[]` (arquivo legítimo sem conteúdo), porque quem chama não pode gravar hash novo
369
- de um conteúdo que não conseguiu ler."""
370
- if data is None:
371
- data, encoding = read_indexable(root, rel_path)
372
- if data is None:
373
- return None
374
- try:
375
- text = data.decode(encoding or "utf-8", "ignore")
376
- except (LookupError, UnicodeDecodeError):
377
- return None
378
- lines = text.splitlines(keepends=True)
379
- if max_lines < 1 or max_chars < 1:
380
- raise ValueError("Chunk limits must be positive.")
381
- boundary = re.compile(r"^(?:\s*(?:async\s+)?(?:def|class|function)\s|export\s|#{1,6}\s)")
382
- chunks, start = [], 0
383
- while start < len(lines):
384
- if len(lines[start]) > max_chars:
385
- chunks.extend(_split_oversized(start + 1, start + 1, lines[start], max_chars))
386
- start += 1
387
- continue
388
- end, chars = start, 0
389
- while end < len(lines) and end - start < max_lines and chars + len(lines[end]) <= max_chars:
390
- chars += len(lines[end])
391
- end += 1
392
- # Prefere terminar antes de uma nova função/seção, mantendo os limites reais
393
- # de linha em cada fragmento. Uma pequena sobreposição preserva contexto.
394
- if end < len(lines) and end - start > 40:
395
- breaks = [i for i in range(start + 30, end) if boundary.match(lines[i])]
396
- if breaks:
397
- end = breaks[-1]
398
- chunks.append((start + 1, end, "".join(lines[start:end])))
399
- if end == len(lines):
400
- break
401
- overlap = min(CHUNK_OVERLAP_LINES, (end - start) // 5)
402
- start = max(start + 1, end - overlap)
403
- return chunks
404
-
405
-
406
- def _pack_vector(vector):
407
- validate_vector(vector)
408
- try:
409
- packed = array.array("f", vector)
410
- except (OverflowError, TypeError, ValueError) as exc:
411
- raise IndexVectorError("Embedding does not fit in float32.") from exc
412
- if not all(math.isfinite(value) for value in packed):
413
- raise IndexVectorError("Embedding contains a non-finite float32 value.")
414
- return packed.tobytes()
415
-
416
-
417
- def _unpack_vector(blob):
418
- arr = array.array("f")
419
- arr.frombytes(blob)
420
- return arr
421
-
422
-
423
- _sumprod = getattr(math, "sumprod", None) or (lambda a, b: sum(map(operator.mul, a, b)))
424
-
425
-
426
- def cosine_similarity(a, b):
427
- if len(a) != len(b):
428
- raise IndexCompatibilityError(f"Incompatible dimensions: query={len(a)}, index={len(b)}.")
429
- norm_a = math.sqrt(_sumprod(a, a))
430
- norm_b = math.sqrt(_sumprod(b, b))
431
- if norm_a == 0 or norm_b == 0:
432
- return 0.0
433
- return _sumprod(a, b) / (norm_a * norm_b)
434
-
435
-
436
- def validate_vector(vector, dimensions=None):
437
- if not isinstance(vector, (list, tuple, array.array)) or not vector:
438
- raise IndexVectorError("Embedding must be a non-empty numeric vector.")
439
- if dimensions not in (None, 0) and len(vector) != dimensions:
440
- raise IndexCompatibilityError(f"Embedding has {len(vector)} dimensions; expected {dimensions}.")
441
- if any(isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value)
442
- for value in vector):
443
- raise IndexVectorError("Embedding contains an invalid or non-finite value.")
444
- return len(vector)
445
-
446
-
447
- def replace_file_chunks(conn, rel_path, chunks_with_vectors):
448
- conn.execute("DELETE FROM chunks WHERE path = ?", (rel_path,))
449
- conn.executemany(
450
- "INSERT INTO chunks (path, start_line, end_line, text, embedding) VALUES (?, ?, ?, ?, ?)",
451
- [
452
- (rel_path, start, end, text, None if vector is None else _pack_vector(vector))
453
- for (start, end, text), vector in chunks_with_vectors
454
- ],
455
- )
456
-
457
-
458
- def remove_file(conn, rel_path):
459
- conn.execute("DELETE FROM chunks WHERE path = ?", (rel_path,))
460
- conn.execute("DELETE FROM manifest WHERE path = ?", (rel_path,))
461
-
462
-
463
- def _file_state(conn, rel_path, stat, reason="indexed"):
464
- conn.execute("INSERT INTO file_state VALUES(?,?,?,?) ON CONFLICT(path) DO UPDATE SET "
465
- "mtime_ns=excluded.mtime_ns,size=excluded.size,reason=excluded.reason",
466
- (rel_path, *stat, reason))
467
-
468
-
469
- def upsert_manifest_entry(conn, rel_path, file_hash, mtime_ns=0, size=-1):
470
- conn.execute(
471
- "INSERT INTO manifest (path, hash, mtime_ns, size) VALUES (?, ?, ?, ?) "
472
- "ON CONFLICT(path) DO UPDATE SET hash=excluded.hash, mtime_ns=excluded.mtime_ns, size=excluded.size",
473
- (rel_path, file_hash, mtime_ns, size),
474
- )
475
- _file_state(conn, rel_path, (mtime_ns, size))
476
-
477
-
478
- def _stat_candidate(root, rel):
479
- full = os.path.join(root, rel)
480
- name = os.path.basename(rel).lower()
481
- if not project_identity.within_root(root, full):
482
- return None, SKIP_SECRET
483
- if any(part.startswith(".") for part in str(rel).replace("\\", "/").split("/")
484
- if part not in (".", "..")):
485
- return None, SKIP_DOTFILE
486
- if _secret_name(name):
487
- return None, SKIP_SECRET
488
- try:
489
- stat = os.stat(full)
490
- if not os.path.isfile(full):
491
- return None, SKIP_UNREADABLE
492
- except OSError:
493
- return None, SKIP_UNREADABLE
494
- if stat.st_size > (document_text.MAX_ARCHIVE_BYTES if name.endswith('.docx') else MAX_INDEXABLE_BYTES):
495
- return None, SKIP_TOO_LARGE
496
- if name.endswith(".svg") and stat.st_size > MAX_SVG_INDEXABLE_BYTES:
497
- return None, SKIP_GRAPHIC
498
- return (stat.st_mtime_ns, stat.st_size), None
499
-
500
-
501
- def _stable_read(root, rel):
502
- for _ in range(2):
503
- before, reason = _stat_candidate(root, rel)
504
- if before is None:
505
- return None, reason, None
506
- data, encoding = read_indexable(root, rel)
507
- if data is None:
508
- return None, encoding, before
509
- after, _ = _stat_candidate(root, rel)
510
- if after == before and (str(rel).lower().endswith('.docx') or len(data) == before[1]):
511
- return data, encoding, before
512
- return None, "file changed while reading", None
513
-
514
-
515
- def invalidate_cache(root):
516
- with _VECTOR_CACHE_LOCK:
517
- for key in list(_VECTOR_CACHE):
518
- if key[0] == project_identity.canonical_root(root):
519
- _VECTOR_CACHE.pop(key, None)
520
-
521
-
522
- def reindex(root, included_paths, embed_batch, *args, **kwargs):
523
- with index_views.pin(root) as view:
524
- return _reindex(root, included_paths, embed_batch, *args, **kwargs)
525
-
526
-
527
- def _split_batches(texts, max_items, max_chars):
528
- batches, batch, size = [], [], 0
529
- for text in texts:
530
- if batch and (len(batch) >= max_items or size + len(text) > max_chars):
531
- batches.append(batch)
532
- batch, size = [], 0
533
- batch.append(text)
534
- size += len(text)
535
- if batch:
536
- batches.append(batch)
537
- return batches
538
-
539
-
540
- def _reindex(root, included_paths, embed_batch, chunk_lines=CHUNK_LINES, on_progress=None,
541
- batch_max_chars=REINDEX_BATCH_MAX_CHARS, batch_max_items=REINDEX_BATCH_MAX_ITEMS,
542
- model_id=None, expected_dimensions=None, force_rebuild=False, full_check=False,
543
- changed_paths=None, cancel_check=None):
544
- """Atualiza snapshots por arquivo; um modelo novo só substitui a geração após sucesso."""
545
- if batch_max_chars < 1 or batch_max_items < 1:
546
- raise ValueError("Indexing batch limits must be positive.")
547
- view = index_views.describe(root)
548
- def check():
549
- if cancel_check:
550
- cancel_check()
551
- index_views.assert_current(view)
552
- root = project_identity.display_root(root)
553
- model_id = model_id or "unspecified"
554
- target = migrate_legacy(root)
555
- current = info(root)
556
- # Checkout e commit exigem confronto de conteúdo, mesmo com timestamps iguais.
557
- if view.get('git') and (current.get('git_commit') != view.get('commit') or
558
- current.get('view_id') != view.get('view_id')):
559
- full_check = True
560
- compatible = (current.get("model_id") == model_id and
561
- current.get("chunker_version") == CHUNKER_VERSION and
562
- current.get("schema_version") == SCHEMA_VERSION and
563
- current.get("chunk_lines") == chunk_lines and
564
- (expected_dimensions is None or current.get("dimensions") in (0, expected_dimensions)))
565
- building = force_rebuild or not compatible
566
- signature = hashlib.sha256(json.dumps([model_id, expected_dimensions, CHUNKER_VERSION,
567
- SCHEMA_VERSION, chunk_lines]).encode()).hexdigest()[:12]
568
- work_path = target[:-8] + f".building-{signature}.sqlite3" if building else target
569
- project_identity.state_path(work_path, INDEX_DIR)
570
- conn = _open_path(work_path)
571
- stats = {"added": 0, "changed": 0, "removed": 0, "failed": 0,
572
- "unchanged": 0, "skipped": {}, "rebuilt": building, "ready": False}
573
- cache = None
574
- try:
575
- meta = _metadata(conn)
576
- if meta.get("model_id") not in (None, model_id):
577
- raise IndexCompatibilityError("Temporary generation belongs to another model.")
578
- dimensions = meta.get("dimensions") or expected_dimensions or 0
579
- contract = {'model': model_id, 'dimensions': dimensions, 'chunker': CHUNKER_VERSION,
580
- 'chunk_lines': chunk_lines, 'preprocessing': 1}
581
- cache = embedding_cache.VectorCache(root, INDEX_DIR, contract)
582
- # Alimenta o cache com o banco existente somente quando o contrato é conhecido.
583
- if compatible and dimensions and not meta.get('vector_cache_seeded'):
584
- for text, blob in conn.execute('SELECT text,embedding FROM chunks WHERE embedding IS NOT NULL'):
585
- vector = _unpack_vector(blob)
586
- validate_vector(vector, dimensions)
587
- cache.put(text, vector)
588
- cache.db.commit()
589
- _set_metadata(conn, {'vector_cache_seeded': True})
590
- generation = meta.get("generation") or uuid.uuid4().hex
591
- _set_metadata(conn, {"root": project_identity.canonical_root(root), "model_id": model_id,
592
- "dimensions": dimensions, "schema_version": SCHEMA_VERSION,
593
- "chunker_version": CHUNKER_VERSION, "chunk_lines": chunk_lines,
594
- "generation": generation})
595
- conn.commit()
596
- old = {r[0]: (r[1], r[2], r[3]) for r in conn.execute("SELECT path,hash,mtime_ns,size FROM manifest")}
597
- old_state = {r[0]: (r[1], r[2]) for r in conn.execute("SELECT path,mtime_ns,size FROM file_state")}
598
- observed, candidates = {}, []
599
- forced = {str(p).replace("\\", "/").casefold().strip("/") for p in (changed_paths or [])}
600
- for rel in sorted(set(included_paths)):
601
- check()
602
- stat, reason = _stat_candidate(root, rel)
603
- if stat is None:
604
- stats["skipped"][reason] = stats["skipped"].get(reason, 0) + 1
605
- continue
606
- observed[rel] = stat
607
- force_file = any(rel.casefold() == p or rel.casefold().startswith(p + "/") for p in forced)
608
- extractor_changed = rel.lower().endswith('.docx') and meta.get('document_extractor_version') != document_text.VERSION
609
- if (full_check or building or force_file or extractor_changed or old_state.get(rel) != stat):
610
- candidates.append(rel)
611
- else:
612
- stats["unchanged"] += 1
613
- for rel in set(old) - set(observed):
614
- remove_file(conn, rel)
615
- stats["removed"] += 1
616
- for rel in set(old_state) - set(observed):
617
- conn.execute("DELETE FROM file_state WHERE path=?", (rel,))
618
- conn.commit()
619
- processed, total = 0, len(candidates)
620
- if on_progress:
621
- on_progress(0, total)
622
- pending, texts, chars = [], [], 0
623
-
624
- def progress():
625
- if on_progress:
626
- on_progress(processed, total)
627
-
628
- def flush():
629
- nonlocal pending, texts, chars, processed, dimensions
630
- if not pending:
631
- return
632
- if embed_batch is None:
633
- vectors = [None] * len(texts)
634
- else:
635
- def guarded(part):
636
- check()
637
- return embed_batch(part)
638
-
639
- check()
640
- vectors = cache.embed(texts, guarded, validate_vector, use_cache=not force_rebuild,
641
- split=lambda items: _split_batches(items, batch_max_items, batch_max_chars),
642
- workers=EMBED_WORKERS)
643
- check()
644
- if len(vectors) != len(texts):
645
- raise IndexVectorError("Embedding count differs from the batch sent.")
646
- for vector in vectors:
647
- dimension = validate_vector(vector, dimensions)
648
- if not dimensions:
649
- dimensions = dimension
650
- offset = 0
651
- for rel, chunks, digest, stat in pending:
652
- check()
653
- count = len(chunks)
654
- replace_file_chunks(conn, rel, zip(chunks, vectors[offset:offset + count]))
655
- upsert_manifest_entry(conn, rel, digest, *stat)
656
- _set_metadata(conn, {"dimensions": dimensions, "updated_at": time.time()})
657
- conn.commit()
658
- offset += count
659
- processed += 1
660
- progress()
661
- pending, texts, chars = [], [], 0
662
-
663
- for rel in candidates:
664
- check()
665
- data, encoding, stat = _stable_read(root, rel)
666
- if data is None:
667
- flush()
668
- stats["skipped"][encoding] = stats["skipped"].get(encoding, 0) + 1
669
- if encoding in (SKIP_UNREADABLE, "file changed while reading"):
670
- stats["failed"] += 1
671
- elif stat is not None:
672
- _file_state(conn, rel, stat, encoding)
673
- if rel in old:
674
- remove_file(conn, rel)
675
- conn.commit()
676
- processed += 1
677
- progress()
678
- continue
679
- digest = hashlib.sha256(data).hexdigest()
680
- if rel in old and digest == old[rel][0]:
681
- upsert_manifest_entry(conn, rel, digest, *stat)
682
- conn.commit()
683
- stats["unchanged"] += 1
684
- processed += 1
685
- progress()
686
- continue
687
- chunks = chunk_file(root, rel, chunk_lines, data=data, encoding=encoding)
688
- stats["changed" if rel in old else "added"] += 1
689
- count_chars = sum(len(chunk[2]) for chunk in chunks)
690
- if pending and (len(texts) + len(chunks) > batch_max_items * EMBED_WORKERS
691
- or chars + count_chars > batch_max_chars * EMBED_WORKERS):
692
- flush()
693
- pending.append((rel, chunks, digest, stat))
694
- texts.extend(chunk[2] for chunk in chunks)
695
- chars += count_chars
696
- if len(texts) >= batch_max_items * EMBED_WORKERS or chars >= batch_max_chars * EMBED_WORKERS:
697
- flush()
698
- flush()
699
- check()
700
- if full_check or building:
701
- _set_metadata(conn, {"full_scan_at": time.time()})
702
- _set_metadata(conn, {"dimensions": dimensions})
703
- _set_metadata(conn, {'document_extractor_version':document_text.VERSION})
704
- _set_metadata(conn, {'view_id': view['view_id'], 'git_branch': view.get('branch'),
705
- 'git_commit': view.get('commit'), 'view_label': view['label']})
706
- if building or stats["added"] or stats["changed"] or stats["removed"]:
707
- _set_metadata(conn, {"updated_at": time.time()})
708
- conn.commit()
709
- stats.update(generation=generation, dimensions=dimensions,
710
- embedded_chunks=cache.misses, reused_chunks=cache.hits,
711
- embedded_characters=cache.characters, view=index_views.public(view),
712
- files=conn.execute("SELECT count(*) FROM manifest").fetchone()[0],
713
- chunks=conn.execute("SELECT count(*) FROM chunks").fetchone()[0])
714
- except BaseException:
715
- conn.rollback()
716
- raise
717
- finally:
718
- conn.close()
719
- if cache:
720
- cache.close()
721
- check()
722
- if building and not stats["failed"]:
723
- if os.path.isfile(target):
724
- _backup(target, target[:-8] + ".previous.sqlite3")
725
- atomic_io.atomic_replace(work_path, project_identity.state_path(target, INDEX_DIR))
726
- stats["ready"] = not bool(stats["failed"])
727
- stats["path"] = target if stats["ready"] else work_path
728
- if building or total or stats["removed"] or full_check:
729
- invalidate_cache(root)
730
- return stats
731
-
732
-
733
- def _vector_rows(root, path):
734
- stat = os.stat(path)
735
- key = (project_identity.canonical_root(root), path, stat.st_mtime_ns, stat.st_size)
736
- with _VECTOR_CACHE_LOCK:
737
- cached = _VECTOR_CACHE.get(key)
738
- if cached is not None:
739
- _VECTOR_CACHE.move_to_end(key)
740
- return cached[0], cached[1], cached[3]
741
- conn = _readonly(path)
742
- try:
743
- meta = _metadata(conn)
744
- raw = conn.execute("SELECT id,path,start_line,end_line,text,embedding FROM chunks").fetchall()
745
- finally:
746
- conn.close()
747
- rows, norms, byte_count = [], {}, 0
748
- for chunk_id, file_path, start, end, text, blob in raw:
749
- vector = _unpack_vector(blob) if blob else array.array("f")
750
- if vector:
751
- validate_vector(vector, meta.get("dimensions"))
752
- norms[chunk_id] = math.sqrt(_sumprod(vector, vector))
753
- rows.append((chunk_id, file_path, start, end, text, vector))
754
- byte_count += len(text.encode("utf-8")) + len(blob or b"") + 256
755
- after = os.stat(path)
756
- if byte_count <= VECTOR_CACHE_MAX_BYTES and (stat.st_mtime_ns, stat.st_size) == (after.st_mtime_ns, after.st_size):
757
- with _VECTOR_CACHE_LOCK:
758
- invalidate_cache(root)
759
- _VECTOR_CACHE[key] = (rows, meta, byte_count, norms)
760
- while (len(_VECTOR_CACHE) > VECTOR_CACHE_MAX_PROJECTS or
761
- sum(item[2] for item in _VECTOR_CACHE.values()) > VECTOR_CACHE_MAX_BYTES):
762
- _VECTOR_CACHE.popitem(last=False)
763
- return rows, meta, norms
764
-
765
-
766
- def _lexical_scored(path, query, rows, limit, offset=0):
767
- """(chunk_id, score) best first; higher score is a better lexical match."""
768
- terms = list(dict.fromkeys(re.findall(r"[\w]{2,}", query or "", re.UNICODE)))[:24]
769
- if not terms:
770
- return []
771
- expression = " OR ".join('"' + term.replace('"', '""') + '"' for term in terms)
772
- conn = _readonly(path)
773
- try:
774
- return [(row[0], -row[1]) for row in conn.execute(
775
- "SELECT rowid, bm25(chunks_fts,3.0,1.0) FROM chunks_fts WHERE chunks_fts MATCH ? "
776
- "ORDER BY bm25(chunks_fts,3.0,1.0) LIMIT ? OFFSET ?",
777
- (expression, limit, offset))]
778
- except sqlite3.OperationalError:
779
- # FTS5 indisponível em algum Python: mantém recuperação lexical em stdlib.
780
- ranked = [(sum(term.casefold() in (file_path + " " + text).casefold() for term in terms), chunk_id)
781
- for chunk_id, file_path, _start, _end, text, _vector in rows]
782
- return [(chunk_id, score) for score, chunk_id in sorted(ranked, reverse=True)[offset:offset + limit] if score]
783
- finally:
784
- conn.close()
785
-
786
-
787
- def _min_max(pairs):
788
- if not pairs:
789
- return {}
790
- values = [value for _, value in pairs]
791
- low, high = min(values), max(values)
792
- return {key: (value - low) / (high - low) if high > low else 1.0 for key, value in pairs}
793
-
794
-
795
- def search(root, query_vector=None, top_k=20, query=None, model_id=None, include=None):
796
- """Recuperação vetorial + lexical por fusão de notas relativas, sem rede."""
797
- path = existing_db_path(root)
798
- if not path:
799
- return []
800
- rows, meta, norms = _vector_rows(root, path)
801
- if include is not None:
802
- rows = [row for row in rows if include(row[1])]
803
- if model_id and meta.get("model_id") != model_id:
804
- raise IndexCompatibilityError("The index must be rebuilt for the selected model.")
805
- dimensions = meta.get("dimensions")
806
- vector_scores = {}
807
- if query_vector is not None:
808
- validate_vector(query_vector, dimensions)
809
- query_norm = math.sqrt(_sumprod(query_vector, query_vector))
810
- for chunk_id, _path, _start, _end, _text, vector in rows:
811
- if vector:
812
- if len(vector) != len(query_vector):
813
- raise IndexCompatibilityError(f"Incompatible dimensions: query={len(query_vector)}, index={len(vector)}.")
814
- norm = norms[chunk_id] * query_norm
815
- vector_scores[chunk_id] = _sumprod(query_vector, vector) / norm if norm else 0.0
816
- vector_ids = sorted(vector_scores, key=vector_scores.get, reverse=True)
817
- limit = max(top_k * 4, 40)
818
- if not query:
819
- lexical = []
820
- elif include is None:
821
- lexical = _lexical_scored(path, query, rows, limit)
822
- else:
823
- allowed, lexical, offset, page = {row[0] for row in rows}, [], 0, limit * 10
824
- while len(lexical) < limit:
825
- batch = _lexical_scored(path, query, rows, page, offset)
826
- lexical += [pair for pair in batch if pair[0] in allowed]
827
- if len(batch) < page:
828
- break
829
- offset += page
830
- lexical = lexical[:limit]
831
- if lexical:
832
- vector_part = _min_max([(chunk_id, vector_scores[chunk_id]) for chunk_id in vector_ids[:limit]])
833
- lexical_part = _min_max(lexical)
834
- alpha = FUSION_ALPHA if vector_part else 0.0
835
- scores = {chunk_id: alpha * vector_part.get(chunk_id, 0.0) + (1 - alpha) * lexical_part.get(chunk_id, 0.0)
836
- for chunk_id in set(vector_part) | set(lexical_part)}
837
- ids = sorted(scores, key=lambda item: (scores[item], item in lexical_part), reverse=True)
838
- else:
839
- scores, ids = vector_scores, vector_ids
840
- by_id = {row[0]: row for row in rows}
841
- return [(scores[chunk_id], *by_id[chunk_id][1:5]) for chunk_id in ids[:top_k]]
842
-
843
-
844
- def remove_index(root):
845
- """Remove somente arquivos de índice da raiz conhecida, nunca arquivos do projeto."""
846
- import glob
847
- removed = []
848
- for key in set([project_identity.project_id(root), *project_identity.legacy_ids(root)]):
849
- pattern = re.compile(re.escape(key) + r"(?:\.v-[a-f0-9]{16})?(?:\.previous|\.building-[a-f0-9]+|\.vectors)?\.sqlite3$")
850
- for path in glob.glob(os.path.join(INDEX_DIR, key + "*.sqlite3")):
851
- if pattern.fullmatch(os.path.basename(path)):
852
- safe = project_identity.state_path(path, INDEX_DIR)
853
- try:
854
- os.remove(safe)
855
- except PermissionError as exc:
856
- raise RuntimeError("The index is open in another process. Close the external reader and try removing it again.") from exc
857
- removed.append(safe)
858
- invalidate_cache(root)
859
- return removed
860
-
861
-
862
- def copy_project_indexes(root, destination):
863
- """Copia todas as visões e cache ao reassociar uma raiz, preservando a origem."""
864
- import glob
865
- target_key = project_identity.project_id(destination)
866
- copied = set()
867
- for key in dict.fromkeys([project_identity.project_id(root), *project_identity.legacy_ids(root)]):
868
- pattern = re.compile(re.escape(key) + r'((?:\.v-[a-f0-9]{16})?(?:\.previous|\.building-[a-f0-9]+|\.vectors)?\.sqlite3)$')
869
- for path in glob.glob(os.path.join(INDEX_DIR, key + '*.sqlite3')):
870
- match = pattern.fullmatch(os.path.basename(path))
871
- if match:
872
- target = os.path.join(INDEX_DIR, target_key + match[1])
873
- if target not in copied:
874
- if os.path.exists(target):
875
- raise ValueError('The reassociation target already has data.')
876
- _backup(path, target)
877
- copied.add(target)
878
- return sorted(copied)
1
+ #!/usr/bin/env python3
2
+ """Indexador hash-manifest do Smart Tool (stdlib only). Não faz nenhuma chamada de rede —
3
+ `reindex()` recebe `embed_batch` injetado pelo chamador (o daemon, que sabe qual modelo/
4
+ credencial usar). Vetores ficam como BLOB (`array` de float32), sem numpy; a busca faz
5
+ similaridade de cosseno em Python puro.
6
+ """
7
+ import array
8
+ import collections
9
+ import hashlib
10
+ import json
11
+ import math
12
+ import operator
13
+ import os
14
+ import paths
15
+ import re
16
+ import sqlite3
17
+ import threading
18
+ import time
19
+ import uuid
20
+ from pathlib import Path
21
+
22
+ import atomic_io
23
+ import project_identity
24
+ import index_views
25
+ import embedding_cache
26
+ import document_text
27
+
28
+ INDEX_DIR = paths.DATA_DIR
29
+ CHUNK_LINES = 200
30
+ CHUNK_MAX_CHARS = 8000
31
+ CHUNK_OVERLAP_LINES = 12
32
+ CHUNKER_VERSION = 3
33
+ SCHEMA_VERSION = 2
34
+ REINDEX_BATCH_MAX_CHARS = 32_000
35
+ REINDEX_BATCH_MAX_ITEMS = 32
36
+ # Embedding requests in flight per flush. Measured 2026-10-03 (docs/spikes/embed_parallel_spike.py, 30 batches,
37
+ # 3 interleaved rounds): 1 worker 33-50 s, 2 workers 19-27 s, 4 workers 11-20 s (~2.8x), no 429 from the gateway.
38
+ EMBED_WORKERS = 4
39
+ # Weaviate's relativeScoreFusion (default since v1.24, DefaultAlpha 0.75). Measured 2026-10-05 on the rerank gold set
40
+ # (42 code targets, docs/spikes/fusion_spike.py) against the previous RRF k=60: target in the 20 candidates 41 -> 42,
41
+ # first place without rerank 31 -> 35, final MRR after rerank 0.946 -> 0.969.
42
+ FUSION_ALPHA = 0.75
43
+
44
+ # `read_indexable` centraliza os filtros entre o escopo e hash → chunk → embed →
45
+ # sqlite. `compute_manifest`, prévia e reindexação usam a mesma leitura; arquivos
46
+ # inalterados reaproveitam a decisão por stat. As regras são mecânicas de
47
+ # propósito — o escopo por LLM decide *diretórios* (convenção varia por repo), mas
48
+ # "isto é texto que vale embeddar" e "isto é material de credencial" não variam.
49
+ MAX_INDEXABLE_BYTES = 1024 * 1024
50
+ MAX_SVG_INDEXABLE_BYTES = 64 * 1024
51
+ _SNIFF_BYTES = 8192
52
+ # Linha média muito longa é bundle/JSON minificado: passa o sniff de binário (é ASCII),
53
+ # mas viraria dezenas de chunks todos com start_line=1, inúteis como citação.
54
+ MAX_AVG_LINE_CHARS = 2000
55
+
56
+ _UTF16_BOMS = (b"\xff\xfe", b"\xfe\xff")
57
+ _UTF8_BOM = b"\xef\xbb\xbf"
58
+
59
+ # Nunca indexado: o conteúdo vai para um endpoint externo de embeddings e fica em texto
60
+ # puro no sqlite3, e nenhuma das duas coisas se desfaz. Comparação sempre em minúsculas
61
+ # — o sistema de arquivos do Windows não distingue caixa, e um `set` distinguiria.
62
+ _SECRET_BASENAMES = frozenset({
63
+ "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519", "identity", "credentials",
64
+ "credentials.json", "service-account.json", "kubeconfig", "netrc", "_netrc",
65
+ "npmrc", "pgpass", "htpasswd", "master.key", "secrets.json", "secrets.yaml",
66
+ "secrets.yml", "local.settings.json",
67
+ })
68
+ _SECRET_EXTS = frozenset({
69
+ ".pem", ".key", ".pfx", ".p12", ".jks", ".keystore", ".ppk", ".kdbx", ".asc",
70
+ ".gpg", ".env", ".tfvars", ".har",
71
+ })
72
+ # Sobrevive a renome — é a única regra que não depende de adivinhar convenção alheia.
73
+ # Só vale no cabeçalho: arquivo de credencial ABRE com o marcador, enquanto código que
74
+ # trata desses formatos (este módulo, um scanner de segredo, um teste) menciona o mesmo
75
+ # literal no meio — e recusar esse código seria tirar fonte legítima do índice.
76
+ _SECRET_CONTENT_MARKERS = (
77
+ b"PRIVATE KEY-----", b"PuTTY-User-Key-File", b"aws_secret_access_key",
78
+ )
79
+ _SECRET_MARKER_WINDOW = 512
80
+
81
+ SKIP_DOTFILE = "dotfile"
82
+ SKIP_SECRET = "credential"
83
+ SKIP_TOO_LARGE = "over size limit"
84
+ SKIP_GRAPHIC = "large svg graphic"
85
+ SKIP_BINARY = "binary"
86
+ SKIP_MINIFIED = "minified"
87
+ SKIP_UNREADABLE = "unreadable"
88
+
89
+
90
+ LEXICAL_MODEL = "lexical"
91
+
92
+ def _secret_name(name):
93
+ if name in _SECRET_BASENAMES:
94
+ return True
95
+ if os.path.splitext(name)[1] in _SECRET_EXTS:
96
+ return True
97
+ # `local.env`/`prod.env` caem na extensão; `env`/`env.local` não têm extensão nenhuma.
98
+ return name == "env" or name.startswith("env.")
99
+
100
+
101
+ def _text_encoding(prefix):
102
+ """Encoding de leitura deduzido do BOM. UTF-16 é o default de `Out-File` no
103
+ PowerShell 5.1: decodificar como UTF-8 entregaria texto intercalado de `\\x00`."""
104
+ if prefix.startswith(_UTF16_BOMS):
105
+ return "utf-16"
106
+ if prefix.startswith(_UTF8_BOM):
107
+ return "utf-8-sig"
108
+ return "utf-8"
109
+
110
+
111
+ def read_indexable(root, rel_path):
112
+ """`(bytes, encoding)` do arquivo, ou `(None, motivo)` quando ele não pode ser
113
+ indexado. Uma única leitura serve o hash do manifest e o chunking; arquivo grande e
114
+ binário são recusados antes de qualquer `read` integral."""
115
+ full = os.path.join(root, rel_path)
116
+ if not project_identity.within_root(root, full):
117
+ return None, SKIP_SECRET
118
+ parts = str(rel_path).replace("\\", "/").split("/")
119
+ if any(part.startswith(".") for part in parts if part not in (".", "..")):
120
+ return None, SKIP_DOTFILE
121
+ name = os.path.basename(rel_path).lower()
122
+ # Nome iniciado por ponto: mesma regra que `index_scope` aplica a diretório, e é
123
+ # onde vive metade dos arquivos de credencial (.env, .npmrc, .netrc).
124
+ if name.startswith("."):
125
+ return None, SKIP_DOTFILE
126
+ if _secret_name(name):
127
+ return None, SKIP_SECRET
128
+ try:
129
+ size = os.path.getsize(full)
130
+ if name.endswith(".svg") and size > MAX_SVG_INDEXABLE_BYTES:
131
+ return None, SKIP_GRAPHIC
132
+ limit = document_text.MAX_ARCHIVE_BYTES if name.endswith('.docx') else MAX_INDEXABLE_BYTES
133
+ if size > limit:
134
+ return None, SKIP_TOO_LARGE
135
+ with open(full, "rb") as f:
136
+ data = f.read(limit + 1)
137
+ except OSError:
138
+ return None, SKIP_UNREADABLE
139
+ if len(data) > limit:
140
+ return None, SKIP_TOO_LARGE
141
+ if name.endswith('.docx'):
142
+ try:
143
+ data = document_text.extract_docx(data)
144
+ except document_text.DocumentError as exc:
145
+ return None, str(exc)
146
+ prefix = data[:_SNIFF_BYTES]
147
+ encoding = _text_encoding(prefix)
148
+ if encoding == "utf-16":
149
+ try:
150
+ prefix = data.decode("utf-16", "ignore")[:_SNIFF_BYTES].encode("utf-8")
151
+ except (UnicodeDecodeError, UnicodeEncodeError):
152
+ return None, SKIP_BINARY
153
+ elif b"\0" in prefix:
154
+ return None, SKIP_BINARY
155
+ header = prefix[:_SECRET_MARKER_WINDOW]
156
+ if any(marker in header for marker in _SECRET_CONTENT_MARKERS):
157
+ return None, SKIP_SECRET
158
+ newlines = prefix.count(b"\n")
159
+ if newlines == 0 and len(prefix) >= _SNIFF_BYTES:
160
+ return None, SKIP_MINIFIED
161
+ if newlines and len(prefix) / (newlines + 1) > MAX_AVG_LINE_CHARS:
162
+ return None, SKIP_MINIFIED
163
+ return data, encoding
164
+
165
+
166
+ def is_indexable(root, rel_path):
167
+ return read_indexable(root, rel_path)[0] is not None
168
+
169
+
170
+ def _project_hash(root):
171
+ return index_views.storage_key(root)
172
+
173
+
174
+ def db_path(root):
175
+ os.makedirs(INDEX_DIR, exist_ok=True)
176
+ return os.path.join(INDEX_DIR, _project_hash(root) + ".sqlite3")
177
+
178
+
179
+ _MIGRATION_LOCK = threading.RLock()
180
+ _VECTOR_CACHE_LOCK = threading.RLock()
181
+ _VECTOR_CACHE = collections.OrderedDict()
182
+ VECTOR_CACHE_MAX_BYTES = 64 * 1024 * 1024
183
+ VECTOR_CACHE_MAX_PROJECTS = 3
184
+
185
+
186
+ class IndexCompatibilityError(RuntimeError):
187
+ """Modelo/dimensão do índice não correspondem à consulta."""
188
+
189
+
190
+ class IndexVectorError(RuntimeError):
191
+ """Resposta de embeddings fora do contrato; lote não pode ser confirmado."""
192
+
193
+
194
+ def existing_db_path(root):
195
+ canonical = os.path.join(INDEX_DIR, _project_hash(root) + ".sqlite3")
196
+ if os.path.isfile(canonical):
197
+ return canonical
198
+ if index_views.describe(root).get('git'):
199
+ return None # Um banco sem visão conhecida não pode responder por uma branch.
200
+ for key in project_identity.legacy_ids(root):
201
+ old = os.path.join(INDEX_DIR, key + ".sqlite3")
202
+ if os.path.isfile(old):
203
+ return old
204
+ return None
205
+
206
+
207
+ def _readonly(path):
208
+ return sqlite3.connect(Path(path).absolute().as_uri() + "?mode=ro", uri=True, timeout=10)
209
+
210
+
211
+ def _backup(source, target):
212
+ target = project_identity.state_path(target, INDEX_DIR)
213
+ temp = atomic_io.tmp_path_for(target)
214
+ src, dst = _readonly(source), sqlite3.connect(temp)
215
+ try:
216
+ src.backup(dst)
217
+ dst.commit()
218
+ finally:
219
+ src.close()
220
+ dst.close()
221
+ atomic_io.atomic_replace(temp, target)
222
+
223
+
224
+ def migrate_legacy(root):
225
+ """Cópia consistente para a chave canônica; o banco legado permanece intacto."""
226
+ target = db_path(root)
227
+ with _MIGRATION_LOCK:
228
+ if not os.path.isfile(target):
229
+ old = existing_db_path(root)
230
+ if not old and index_views.describe(root).get('git'):
231
+ old = next((os.path.join(INDEX_DIR, key + '.sqlite3') for key in
232
+ [project_identity.project_id(root), *project_identity.legacy_ids(root)]
233
+ if os.path.isfile(os.path.join(INDEX_DIR, key + '.sqlite3'))), None)
234
+ if old:
235
+ _backup(old, target)
236
+ return target
237
+
238
+
239
+ def _open_path(path):
240
+ conn = sqlite3.connect(path, timeout=10)
241
+ # `chunks.text` é conteúdo de arquivo em texto puro: sem isto, um DELETE só desliga a
242
+ # página e o texto continua legível nas free pages do arquivo até um VACUUM.
243
+ conn.execute("PRAGMA secure_delete=ON")
244
+ conn.execute(
245
+ "CREATE TABLE IF NOT EXISTS manifest (path TEXT PRIMARY KEY, hash TEXT NOT NULL, "
246
+ "mtime_ns INTEGER NOT NULL DEFAULT 0, size INTEGER NOT NULL DEFAULT -1)"
247
+ )
248
+ conn.execute(
249
+ "CREATE TABLE IF NOT EXISTS chunks ("
250
+ "id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT NOT NULL, "
251
+ "start_line INTEGER NOT NULL, end_line INTEGER NOT NULL, "
252
+ "text TEXT NOT NULL, embedding BLOB)"
253
+ )
254
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_chunks_path ON chunks(path)")
255
+ conn.execute("CREATE TABLE IF NOT EXISTS file_state(path TEXT PRIMARY KEY, mtime_ns INTEGER NOT NULL, size INTEGER NOT NULL, reason TEXT NOT NULL)")
256
+ columns = {row[1] for row in conn.execute("PRAGMA table_info(manifest)")}
257
+ for column, sql_type, default in (("mtime_ns", "INTEGER", "0"), ("size", "INTEGER", "-1")):
258
+ if column not in columns:
259
+ conn.execute(f"ALTER TABLE manifest ADD COLUMN {column} {sql_type} NOT NULL DEFAULT {default}")
260
+ conn.execute("CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
261
+ try:
262
+ existed = conn.execute("SELECT 1 FROM sqlite_master WHERE name='chunks_fts'").fetchone()
263
+ conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(path, text, content='chunks', content_rowid='id')")
264
+ conn.executescript("""
265
+ CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN
266
+ INSERT INTO chunks_fts(rowid,path,text) VALUES(new.id,new.path,new.text); END;
267
+ CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN
268
+ INSERT INTO chunks_fts(chunks_fts,rowid,path,text) VALUES('delete',old.id,old.path,old.text); END;
269
+ CREATE TRIGGER IF NOT EXISTS chunks_au AFTER UPDATE ON chunks BEGIN
270
+ INSERT INTO chunks_fts(chunks_fts,rowid,path,text) VALUES('delete',old.id,old.path,old.text);
271
+ INSERT INTO chunks_fts(rowid,path,text) VALUES(new.id,new.path,new.text); END;
272
+ """)
273
+ if not existed:
274
+ conn.execute("INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild')")
275
+ _set_metadata(conn, {"fts": True})
276
+ except sqlite3.OperationalError:
277
+ _set_metadata(conn, {"fts": False})
278
+ conn.commit()
279
+ return conn
280
+
281
+
282
+ def open_db(root):
283
+ return _open_path(migrate_legacy(root))
284
+
285
+
286
+ def _metadata(conn):
287
+ try:
288
+ return {key: json.loads(value) for key, value in conn.execute("SELECT key,value FROM metadata")}
289
+ except (sqlite3.Error, ValueError):
290
+ return {}
291
+
292
+
293
+ def _set_metadata(conn, values):
294
+ conn.executemany("INSERT INTO metadata(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value WHERE metadata.value != excluded.value",
295
+ [(key, json.dumps(value, ensure_ascii=False)) for key, value in values.items()])
296
+
297
+
298
+ def info(root):
299
+ path = existing_db_path(root)
300
+ if not path:
301
+ return {"exists": False, "path": os.path.join(INDEX_DIR, _project_hash(root) + ".sqlite3")}
302
+ try:
303
+ conn = _readonly(path)
304
+ try:
305
+ meta = _metadata(conn)
306
+ return {**meta, "exists": True, "path": path, "bytes": os.path.getsize(path),
307
+ "files": conn.execute("SELECT count(*) FROM manifest").fetchone()[0],
308
+ "chunks": conn.execute("SELECT count(*) FROM chunks").fetchone()[0],
309
+ "legacy": not bool(meta.get("model_id"))}
310
+ finally:
311
+ conn.close()
312
+ except (OSError, sqlite3.Error) as exc:
313
+ return {"exists": True, "path": path, "error": type(exc).__name__, "legacy": True}
314
+
315
+
316
+ def manifest_snapshot(root):
317
+ path = existing_db_path(root)
318
+ if not path:
319
+ return {}
320
+ conn = _readonly(path)
321
+ try:
322
+ if conn.execute("SELECT 1 FROM sqlite_master WHERE name='file_state'").fetchone():
323
+ return {rel: (mtime, size) for rel, mtime, size in conn.execute("SELECT path,mtime_ns,size FROM file_state")}
324
+ return {rel: (mtime, size) for rel, mtime, size in conn.execute("SELECT path,mtime_ns,size FROM manifest")}
325
+ except sqlite3.Error:
326
+ return {}
327
+ finally:
328
+ conn.close()
329
+
330
+
331
+ def compute_manifest(root, included_paths):
332
+ """`(manifest, contagem de descartes por motivo)`. O manifest é a fonte de verdade do
333
+ cálculo completo de hashes. A reindexação usa `read_indexable` com os mesmos gates
334
+ ao detectar alteração por stat/evento; arquivos que deixam de se qualificar saem
335
+ do manifest e têm os chunks removidos."""
336
+ manifest = {}
337
+ skipped = {}
338
+ for rel in included_paths:
339
+ data, reason = read_indexable(root, rel)
340
+ if data is None:
341
+ skipped[reason] = skipped.get(reason, 0) + 1
342
+ continue
343
+ manifest[rel] = hashlib.sha256(data).hexdigest()
344
+ return manifest, skipped
345
+
346
+
347
+ def load_manifest(conn):
348
+ return dict(conn.execute("SELECT path, hash FROM manifest").fetchall())
349
+
350
+
351
+ def diff_manifest(old, new):
352
+ added = [p for p in new if p not in old]
353
+ changed = [p for p in new if p in old and old[p] != new[p]]
354
+ removed = [p for p in old if p not in new]
355
+ return added, changed, removed
356
+
357
+
358
+ def _split_oversized(start_line, end_line, text, max_chars):
359
+ return [
360
+ (start_line, end_line, text[i:i + max_chars])
361
+ for i in range(0, len(text), max_chars)
362
+ ]
363
+
364
+
365
+ def chunk_file(root, rel_path, max_lines=CHUNK_LINES, max_chars=CHUNK_MAX_CHARS, data=None, encoding=None):
366
+ """`data`/`encoding` já resolvidos por `read_indexable` evitam reler o arquivo. Sem
367
+ eles, relê e redecide o encoding — e uma leitura que falhe devolve `None`, distinto
368
+ de `[]` (arquivo legítimo sem conteúdo), porque quem chama não pode gravar hash novo
369
+ de um conteúdo que não conseguiu ler."""
370
+ if data is None:
371
+ data, encoding = read_indexable(root, rel_path)
372
+ if data is None:
373
+ return None
374
+ try:
375
+ text = data.decode(encoding or "utf-8", "ignore")
376
+ except (LookupError, UnicodeDecodeError):
377
+ return None
378
+ lines = text.splitlines(keepends=True)
379
+ if max_lines < 1 or max_chars < 1:
380
+ raise ValueError("Chunk limits must be positive.")
381
+ boundary = re.compile(r"^(?:\s*(?:async\s+)?(?:def|class|function)\s|export\s|#{1,6}\s)")
382
+ chunks, start = [], 0
383
+ while start < len(lines):
384
+ if len(lines[start]) > max_chars:
385
+ chunks.extend(_split_oversized(start + 1, start + 1, lines[start], max_chars))
386
+ start += 1
387
+ continue
388
+ end, chars = start, 0
389
+ while end < len(lines) and end - start < max_lines and chars + len(lines[end]) <= max_chars:
390
+ chars += len(lines[end])
391
+ end += 1
392
+ # Prefere terminar antes de uma nova função/seção, mantendo os limites reais
393
+ # de linha em cada fragmento. Uma pequena sobreposição preserva contexto.
394
+ if end < len(lines) and end - start > 40:
395
+ breaks = [i for i in range(start + 30, end) if boundary.match(lines[i])]
396
+ if breaks:
397
+ end = breaks[-1]
398
+ chunks.append((start + 1, end, "".join(lines[start:end])))
399
+ if end == len(lines):
400
+ break
401
+ overlap = min(CHUNK_OVERLAP_LINES, (end - start) // 5)
402
+ start = max(start + 1, end - overlap)
403
+ return chunks
404
+
405
+
406
+ def _pack_vector(vector):
407
+ validate_vector(vector)
408
+ try:
409
+ packed = array.array("f", vector)
410
+ except (OverflowError, TypeError, ValueError) as exc:
411
+ raise IndexVectorError("Embedding does not fit in float32.") from exc
412
+ if not all(math.isfinite(value) for value in packed):
413
+ raise IndexVectorError("Embedding contains a non-finite float32 value.")
414
+ return packed.tobytes()
415
+
416
+
417
+ def _unpack_vector(blob):
418
+ arr = array.array("f")
419
+ arr.frombytes(blob)
420
+ return arr
421
+
422
+
423
+ _sumprod = getattr(math, "sumprod", None) or (lambda a, b: sum(map(operator.mul, a, b)))
424
+
425
+
426
+ def cosine_similarity(a, b):
427
+ if len(a) != len(b):
428
+ raise IndexCompatibilityError(f"Incompatible dimensions: query={len(a)}, index={len(b)}.")
429
+ norm_a = math.sqrt(_sumprod(a, a))
430
+ norm_b = math.sqrt(_sumprod(b, b))
431
+ if norm_a == 0 or norm_b == 0:
432
+ return 0.0
433
+ return _sumprod(a, b) / (norm_a * norm_b)
434
+
435
+
436
+ def validate_vector(vector, dimensions=None):
437
+ if not isinstance(vector, (list, tuple, array.array)) or not vector:
438
+ raise IndexVectorError("Embedding must be a non-empty numeric vector.")
439
+ if dimensions not in (None, 0) and len(vector) != dimensions:
440
+ raise IndexCompatibilityError(f"Embedding has {len(vector)} dimensions; expected {dimensions}.")
441
+ if any(isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value)
442
+ for value in vector):
443
+ raise IndexVectorError("Embedding contains an invalid or non-finite value.")
444
+ return len(vector)
445
+
446
+
447
+ def replace_file_chunks(conn, rel_path, chunks_with_vectors):
448
+ conn.execute("DELETE FROM chunks WHERE path = ?", (rel_path,))
449
+ conn.executemany(
450
+ "INSERT INTO chunks (path, start_line, end_line, text, embedding) VALUES (?, ?, ?, ?, ?)",
451
+ [
452
+ (rel_path, start, end, text, None if vector is None else _pack_vector(vector))
453
+ for (start, end, text), vector in chunks_with_vectors
454
+ ],
455
+ )
456
+
457
+
458
+ def remove_file(conn, rel_path):
459
+ conn.execute("DELETE FROM chunks WHERE path = ?", (rel_path,))
460
+ conn.execute("DELETE FROM manifest WHERE path = ?", (rel_path,))
461
+
462
+
463
+ def _file_state(conn, rel_path, stat, reason="indexed"):
464
+ conn.execute("INSERT INTO file_state VALUES(?,?,?,?) ON CONFLICT(path) DO UPDATE SET "
465
+ "mtime_ns=excluded.mtime_ns,size=excluded.size,reason=excluded.reason",
466
+ (rel_path, *stat, reason))
467
+
468
+
469
+ def upsert_manifest_entry(conn, rel_path, file_hash, mtime_ns=0, size=-1):
470
+ conn.execute(
471
+ "INSERT INTO manifest (path, hash, mtime_ns, size) VALUES (?, ?, ?, ?) "
472
+ "ON CONFLICT(path) DO UPDATE SET hash=excluded.hash, mtime_ns=excluded.mtime_ns, size=excluded.size",
473
+ (rel_path, file_hash, mtime_ns, size),
474
+ )
475
+ _file_state(conn, rel_path, (mtime_ns, size))
476
+
477
+
478
+ def _stat_candidate(root, rel):
479
+ full = os.path.join(root, rel)
480
+ name = os.path.basename(rel).lower()
481
+ if not project_identity.within_root(root, full):
482
+ return None, SKIP_SECRET
483
+ if any(part.startswith(".") for part in str(rel).replace("\\", "/").split("/")
484
+ if part not in (".", "..")):
485
+ return None, SKIP_DOTFILE
486
+ if _secret_name(name):
487
+ return None, SKIP_SECRET
488
+ try:
489
+ stat = os.stat(full)
490
+ if not os.path.isfile(full):
491
+ return None, SKIP_UNREADABLE
492
+ except OSError:
493
+ return None, SKIP_UNREADABLE
494
+ if stat.st_size > (document_text.MAX_ARCHIVE_BYTES if name.endswith('.docx') else MAX_INDEXABLE_BYTES):
495
+ return None, SKIP_TOO_LARGE
496
+ if name.endswith(".svg") and stat.st_size > MAX_SVG_INDEXABLE_BYTES:
497
+ return None, SKIP_GRAPHIC
498
+ return (stat.st_mtime_ns, stat.st_size), None
499
+
500
+
501
+ def _stable_read(root, rel):
502
+ for _ in range(2):
503
+ before, reason = _stat_candidate(root, rel)
504
+ if before is None:
505
+ return None, reason, None
506
+ data, encoding = read_indexable(root, rel)
507
+ if data is None:
508
+ return None, encoding, before
509
+ after, _ = _stat_candidate(root, rel)
510
+ if after == before and (str(rel).lower().endswith('.docx') or len(data) == before[1]):
511
+ return data, encoding, before
512
+ return None, "file changed while reading", None
513
+
514
+
515
+ def invalidate_cache(root):
516
+ with _VECTOR_CACHE_LOCK:
517
+ for key in list(_VECTOR_CACHE):
518
+ if key[0] == project_identity.canonical_root(root):
519
+ _VECTOR_CACHE.pop(key, None)
520
+
521
+
522
+ def reindex(root, included_paths, embed_batch, *args, **kwargs):
523
+ with index_views.pin(root) as view:
524
+ return _reindex(root, included_paths, embed_batch, *args, **kwargs)
525
+
526
+
527
+ def _split_batches(texts, max_items, max_chars):
528
+ batches, batch, size = [], [], 0
529
+ for text in texts:
530
+ if batch and (len(batch) >= max_items or size + len(text) > max_chars):
531
+ batches.append(batch)
532
+ batch, size = [], 0
533
+ batch.append(text)
534
+ size += len(text)
535
+ if batch:
536
+ batches.append(batch)
537
+ return batches
538
+
539
+
540
+ def _reindex(root, included_paths, embed_batch, chunk_lines=CHUNK_LINES, on_progress=None,
541
+ batch_max_chars=REINDEX_BATCH_MAX_CHARS, batch_max_items=REINDEX_BATCH_MAX_ITEMS,
542
+ model_id=None, expected_dimensions=None, force_rebuild=False, full_check=False,
543
+ changed_paths=None, cancel_check=None):
544
+ """Atualiza snapshots por arquivo; um modelo novo só substitui a geração após sucesso."""
545
+ if batch_max_chars < 1 or batch_max_items < 1:
546
+ raise ValueError("Indexing batch limits must be positive.")
547
+ view = index_views.describe(root)
548
+ def check():
549
+ if cancel_check:
550
+ cancel_check()
551
+ index_views.assert_current(view)
552
+ root = project_identity.display_root(root)
553
+ model_id = model_id or "unspecified"
554
+ target = migrate_legacy(root)
555
+ current = info(root)
556
+ # Checkout e commit exigem confronto de conteúdo, mesmo com timestamps iguais.
557
+ if view.get('git') and (current.get('git_commit') != view.get('commit') or
558
+ current.get('view_id') != view.get('view_id')):
559
+ full_check = True
560
+ compatible = (current.get("model_id") == model_id and
561
+ current.get("chunker_version") == CHUNKER_VERSION and
562
+ current.get("schema_version") == SCHEMA_VERSION and
563
+ current.get("chunk_lines") == chunk_lines and
564
+ (expected_dimensions is None or current.get("dimensions") in (0, expected_dimensions)))
565
+ building = force_rebuild or not compatible
566
+ signature = hashlib.sha256(json.dumps([model_id, expected_dimensions, CHUNKER_VERSION,
567
+ SCHEMA_VERSION, chunk_lines]).encode()).hexdigest()[:12]
568
+ work_path = target[:-8] + f".building-{signature}.sqlite3" if building else target
569
+ project_identity.state_path(work_path, INDEX_DIR)
570
+ conn = _open_path(work_path)
571
+ stats = {"added": 0, "changed": 0, "removed": 0, "failed": 0,
572
+ "unchanged": 0, "skipped": {}, "rebuilt": building, "ready": False}
573
+ cache = None
574
+ try:
575
+ meta = _metadata(conn)
576
+ if meta.get("model_id") not in (None, model_id):
577
+ raise IndexCompatibilityError("Temporary generation belongs to another model.")
578
+ dimensions = meta.get("dimensions") or expected_dimensions or 0
579
+ contract = {'model': model_id, 'dimensions': dimensions, 'chunker': CHUNKER_VERSION,
580
+ 'chunk_lines': chunk_lines, 'preprocessing': 1}
581
+ cache = embedding_cache.VectorCache(root, INDEX_DIR, contract)
582
+ # Alimenta o cache com o banco existente somente quando o contrato é conhecido.
583
+ if compatible and dimensions and not meta.get('vector_cache_seeded'):
584
+ for text, blob in conn.execute('SELECT text,embedding FROM chunks WHERE embedding IS NOT NULL'):
585
+ vector = _unpack_vector(blob)
586
+ validate_vector(vector, dimensions)
587
+ cache.put(text, vector)
588
+ cache.db.commit()
589
+ _set_metadata(conn, {'vector_cache_seeded': True})
590
+ generation = meta.get("generation") or uuid.uuid4().hex
591
+ _set_metadata(conn, {"root": project_identity.canonical_root(root), "model_id": model_id,
592
+ "dimensions": dimensions, "schema_version": SCHEMA_VERSION,
593
+ "chunker_version": CHUNKER_VERSION, "chunk_lines": chunk_lines,
594
+ "generation": generation})
595
+ conn.commit()
596
+ old = {r[0]: (r[1], r[2], r[3]) for r in conn.execute("SELECT path,hash,mtime_ns,size FROM manifest")}
597
+ old_state = {r[0]: (r[1], r[2]) for r in conn.execute("SELECT path,mtime_ns,size FROM file_state")}
598
+ observed, candidates = {}, []
599
+ forced = {str(p).replace("\\", "/").casefold().strip("/") for p in (changed_paths or [])}
600
+ for rel in sorted(set(included_paths)):
601
+ check()
602
+ stat, reason = _stat_candidate(root, rel)
603
+ if stat is None:
604
+ stats["skipped"][reason] = stats["skipped"].get(reason, 0) + 1
605
+ continue
606
+ observed[rel] = stat
607
+ force_file = any(rel.casefold() == p or rel.casefold().startswith(p + "/") for p in forced)
608
+ extractor_changed = rel.lower().endswith('.docx') and meta.get('document_extractor_version') != document_text.VERSION
609
+ if (full_check or building or force_file or extractor_changed or old_state.get(rel) != stat):
610
+ candidates.append(rel)
611
+ else:
612
+ stats["unchanged"] += 1
613
+ for rel in set(old) - set(observed):
614
+ remove_file(conn, rel)
615
+ stats["removed"] += 1
616
+ for rel in set(old_state) - set(observed):
617
+ conn.execute("DELETE FROM file_state WHERE path=?", (rel,))
618
+ conn.commit()
619
+ processed, total = 0, len(candidates)
620
+ if on_progress:
621
+ on_progress(0, total)
622
+ pending, texts, chars = [], [], 0
623
+
624
+ def progress():
625
+ if on_progress:
626
+ on_progress(processed, total)
627
+
628
+ def flush():
629
+ nonlocal pending, texts, chars, processed, dimensions
630
+ if not pending:
631
+ return
632
+ if embed_batch is None:
633
+ vectors = [None] * len(texts)
634
+ else:
635
+ def guarded(part):
636
+ check()
637
+ return embed_batch(part)
638
+
639
+ check()
640
+ vectors = cache.embed(texts, guarded, validate_vector, use_cache=not force_rebuild,
641
+ split=lambda items: _split_batches(items, batch_max_items, batch_max_chars),
642
+ workers=EMBED_WORKERS)
643
+ check()
644
+ if len(vectors) != len(texts):
645
+ raise IndexVectorError("Embedding count differs from the batch sent.")
646
+ for vector in vectors:
647
+ dimension = validate_vector(vector, dimensions)
648
+ if not dimensions:
649
+ dimensions = dimension
650
+ offset = 0
651
+ for rel, chunks, digest, stat in pending:
652
+ check()
653
+ count = len(chunks)
654
+ replace_file_chunks(conn, rel, zip(chunks, vectors[offset:offset + count]))
655
+ upsert_manifest_entry(conn, rel, digest, *stat)
656
+ _set_metadata(conn, {"dimensions": dimensions, "updated_at": time.time()})
657
+ conn.commit()
658
+ offset += count
659
+ processed += 1
660
+ progress()
661
+ pending, texts, chars = [], [], 0
662
+
663
+ for rel in candidates:
664
+ check()
665
+ data, encoding, stat = _stable_read(root, rel)
666
+ if data is None:
667
+ flush()
668
+ stats["skipped"][encoding] = stats["skipped"].get(encoding, 0) + 1
669
+ if encoding in (SKIP_UNREADABLE, "file changed while reading"):
670
+ stats["failed"] += 1
671
+ elif stat is not None:
672
+ _file_state(conn, rel, stat, encoding)
673
+ if rel in old:
674
+ remove_file(conn, rel)
675
+ conn.commit()
676
+ processed += 1
677
+ progress()
678
+ continue
679
+ digest = hashlib.sha256(data).hexdigest()
680
+ if rel in old and digest == old[rel][0]:
681
+ upsert_manifest_entry(conn, rel, digest, *stat)
682
+ conn.commit()
683
+ stats["unchanged"] += 1
684
+ processed += 1
685
+ progress()
686
+ continue
687
+ chunks = chunk_file(root, rel, chunk_lines, data=data, encoding=encoding)
688
+ stats["changed" if rel in old else "added"] += 1
689
+ count_chars = sum(len(chunk[2]) for chunk in chunks)
690
+ if pending and (len(texts) + len(chunks) > batch_max_items * EMBED_WORKERS
691
+ or chars + count_chars > batch_max_chars * EMBED_WORKERS):
692
+ flush()
693
+ pending.append((rel, chunks, digest, stat))
694
+ texts.extend(chunk[2] for chunk in chunks)
695
+ chars += count_chars
696
+ if len(texts) >= batch_max_items * EMBED_WORKERS or chars >= batch_max_chars * EMBED_WORKERS:
697
+ flush()
698
+ flush()
699
+ check()
700
+ if full_check or building:
701
+ _set_metadata(conn, {"full_scan_at": time.time()})
702
+ _set_metadata(conn, {"dimensions": dimensions})
703
+ _set_metadata(conn, {'document_extractor_version':document_text.VERSION})
704
+ _set_metadata(conn, {'view_id': view['view_id'], 'git_branch': view.get('branch'),
705
+ 'git_commit': view.get('commit'), 'view_label': view['label']})
706
+ if building or stats["added"] or stats["changed"] or stats["removed"]:
707
+ _set_metadata(conn, {"updated_at": time.time()})
708
+ conn.commit()
709
+ stats.update(generation=generation, dimensions=dimensions,
710
+ embedded_chunks=cache.misses, reused_chunks=cache.hits,
711
+ embedded_characters=cache.characters, view=index_views.public(view),
712
+ files=conn.execute("SELECT count(*) FROM manifest").fetchone()[0],
713
+ chunks=conn.execute("SELECT count(*) FROM chunks").fetchone()[0])
714
+ except BaseException:
715
+ conn.rollback()
716
+ raise
717
+ finally:
718
+ conn.close()
719
+ if cache:
720
+ cache.close()
721
+ check()
722
+ if building and not stats["failed"]:
723
+ if os.path.isfile(target):
724
+ _backup(target, target[:-8] + ".previous.sqlite3")
725
+ atomic_io.atomic_replace(work_path, project_identity.state_path(target, INDEX_DIR))
726
+ stats["ready"] = not bool(stats["failed"])
727
+ stats["path"] = target if stats["ready"] else work_path
728
+ if building or total or stats["removed"] or full_check:
729
+ invalidate_cache(root)
730
+ return stats
731
+
732
+
733
+ def _vector_rows(root, path):
734
+ stat = os.stat(path)
735
+ key = (project_identity.canonical_root(root), path, stat.st_mtime_ns, stat.st_size)
736
+ with _VECTOR_CACHE_LOCK:
737
+ cached = _VECTOR_CACHE.get(key)
738
+ if cached is not None:
739
+ _VECTOR_CACHE.move_to_end(key)
740
+ return cached[0], cached[1], cached[3]
741
+ conn = _readonly(path)
742
+ try:
743
+ meta = _metadata(conn)
744
+ raw = conn.execute("SELECT id,path,start_line,end_line,text,embedding FROM chunks").fetchall()
745
+ finally:
746
+ conn.close()
747
+ rows, norms, byte_count = [], {}, 0
748
+ for chunk_id, file_path, start, end, text, blob in raw:
749
+ vector = _unpack_vector(blob) if blob else array.array("f")
750
+ if vector:
751
+ validate_vector(vector, meta.get("dimensions"))
752
+ norms[chunk_id] = math.sqrt(_sumprod(vector, vector))
753
+ rows.append((chunk_id, file_path, start, end, text, vector))
754
+ byte_count += len(text.encode("utf-8")) + len(blob or b"") + 256
755
+ after = os.stat(path)
756
+ if byte_count <= VECTOR_CACHE_MAX_BYTES and (stat.st_mtime_ns, stat.st_size) == (after.st_mtime_ns, after.st_size):
757
+ with _VECTOR_CACHE_LOCK:
758
+ invalidate_cache(root)
759
+ _VECTOR_CACHE[key] = (rows, meta, byte_count, norms)
760
+ while (len(_VECTOR_CACHE) > VECTOR_CACHE_MAX_PROJECTS or
761
+ sum(item[2] for item in _VECTOR_CACHE.values()) > VECTOR_CACHE_MAX_BYTES):
762
+ _VECTOR_CACHE.popitem(last=False)
763
+ return rows, meta, norms
764
+
765
+
766
+ def _lexical_scored(path, query, rows, limit, offset=0):
767
+ """(chunk_id, score) best first; higher score is a better lexical match."""
768
+ terms = list(dict.fromkeys(re.findall(r"[\w]{2,}", query or "", re.UNICODE)))[:24]
769
+ if not terms:
770
+ return []
771
+ expression = " OR ".join('"' + term.replace('"', '""') + '"' for term in terms)
772
+ conn = _readonly(path)
773
+ try:
774
+ return [(row[0], -row[1]) for row in conn.execute(
775
+ "SELECT rowid, bm25(chunks_fts,3.0,1.0) FROM chunks_fts WHERE chunks_fts MATCH ? "
776
+ "ORDER BY bm25(chunks_fts,3.0,1.0) LIMIT ? OFFSET ?",
777
+ (expression, limit, offset))]
778
+ except sqlite3.OperationalError:
779
+ # FTS5 indisponível em algum Python: mantém recuperação lexical em stdlib.
780
+ ranked = [(sum(term.casefold() in (file_path + " " + text).casefold() for term in terms), chunk_id)
781
+ for chunk_id, file_path, _start, _end, text, _vector in rows]
782
+ return [(chunk_id, score) for score, chunk_id in sorted(ranked, reverse=True)[offset:offset + limit] if score]
783
+ finally:
784
+ conn.close()
785
+
786
+
787
+ def _min_max(pairs):
788
+ if not pairs:
789
+ return {}
790
+ values = [value for _, value in pairs]
791
+ low, high = min(values), max(values)
792
+ return {key: (value - low) / (high - low) if high > low else 1.0 for key, value in pairs}
793
+
794
+
795
+ def search(root, query_vector=None, top_k=20, query=None, model_id=None, include=None):
796
+ """Recuperação vetorial + lexical por fusão de notas relativas, sem rede."""
797
+ path = existing_db_path(root)
798
+ if not path:
799
+ return []
800
+ rows, meta, norms = _vector_rows(root, path)
801
+ if include is not None:
802
+ rows = [row for row in rows if include(row[1])]
803
+ if model_id and meta.get("model_id") != model_id:
804
+ raise IndexCompatibilityError("The index must be rebuilt for the selected model.")
805
+ dimensions = meta.get("dimensions")
806
+ vector_scores = {}
807
+ if query_vector is not None:
808
+ validate_vector(query_vector, dimensions)
809
+ query_norm = math.sqrt(_sumprod(query_vector, query_vector))
810
+ for chunk_id, _path, _start, _end, _text, vector in rows:
811
+ if vector:
812
+ if len(vector) != len(query_vector):
813
+ raise IndexCompatibilityError(f"Incompatible dimensions: query={len(query_vector)}, index={len(vector)}.")
814
+ norm = norms[chunk_id] * query_norm
815
+ vector_scores[chunk_id] = _sumprod(query_vector, vector) / norm if norm else 0.0
816
+ vector_ids = sorted(vector_scores, key=vector_scores.get, reverse=True)
817
+ limit = max(top_k * 4, 40)
818
+ if not query:
819
+ lexical = []
820
+ elif include is None:
821
+ lexical = _lexical_scored(path, query, rows, limit)
822
+ else:
823
+ allowed, lexical, offset, page = {row[0] for row in rows}, [], 0, limit * 10
824
+ while len(lexical) < limit:
825
+ batch = _lexical_scored(path, query, rows, page, offset)
826
+ lexical += [pair for pair in batch if pair[0] in allowed]
827
+ if len(batch) < page:
828
+ break
829
+ offset += page
830
+ lexical = lexical[:limit]
831
+ if lexical:
832
+ vector_part = _min_max([(chunk_id, vector_scores[chunk_id]) for chunk_id in vector_ids[:limit]])
833
+ lexical_part = _min_max(lexical)
834
+ alpha = FUSION_ALPHA if vector_part else 0.0
835
+ scores = {chunk_id: alpha * vector_part.get(chunk_id, 0.0) + (1 - alpha) * lexical_part.get(chunk_id, 0.0)
836
+ for chunk_id in set(vector_part) | set(lexical_part)}
837
+ ids = sorted(scores, key=lambda item: (scores[item], item in lexical_part), reverse=True)
838
+ else:
839
+ scores, ids = vector_scores, vector_ids
840
+ by_id = {row[0]: row for row in rows}
841
+ return [(scores[chunk_id], *by_id[chunk_id][1:5]) for chunk_id in ids[:top_k]]
842
+
843
+
844
+ def remove_index(root):
845
+ """Remove somente arquivos de índice da raiz conhecida, nunca arquivos do projeto."""
846
+ import glob
847
+ removed = []
848
+ for key in set([project_identity.project_id(root), *project_identity.legacy_ids(root)]):
849
+ pattern = re.compile(re.escape(key) + r"(?:\.v-[a-f0-9]{16})?(?:\.previous|\.building-[a-f0-9]+|\.vectors)?\.sqlite3$")
850
+ for path in glob.glob(os.path.join(INDEX_DIR, key + "*.sqlite3")):
851
+ if pattern.fullmatch(os.path.basename(path)):
852
+ safe = project_identity.state_path(path, INDEX_DIR)
853
+ try:
854
+ os.remove(safe)
855
+ except PermissionError as exc:
856
+ raise RuntimeError("The index is open in another process. Close the external reader and try removing it again.") from exc
857
+ removed.append(safe)
858
+ invalidate_cache(root)
859
+ return removed
860
+
861
+
862
+ def copy_project_indexes(root, destination):
863
+ """Copia todas as visões e cache ao reassociar uma raiz, preservando a origem."""
864
+ import glob
865
+ target_key = project_identity.project_id(destination)
866
+ copied = set()
867
+ for key in dict.fromkeys([project_identity.project_id(root), *project_identity.legacy_ids(root)]):
868
+ pattern = re.compile(re.escape(key) + r'((?:\.v-[a-f0-9]{16})?(?:\.previous|\.building-[a-f0-9]+|\.vectors)?\.sqlite3)$')
869
+ for path in glob.glob(os.path.join(INDEX_DIR, key + '*.sqlite3')):
870
+ match = pattern.fullmatch(os.path.basename(path))
871
+ if match:
872
+ target = os.path.join(INDEX_DIR, target_key + match[1])
873
+ if target not in copied:
874
+ if os.path.exists(target):
875
+ raise ValueError('The reassociation target already has data.')
876
+ _backup(path, target)
877
+ copied.add(target)
878
+ return sorted(copied)