vectr 1.0.0__py3-none-any.whl

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 (61) hide show
  1. agent/__init__.py +0 -0
  2. agent/cartographer.py +428 -0
  3. agent/chunk_quality.py +1119 -0
  4. agent/config.py +648 -0
  5. agent/config.yaml +1153 -0
  6. agent/eviction_advisor.py +447 -0
  7. agent/identifier_hint.py +68 -0
  8. agent/indexer/__init__.py +112 -0
  9. agent/indexer/_chunking.py +458 -0
  10. agent/indexer/_constants.py +105 -0
  11. agent/indexer/_core.py +878 -0
  12. agent/indexer/_types.py +157 -0
  13. agent/instance_registry.py +127 -0
  14. agent/llm_client.py +8 -0
  15. agent/model_cache.py +101 -0
  16. agent/prompt_templates.py +31 -0
  17. agent/searcher.py +848 -0
  18. agent/strategy_selector.py +299 -0
  19. agent/symbol_graph/__init__.py +131 -0
  20. agent/symbol_graph/_constants.py +366 -0
  21. agent/symbol_graph/_extraction.py +528 -0
  22. agent/symbol_graph/_graph.py +1878 -0
  23. agent/symbol_graph/_types.py +35 -0
  24. agent/templates/claude_md.md +65 -0
  25. agent/templates/claude_md_search_only.md +27 -0
  26. agent/templates/cursor_mcp.json.template +7 -0
  27. agent/templates/cursor_rules_header.txt +5 -0
  28. agent/templates/hook_no_double_recall.txt +1 -0
  29. agent/templates/mcp.json.template +8 -0
  30. agent/templates/session_start_guidance_default.txt +3 -0
  31. agent/templates/session_start_guidance_hooks_aware.txt +1 -0
  32. agent/templates/tool_loading_guidance_claude.txt +1 -0
  33. agent/templates/tool_loading_guidance_claude_search_only.txt +1 -0
  34. agent/templates/vscode_mcp.json.template +8 -0
  35. agent/tool_necessity_probe.py +177 -0
  36. agent/version_stamp.py +58 -0
  37. agent/watcher.py +515 -0
  38. agent/working_context_store/__init__.py +76 -0
  39. agent/working_context_store/_audit.py +46 -0
  40. agent/working_context_store/_encryption.py +75 -0
  41. agent/working_context_store/_store.py +1130 -0
  42. agent/working_context_store/_types.py +50 -0
  43. api.py +101 -0
  44. app/__init__.py +0 -0
  45. app/models.py +368 -0
  46. app/routes.py +451 -0
  47. app/service.py +1055 -0
  48. integrations/__init__.py +0 -0
  49. integrations/mcp_server/__init__.py +73 -0
  50. integrations/mcp_server/_dispatch.py +782 -0
  51. integrations/mcp_server/_schemas.py +484 -0
  52. integrations/mcp_server/_session.py +86 -0
  53. integrations/vscode_bridge.py +71 -0
  54. integrations/workspace_detect.py +259 -0
  55. main.py +2037 -0
  56. vectr-1.0.0.dist-info/METADATA +37 -0
  57. vectr-1.0.0.dist-info/RECORD +61 -0
  58. vectr-1.0.0.dist-info/WHEEL +5 -0
  59. vectr-1.0.0.dist-info/entry_points.txt +2 -0
  60. vectr-1.0.0.dist-info/licenses/LICENSE +21 -0
  61. vectr-1.0.0.dist-info/top_level.txt +5 -0
agent/indexer/_core.py ADDED
@@ -0,0 +1,878 @@
1
+ """CodeIndexer: ChromaDB-backed index orchestration (chunking + embed + upsert)."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import logging
7
+ import os
8
+ import time
9
+ from concurrent.futures import ThreadPoolExecutor, as_completed
10
+ from pathlib import Path
11
+
12
+ import chromadb
13
+ import numpy as np
14
+
15
+ from agent.chunk_quality import build_purpose_text
16
+ from agent.config import DUAL_VECTOR_ENABLED as _DUAL_VECTOR_ENABLED
17
+ from agent.config import EMBEDDING_DEFAULT_MODEL as _EMBEDDING_DEFAULT_MODEL
18
+ from agent.indexer._constants import (
19
+ EXCLUDED_DIRS,
20
+ _FILE_BATCH_SIZE,
21
+ _EMBED_BATCH_SIZE,
22
+ _UPSERT_BATCH_SIZE,
23
+ _CHUNK_WORKERS,
24
+ _MTIME_CACHE_SCHEMA_KEY,
25
+ INDEXING_SCHEMA_VERSION,
26
+ _EMBED_MODEL_STAMP_FILE,
27
+ )
28
+ from agent.indexer._chunking import chunk_file
29
+ from agent.indexer._types import CodeChunk
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ class CodeIndexer:
35
+ def __init__(
36
+ self,
37
+ workspace_root: str,
38
+ embed_model: str = _EMBEDDING_DEFAULT_MODEL,
39
+ db_path: str | None = None,
40
+ extra_roots: list[str] | None = None,
41
+ ) -> None:
42
+ self.workspace_root = Path(workspace_root).resolve()
43
+ self._extra_roots: list[Path] = [Path(r).resolve() for r in (extra_roots or [])]
44
+ self.embed_model = embed_model
45
+
46
+ db_dir = Path(db_path) if db_path else Path.home() / ".cache" / "vectr" / "db" / self._workspace_hash()
47
+ db_dir.mkdir(parents=True, exist_ok=True)
48
+ self._db_dir = db_dir
49
+
50
+ self._client = chromadb.PersistentClient(path=str(db_dir))
51
+ self._create_collections()
52
+ self._last_indexed: float = 0.0
53
+ self._indexed_files: set[str] = set()
54
+ self._lang_cache: tuple[int, dict[str, dict[str, int]]] | None = None # (chunk_count, per-lang stats) — UPG-3.1/3.3
55
+
56
+ # Deferred: look up get_embed_provider through the package namespace so that
57
+ # test-time monkeypatching of agent.indexer.get_embed_provider is honoured
58
+ # (identical to the original flat-module behaviour where the function lived
59
+ # in the same module namespace that patches target).
60
+ import agent.indexer as _idx
61
+ self._embed_provider = _idx.get_embed_provider(embed_model)
62
+
63
+ def _workspace_hash(self) -> str:
64
+ return hashlib.md5(str(self.workspace_root).encode()).hexdigest()[:12]
65
+
66
+ def _create_collections(self) -> None:
67
+ self._collection = self._client.get_or_create_collection(
68
+ name="code_chunks",
69
+ metadata={
70
+ "hnsw:space": "cosine",
71
+ "hnsw:construction_ef": 200, # default 100 — denser graph, better recall
72
+ "hnsw:search_ef": 100, # default 10 — wider beam search at query time
73
+ "hnsw:M": 32, # default 16 — more neighbours per node
74
+ },
75
+ )
76
+ # ARCH-4: second collection holding the body-stripped "purpose" vector
77
+ # (qualified signature + docstring) for symbol-bearing chunks only —
78
+ # keyed by the SAME chunk_id as `self._collection`. get_or_create_collection
79
+ # means an existing (pre-ARCH-4) workspace transparently gets an empty
80
+ # purpose collection rather than an error; queries against it then return
81
+ # no candidates until the workspace is (re)indexed, which is exactly the
82
+ # graceful body-only fallback the spec calls for.
83
+ self._purpose_collection = self._client.get_or_create_collection(
84
+ name="code_chunks_purpose",
85
+ metadata={
86
+ "hnsw:space": "cosine",
87
+ "hnsw:construction_ef": 200,
88
+ "hnsw:search_ef": 100,
89
+ "hnsw:M": 32,
90
+ },
91
+ )
92
+
93
+ def _recreate_collections(self) -> None:
94
+ """Drop and recreate both vector collections.
95
+
96
+ ChromaDB pins a collection's embedding dimensionality at first insert
97
+ and keeps it even after every entry is deleted, so an embedding-model
98
+ swap to a DIFFERENT-dimension model would crash the per-file
99
+ delete-then-reinsert rebuild (`force=True`) against the old
100
+ collection. On an embed-model stamp mismatch the collections
101
+ themselves must be dropped, not just their contents — this also
102
+ guarantees no old-model vector can survive regardless of any
103
+ per-file bookkeeping.
104
+ """
105
+ for name in ("code_chunks", "code_chunks_purpose"):
106
+ try:
107
+ self._client.delete_collection(name)
108
+ except Exception:
109
+ pass # collection may not exist yet — nothing to drop
110
+ self._create_collections()
111
+ self._lang_cache = None
112
+
113
+ @property
114
+ def all_roots(self) -> list[Path]:
115
+ """All workspace roots: primary first, then extra roots in order."""
116
+ return [self.workspace_root] + self._extra_roots
117
+
118
+ # ------------------------------------------------------------------
119
+ # Public API
120
+ # ------------------------------------------------------------------
121
+
122
+ def index_workspace(
123
+ self, gitignore_patterns: list[str] | None = None, force: bool = False,
124
+ ) -> tuple[int, int]:
125
+ """Walk workspace, index all supported files. Returns (files_indexed, chunks_total).
126
+
127
+ Three-phase pipeline:
128
+ 1. Parallel chunking — ThreadPoolExecutor, tree-sitter releases GIL
129
+ 2. Global batch embed — 256-chunk batches across all files (vs 64 per-file)
130
+ 3. Incremental skip — files unchanged since last index are skipped via mtime cache
131
+
132
+ Before indexing, chunks for files no longer in the walk set (excluded via
133
+ .vectrignore/.gitignore, deleted, or moved out of all roots) are pruned so
134
+ the collection never carries orphaned chunks (UPG-8.4).
135
+
136
+ force=True ignores the mtime cache and re-chunks/re-embeds every file,
137
+ replacing its chunks — a clean rebuild that recovers from any cache/
138
+ collection desync without a manual cache wipe (UPG-8.6).
139
+ """
140
+ from integrations.workspace_detect import (
141
+ should_index_file, get_gitignore_patterns, get_vectrignore_dirs,
142
+ get_vectrignore_file_globs, get_vectrignore_regexes,
143
+ )
144
+
145
+ # Collect candidate files across all roots; each root gets its own
146
+ # gitignore/vectrignore patterns so per-project exclusions are respected.
147
+ all_files: list[Path] = []
148
+ for root in self.all_roots:
149
+ root_patterns = gitignore_patterns or get_gitignore_patterns(str(root))
150
+ # UPG-13.3: .vectrignore file-glob entries (e.g. "*.generated.py") are
151
+ # matched the same way as gitignore patterns — additive, on top of the
152
+ # existing bare directory-name exclusions below.
153
+ root_patterns = [*root_patterns, *get_vectrignore_file_globs(str(root))]
154
+ vectrignore_dirs = get_vectrignore_dirs(str(root))
155
+ # UPG-EXCLUDE-REGEX: `re:<pattern>` .vectrignore entries, matched
156
+ # against each file's workspace-relative path — additive, on top
157
+ # of the dir-name and glob exclusions above.
158
+ vectrignore_regexes = get_vectrignore_regexes(str(root))
159
+ all_excluded = EXCLUDED_DIRS | vectrignore_dirs
160
+ for dirpath, dirnames, filenames in os.walk(root):
161
+ dirnames[:] = [d for d in dirnames if d not in all_excluded and not d.startswith(".")]
162
+ for fname in filenames:
163
+ fpath = Path(dirpath) / fname
164
+ if should_index_file(str(fpath), root_patterns, extra_excluded_dirs=vectrignore_dirs,
165
+ workspace_root=str(root),
166
+ extra_excluded_regexes=vectrignore_regexes):
167
+ all_files.append(fpath)
168
+
169
+ should_index_paths = {str(f) for f in all_files}
170
+
171
+ # Reconcile: drop chunks for files no longer indexable (excluded via
172
+ # .vectrignore/.gitignore, deleted, or moved out of all roots). Without
173
+ # this, editing .vectrignore stops *new* indexing but leaves the old
174
+ # chunks in the collection forever. (UPG-8.4)
175
+ mtime_cache = self._load_mtime_cache()
176
+ pruned = self._prune_orphaned_chunks(should_index_paths, mtime_cache)
177
+
178
+ # UPG-EMBEDDER-SWAP-GRANITE: vectors from two different embedding
179
+ # models must never silently coexist in one collection (a same-
180
+ # dimension model swap is exactly the silent-corruption case — cosine
181
+ # distances between the two spaces are meaningless even though
182
+ # ChromaDB's HNSW index accepts them without error). A stamp mismatch
183
+ # — including a MISSING stamp, treated as a mismatch rather than a
184
+ # match since a pre-existing index from an older vectr version
185
+ # predates this mechanism — drops and recreates both collections
186
+ # (ChromaDB pins dimensionality at first insert, so a different-
187
+ # dimension model would otherwise crash the reinsert) and forces the
188
+ # full-rebuild path: every file is re-chunked/re-embedded into the
189
+ # fresh collections, so no old-model vector can survive.
190
+ stored_embed_model = self._stored_embed_model()
191
+ if stored_embed_model != self.embed_model:
192
+ if not force:
193
+ logger.warning(
194
+ "Embedding model changed (index built with %r, now configured "
195
+ "with %r) — forcing a full vector index rebuild so vectors "
196
+ "from the two models never mix in one collection",
197
+ stored_embed_model, self.embed_model,
198
+ )
199
+ force = True
200
+ self._recreate_collections()
201
+
202
+ if force:
203
+ # Clean rebuild: ignore the mtime cache so every file is re-indexed,
204
+ # and (in Phase 2) its existing chunks are deleted first. (UPG-8.6)
205
+ mtime_cache = {}
206
+
207
+ # Incremental: split into files to index vs unchanged files to skip
208
+ to_index: list[tuple[Path, float]] = []
209
+ for f in all_files:
210
+ try:
211
+ mtime = f.stat().st_mtime
212
+ except OSError:
213
+ continue
214
+ cached = mtime_cache.get(str(f))
215
+ if cached is None or cached != mtime:
216
+ to_index.append((f, mtime))
217
+ else:
218
+ self._indexed_files.add(str(f)) # already indexed — track in memory
219
+
220
+ if not to_index:
221
+ if pruned:
222
+ self._save_mtime_cache(mtime_cache) # persist orphan removals
223
+ self._write_embed_model_stamp() # cheap no-op when already current
224
+ logger.info("All %d files up to date — nothing to re-index", len(all_files))
225
+ self._last_indexed = time.time()
226
+ return len(self._indexed_files), self._collection.count()
227
+
228
+ logger.info(
229
+ "Indexing %d/%d files (%d unchanged, skipped)...",
230
+ len(to_index), len(all_files), len(all_files) - len(to_index),
231
+ )
232
+
233
+ # Phase 1: parallel chunking
234
+ all_chunks: list[CodeChunk] = []
235
+ new_mtimes: dict[str, float] = {}
236
+
237
+ def _safe_chunk(item: tuple[Path, float]) -> tuple[list[CodeChunk], str, float]:
238
+ fpath, mtime = item
239
+ try:
240
+ return chunk_file(str(fpath)), str(fpath), mtime
241
+ except Exception:
242
+ return [], str(fpath), mtime
243
+
244
+ with ThreadPoolExecutor(max_workers=_CHUNK_WORKERS) as pool:
245
+ futures = {pool.submit(_safe_chunk, item): item for item in to_index}
246
+ done = 0
247
+ for fut in as_completed(futures):
248
+ chunks, fpath_str, mtime = fut.result()
249
+ seen: set[str] = set()
250
+ for c in chunks:
251
+ if c.chunk_id not in seen:
252
+ seen.add(c.chunk_id)
253
+ all_chunks.append(c)
254
+ self._indexed_files.add(fpath_str)
255
+ new_mtimes[fpath_str] = mtime
256
+ done += 1
257
+ if done % 50 == 0 or done == len(to_index):
258
+ logger.info(" chunked %d/%d files (%d chunks so far)...",
259
+ done, len(to_index), len(all_chunks))
260
+
261
+ if not all_chunks:
262
+ self._last_indexed = time.time()
263
+ return len(self._indexed_files), self._collection.count()
264
+
265
+ # Phase 2: delete stale chunks for re-indexed files (no-op for brand-new files).
266
+ # Under force, mtime_cache is empty, so delete by file_path unconditionally
267
+ # to avoid leaving stale chunks whose ids no longer match (UPG-8.6). The
268
+ # purpose collection (ARCH-4) is keyed by the same file_path metadata, so
269
+ # its stale entries are dropped alongside the body collection's.
270
+ phase_start = time.time()
271
+ for fpath_str in new_mtimes:
272
+ if force or fpath_str in mtime_cache: # previously indexed → delete old chunks
273
+ try:
274
+ existing = self._collection.get(where={"file_path": fpath_str})
275
+ if existing["ids"]:
276
+ self._collection.delete(ids=existing["ids"])
277
+ except Exception:
278
+ pass
279
+ try:
280
+ existing_p = self._purpose_collection.get(where={"file_path": fpath_str})
281
+ if existing_p["ids"]:
282
+ self._purpose_collection.delete(ids=existing_p["ids"])
283
+ except Exception:
284
+ pass
285
+ logger.info(" stale-chunk sweep done: %d files in %.0fs",
286
+ len(new_mtimes), time.time() - phase_start)
287
+
288
+ # Phase 3: global batched embed + upsert
289
+ # Embed in large batches (256) for BLAS efficiency, upsert in smaller batches (100)
290
+ # to stay within SQLite's 999-variable limit (6 metadata fields × 100 rows = 600).
291
+ ids = [c.chunk_id for c in all_chunks]
292
+ documents = [c.content for c in all_chunks]
293
+ metadatas = [
294
+ {
295
+ "file_path": c.file_path,
296
+ "language": c.language,
297
+ "node_type": c.node_type,
298
+ "start_line": c.start_line,
299
+ "end_line": c.end_line,
300
+ "symbol_name": c.symbol_name,
301
+ }
302
+ for c in all_chunks
303
+ ]
304
+
305
+ total = len(ids)
306
+ all_embeddings: list[list[float]] = []
307
+ phase_start = time.time()
308
+ for i in range(0, total, _EMBED_BATCH_SIZE):
309
+ batch_docs = documents[i: i + _EMBED_BATCH_SIZE]
310
+ all_embeddings.extend(self._embed_provider.embed(batch_docs))
311
+ if i % (10 * _EMBED_BATCH_SIZE) == 0 and i > 0:
312
+ logger.info(" embedded %d/%d chunks...", i, total)
313
+ logger.info(" content embed done: %d chunks in %.0fs",
314
+ total, time.time() - phase_start)
315
+
316
+ phase_start = time.time()
317
+ for i in range(0, total, _UPSERT_BATCH_SIZE):
318
+ self._collection.upsert(
319
+ ids=ids[i: i + _UPSERT_BATCH_SIZE],
320
+ documents=documents[i: i + _UPSERT_BATCH_SIZE],
321
+ metadatas=metadatas[i: i + _UPSERT_BATCH_SIZE],
322
+ embeddings=all_embeddings[i: i + _UPSERT_BATCH_SIZE],
323
+ )
324
+ if i % (50 * _UPSERT_BATCH_SIZE) == 0 and i > 0:
325
+ logger.info(" upserted %d/%d chunks...", i, total)
326
+ logger.info(" content upsert done: %d chunks in %.0fs",
327
+ total, time.time() - phase_start)
328
+
329
+ self._upsert_purpose_vectors(all_chunks)
330
+
331
+ logger.info("Indexed %d chunks from %d files", total, len(to_index))
332
+
333
+ # Persist mtime cache
334
+ mtime_cache.update(new_mtimes)
335
+ self._save_mtime_cache(mtime_cache)
336
+ self._write_embed_model_stamp()
337
+
338
+ self._last_indexed = time.time()
339
+ return len(self._indexed_files), self._collection.count()
340
+
341
+ def index_file(self, file_path: str) -> int:
342
+ """Chunk and embed a single file. Returns number of chunks indexed."""
343
+ chunks = chunk_file(file_path)
344
+ if not chunks:
345
+ return 0
346
+
347
+ # Deduplicate by chunk_id (AST nodes on the same line range can collide in
348
+ # minified files like jquery.min.js where many nodes share line 2-2)
349
+ seen_ids: set[str] = set()
350
+ deduped: list = []
351
+ for c in chunks:
352
+ if c.chunk_id not in seen_ids:
353
+ seen_ids.add(c.chunk_id)
354
+ deduped.append(c)
355
+ chunks = deduped
356
+
357
+ # Remove old chunks for this file before re-indexing
358
+ self.delete_file(file_path)
359
+
360
+ ids = [c.chunk_id for c in chunks]
361
+ documents = [c.content for c in chunks]
362
+ metadatas = [
363
+ {
364
+ "file_path": c.file_path,
365
+ "language": c.language,
366
+ "node_type": c.node_type,
367
+ "start_line": c.start_line,
368
+ "end_line": c.end_line,
369
+ "symbol_name": c.symbol_name,
370
+ }
371
+ for c in chunks
372
+ ]
373
+
374
+ # Embed in batches
375
+ all_embeddings: list[list[float]] = []
376
+ for i in range(0, len(documents), _FILE_BATCH_SIZE):
377
+ batch = documents[i: i + _FILE_BATCH_SIZE]
378
+ all_embeddings.extend(self._embed_provider.embed(batch))
379
+ assert len(all_embeddings) == len(ids), (
380
+ f"Embed provider returned {len(all_embeddings)} embeddings for {len(ids)} chunks"
381
+ )
382
+
383
+ for i in range(0, len(ids), _FILE_BATCH_SIZE):
384
+ self._collection.upsert(
385
+ ids=ids[i: i + _FILE_BATCH_SIZE],
386
+ documents=documents[i: i + _FILE_BATCH_SIZE],
387
+ metadatas=metadatas[i: i + _FILE_BATCH_SIZE],
388
+ embeddings=all_embeddings[i: i + _FILE_BATCH_SIZE],
389
+ )
390
+
391
+ self._upsert_purpose_vectors(chunks)
392
+
393
+ self._indexed_files.add(file_path)
394
+ return len(chunks)
395
+
396
+ def delete_file(self, file_path: str) -> None:
397
+ """Remove all chunks belonging to a file (body + purpose collections)."""
398
+ try:
399
+ existing = self._collection.get(where={"file_path": file_path})
400
+ if existing["ids"]:
401
+ self._collection.delete(ids=existing["ids"])
402
+ self._indexed_files.discard(file_path)
403
+ except Exception:
404
+ pass
405
+ try:
406
+ existing_p = self._purpose_collection.get(where={"file_path": file_path})
407
+ if existing_p["ids"]:
408
+ self._purpose_collection.delete(ids=existing_p["ids"])
409
+ except Exception:
410
+ pass
411
+
412
+ # ------------------------------------------------------------------
413
+ # Purpose vectors (ARCH-4 dual-vector pool entry)
414
+ # ------------------------------------------------------------------
415
+
416
+ def _upsert_purpose_vectors(self, chunks: list[CodeChunk]) -> None:
417
+ """Embed + upsert the body-stripped purpose text for symbol chunks.
418
+
419
+ Skipped entirely when `DUAL_VECTOR_ENABLED` is False (config, default
420
+ on) — reduces to the pre-ARCH-4 body-only index. Non-symbol chunks
421
+ (`build_purpose_text` returns None) are not written — the purpose
422
+ collection stays a strict subset of the body collection's ids.
423
+ """
424
+ if not _DUAL_VECTOR_ENABLED or not chunks:
425
+ return
426
+ ids: list[str] = []
427
+ documents: list[str] = []
428
+ metadatas: list[dict] = []
429
+ for c in chunks:
430
+ purpose_text = build_purpose_text(c.content, c.symbol_name, c.node_type, c.language)
431
+ if purpose_text is None:
432
+ continue
433
+ ids.append(c.chunk_id)
434
+ documents.append(purpose_text)
435
+ metadatas.append({
436
+ "file_path": c.file_path,
437
+ "language": c.language,
438
+ "node_type": c.node_type,
439
+ "start_line": c.start_line,
440
+ "end_line": c.end_line,
441
+ "symbol_name": c.symbol_name,
442
+ })
443
+ if not ids:
444
+ return
445
+
446
+ total = len(ids)
447
+ all_embeddings: list[list[float]] = []
448
+ phase_start = time.time()
449
+ for i in range(0, total, _EMBED_BATCH_SIZE):
450
+ all_embeddings.extend(self._embed_provider.embed(documents[i: i + _EMBED_BATCH_SIZE]))
451
+ if i % (10 * _EMBED_BATCH_SIZE) == 0 and i > 0:
452
+ logger.info(" purpose-embedded %d/%d symbol chunks...", i, total)
453
+ logger.info(" purpose embed done: %d symbol chunks in %.0fs",
454
+ total, time.time() - phase_start)
455
+
456
+ phase_start = time.time()
457
+ for i in range(0, total, _UPSERT_BATCH_SIZE):
458
+ self._purpose_collection.upsert(
459
+ ids=ids[i: i + _UPSERT_BATCH_SIZE],
460
+ documents=documents[i: i + _UPSERT_BATCH_SIZE],
461
+ metadatas=metadatas[i: i + _UPSERT_BATCH_SIZE],
462
+ embeddings=all_embeddings[i: i + _UPSERT_BATCH_SIZE],
463
+ )
464
+ logger.info(" purpose upsert done: %d symbol chunks in %.0fs",
465
+ total, time.time() - phase_start)
466
+
467
+ # ------------------------------------------------------------------
468
+ # mtime cache — tracks file modification times for incremental indexing
469
+ # ------------------------------------------------------------------
470
+
471
+ def _mtime_cache_path(self) -> Path:
472
+ # Co-locate the mtime cache with the ChromaDB dir so the two always clear
473
+ # together. Previously this hardcoded ~/.cache/vectr/db/<hash> regardless
474
+ # of the db_path override, so when the collection lived elsewhere (e.g. the
475
+ # service's ~/.cache/vectr/<hash>/chroma) the cache could desync — a stale
476
+ # cache then reported "nothing to re-index" against an empty collection,
477
+ # leaving 0 chunks. (UPG-8.5)
478
+ return self._db_dir / "index_cache.json"
479
+
480
+ def _load_mtime_cache(self) -> dict[str, float]:
481
+ path = self._mtime_cache_path()
482
+ try:
483
+ cache = json.loads(path.read_text(encoding="utf-8"))
484
+ except (OSError, json.JSONDecodeError):
485
+ return {}
486
+ # Schema-version gate: a cache written by an older INDEXING_SCHEMA_VERSION
487
+ # (e.g. before ARCH-4's purpose vector existed) is treated as a cold
488
+ # cache — every file falls into `to_index` on the next index_workspace()
489
+ # and is fully re-chunked/re-embedded, the same recovery path force=True
490
+ # uses. This is what makes a pipeline change (new derived vector, new
491
+ # chunk content) reach an already-indexed workspace without a manual
492
+ # cache wipe. A cache with no version key at all (pre-ARCH-4, unversioned)
493
+ # is also treated as stale.
494
+ stored_version = cache.pop(_MTIME_CACHE_SCHEMA_KEY, None)
495
+ if stored_version != INDEXING_SCHEMA_VERSION:
496
+ return {}
497
+ return cache
498
+
499
+ def _save_mtime_cache(self, cache: dict[str, float]) -> None:
500
+ path = self._mtime_cache_path()
501
+ try:
502
+ path.parent.mkdir(parents=True, exist_ok=True)
503
+ payload = dict(cache)
504
+ payload[_MTIME_CACHE_SCHEMA_KEY] = INDEXING_SCHEMA_VERSION
505
+ path.write_text(json.dumps(payload), encoding="utf-8")
506
+ except OSError:
507
+ pass
508
+
509
+ # ------------------------------------------------------------------
510
+ # Embedding-model version stamp (UPG-EMBEDDER-SWAP-GRANITE) — see
511
+ # _EMBED_MODEL_STAMP_FILE's docstring for why this is a separate file
512
+ # from the mtime cache rather than another key inside it.
513
+ # ------------------------------------------------------------------
514
+
515
+ def _embed_model_stamp_path(self) -> Path:
516
+ return self._db_dir / _EMBED_MODEL_STAMP_FILE
517
+
518
+ def _stored_embed_model(self) -> str | None:
519
+ """The embed_model that built the collection's CURRENT vectors, or
520
+ None if no stamp exists yet (fresh index, or a pre-existing index
521
+ from a vectr version that predates this mechanism — callers must
522
+ treat None as a mismatch, not a match)."""
523
+ path = self._embed_model_stamp_path()
524
+ try:
525
+ data = json.loads(path.read_text(encoding="utf-8"))
526
+ except (OSError, json.JSONDecodeError):
527
+ return None
528
+ model = data.get("embed_model")
529
+ return str(model) if model else None
530
+
531
+ def _write_embed_model_stamp(self) -> None:
532
+ path = self._embed_model_stamp_path()
533
+ try:
534
+ path.parent.mkdir(parents=True, exist_ok=True)
535
+ path.write_text(json.dumps({"embed_model": self.embed_model}), encoding="utf-8")
536
+ except OSError:
537
+ pass
538
+
539
+ # ------------------------------------------------------------------
540
+ # Orphan pruning — reconcile the collection against the current walk
541
+ # ------------------------------------------------------------------
542
+
543
+ def _collection_file_paths(self) -> set[str]:
544
+ """Distinct file_path values currently stored in the collection.
545
+
546
+ Metadata-only paginated scan (no documents/embeddings loaded)."""
547
+ _PAGE = 1000
548
+ paths: set[str] = set()
549
+ offset = 0
550
+ while True:
551
+ page = self._collection.get(include=["metadatas"], limit=_PAGE, offset=offset)
552
+ ids = page["ids"]
553
+ if not ids:
554
+ break
555
+ for meta in page["metadatas"]:
556
+ fp = meta.get("file_path")
557
+ if fp:
558
+ paths.add(fp)
559
+ offset += len(ids)
560
+ if len(ids) < _PAGE:
561
+ break
562
+ return paths
563
+
564
+ def _prune_orphaned_chunks(
565
+ self, should_index_paths: set[str], mtime_cache: dict[str, float],
566
+ ) -> int:
567
+ """Delete chunks for indexed files no longer in the walk set. Returns count.
568
+
569
+ Files become orphaned when they're newly excluded (.vectrignore/.gitignore),
570
+ deleted, or moved out of all roots. `mtime_cache` is mutated in place so the
571
+ pruned entries don't linger and re-trigger a phantom skip. (UPG-8.4)
572
+ """
573
+ try:
574
+ indexed_paths = self._collection_file_paths()
575
+ except Exception:
576
+ return 0
577
+ orphaned = indexed_paths - should_index_paths
578
+ for path in orphaned:
579
+ self.delete_file(path)
580
+ mtime_cache.pop(path, None)
581
+ if orphaned:
582
+ logger.info("Pruned chunks for %d orphaned/excluded file(s)", len(orphaned))
583
+ return len(orphaned)
584
+
585
+ # ------------------------------------------------------------------
586
+ # Stats
587
+ # ------------------------------------------------------------------
588
+
589
+ @property
590
+ def total_chunks(self) -> int:
591
+ return self._collection.count()
592
+
593
+ @property
594
+ def last_indexed_ts(self) -> float:
595
+ return self._last_indexed
596
+
597
+ @property
598
+ def indexed_file_count(self) -> int:
599
+ return len(self._indexed_files)
600
+
601
+ @property
602
+ def indexed_file_paths(self) -> list[str]:
603
+ """Return a copy of all file paths currently in the index."""
604
+ return list(self._indexed_files)
605
+
606
+ def indexed_language_stats(self) -> dict[str, dict[str, int]]:
607
+ """Per-language coverage in the collection: `{lang: {"files", "chunks"}}`.
608
+
609
+ The ground truth for what the index actually contains — derived from chunk
610
+ metadata, not a fixed allow-list. One metadata-only paginated scan, cached
611
+ against the live chunk count so it only rescans when the index changes.
612
+ UPG-3.1 (which languages exist) and UPG-3.3 (per-language coverage +
613
+ symbol availability) both read from this single source.
614
+ """
615
+ count = self.total_chunks
616
+ if self._lang_cache is not None and self._lang_cache[0] == count:
617
+ return self._lang_cache[1]
618
+ _PAGE = 1000
619
+ chunks: dict[str, int] = {}
620
+ files: dict[str, set[str]] = {}
621
+ offset = 0
622
+ while True:
623
+ page = self._collection.get(include=["metadatas"], limit=_PAGE, offset=offset)
624
+ ids = page["ids"]
625
+ if not ids:
626
+ break
627
+ for meta in page["metadatas"]:
628
+ lang = meta.get("language")
629
+ if not lang:
630
+ continue
631
+ chunks[lang] = chunks.get(lang, 0) + 1
632
+ fp = meta.get("file_path")
633
+ if fp:
634
+ files.setdefault(lang, set()).add(fp)
635
+ offset += len(ids)
636
+ if len(ids) < _PAGE:
637
+ break
638
+ stats = {
639
+ lang: {"files": len(files.get(lang, ())), "chunks": n}
640
+ for lang, n in chunks.items()
641
+ }
642
+ self._lang_cache = (count, stats)
643
+ return stats
644
+
645
+ def indexed_languages(self) -> list[str]:
646
+ """Distinct, sorted `language` values currently in the collection (UPG-3.1)."""
647
+ return sorted(self.indexed_language_stats())
648
+
649
+ def embed_query(self, text: str) -> list[float]:
650
+ """Embed a single search-query string. Public method for external callers.
651
+
652
+ Uses the embed provider's query mode (`embed_query`), not `embed` — for
653
+ asymmetric models (the default arctic-embed) this applies the model's
654
+ registered query prompt, which is required for correct dense retrieval and
655
+ must never be applied on the document/indexing side.
656
+ """
657
+ return self._embed_provider.embed_query([text])[0]
658
+
659
+ def embed_query_batch(self, texts: list[str]) -> list[list[float]]:
660
+ """Embed a batch of search-query strings using the provider's query mode.
661
+
662
+ For working-memory recall, where a query is matched against previously
663
+ stored note embeddings — same query/document asymmetry as code search.
664
+ """
665
+ return self._embed_provider.embed_query(texts)
666
+
667
+ def embed_texts(self, texts: list[str]) -> list[list[float]]:
668
+ """Embed a batch of texts (document/indexing side) using the configured
669
+ embed provider. Never applies a query prompt — for chunk/note content being
670
+ stored, not for search queries."""
671
+ return self._embed_provider.embed(texts)
672
+
673
+ @property
674
+ def chroma_client(self):
675
+ """The underlying ChromaDB client — shared with the working memory collection."""
676
+ return self._client
677
+
678
+ def get_all_documents(self) -> tuple[list[str], list[str], list[dict]]:
679
+ """Return (ids, documents, metadatas) for all stored chunks — used by BM25 index.
680
+
681
+ Paginates in batches of 500 to avoid ChromaDB's SQLite variable limit (~999).
682
+ """
683
+ _PAGE = 500
684
+ all_ids: list[str] = []
685
+ all_docs: list[str] = []
686
+ all_meta: list[dict] = []
687
+ offset = 0
688
+ while True:
689
+ page = self._collection.get(
690
+ include=["documents", "metadatas"],
691
+ limit=_PAGE,
692
+ offset=offset,
693
+ )
694
+ batch_ids = page["ids"]
695
+ if not batch_ids:
696
+ break
697
+ all_ids.extend(batch_ids)
698
+ all_docs.extend(page["documents"])
699
+ all_meta.extend(page["metadatas"])
700
+ offset += len(batch_ids)
701
+ if len(batch_ids) < _PAGE:
702
+ break
703
+ return all_ids, all_docs, all_meta
704
+
705
+ def query_vector(
706
+ self,
707
+ query_embedding: list[float],
708
+ n_results: int = 10,
709
+ language: str | None = None,
710
+ languages: list[str] | None = None,
711
+ ) -> dict:
712
+ # `languages` (a set/list) takes precedence over a single `language`:
713
+ # restricts the vector search to any of the given languages via an $in
714
+ # filter. Used by the doc-intent pool reservation (UPG-15.13) to fetch
715
+ # documentation-prose chunks that embed below the unfiltered fetch depth.
716
+ if languages:
717
+ where = {"language": {"$in": list(languages)}}
718
+ elif language:
719
+ where = {"language": language}
720
+ else:
721
+ where = None
722
+ return self._collection.query(
723
+ query_embeddings=[query_embedding],
724
+ n_results=min(n_results, max(1, self._collection.count())),
725
+ where=where,
726
+ include=["documents", "metadatas", "distances"],
727
+ )
728
+
729
+ def get_chunk_cosine_similarities(
730
+ self, query_embedding: list[float], chunk_ids: list[str]
731
+ ) -> dict[str, float]:
732
+ """Return cosine similarity scores for a specific set of chunk IDs.
733
+
734
+ Used by the forced-inclusion relevance gate (UPG-11.12) to check vector
735
+ similarity for non-compound candidates that are not in the natural pool.
736
+ ChromaDB embeddings are L2-normalised by default, so cosine = dot product.
737
+
738
+ Returns a dict {chunk_id: cosine_similarity}. Chunk IDs not found in the
739
+ collection are absent from the result (similarity treated as 0.0 by caller).
740
+ """
741
+ if not chunk_ids or not query_embedding:
742
+ return {}
743
+ try:
744
+ batch = self._collection.get(ids=chunk_ids, include=["embeddings"])
745
+ except Exception:
746
+ return {}
747
+ result: dict[str, float] = {}
748
+ import numpy as np
749
+ q = np.asarray(query_embedding, dtype=np.float32)
750
+ q_norm = float(np.linalg.norm(q))
751
+ if q_norm == 0.0:
752
+ return result
753
+ # batch["embeddings"] may be a numpy array — avoid truthiness check on it.
754
+ embeddings_raw = batch.get("embeddings")
755
+ ids_raw = batch.get("ids")
756
+ if embeddings_raw is None or ids_raw is None:
757
+ return result
758
+ embeddings_list = list(embeddings_raw) if not isinstance(embeddings_raw, list) else embeddings_raw
759
+ ids_list = list(ids_raw) if not isinstance(ids_raw, list) else ids_raw
760
+ for cid, emb_raw in zip(ids_list, embeddings_list):
761
+ if emb_raw is None:
762
+ continue
763
+ emb = np.asarray(emb_raw, dtype=np.float32)
764
+ if emb.size == 0:
765
+ continue
766
+ dot = float(np.dot(q, emb))
767
+ e_norm = float(np.linalg.norm(emb))
768
+ if e_norm == 0.0:
769
+ continue
770
+ result[cid] = dot / (q_norm * e_norm)
771
+ return result
772
+
773
+ def query_vector_purpose(
774
+ self,
775
+ query_embedding: list[float],
776
+ n_results: int = 10,
777
+ language: str | None = None,
778
+ languages: list[str] | None = None,
779
+ ) -> dict:
780
+ """Query the purpose-vector collection (ARCH-4 dual-vector pool entry).
781
+
782
+ Mirrors `query_vector` but against `self._purpose_collection`, which only
783
+ holds symbol-bearing chunks (see `_upsert_purpose_vectors`). A workspace
784
+ indexed before ARCH-4 shipped has an empty purpose collection — `count()`
785
+ guards against ChromaDB raising on a query against zero rows, so old
786
+ indexes degrade gracefully to body-only results until reindexed.
787
+ """
788
+ count = self._purpose_collection.count()
789
+ if count == 0:
790
+ return {"ids": [[]], "distances": [[]], "documents": [[]], "metadatas": [[]]}
791
+ if languages:
792
+ where = {"language": {"$in": list(languages)}}
793
+ elif language:
794
+ where = {"language": language}
795
+ else:
796
+ where = None
797
+ return self._purpose_collection.query(
798
+ query_embeddings=[query_embedding],
799
+ n_results=min(n_results, max(1, count)),
800
+ where=where,
801
+ include=["documents", "metadatas", "distances"],
802
+ )
803
+
804
+ def fetch_chunks(self, ids: list[str]) -> list[dict]:
805
+ """Deterministic re-fetch by chunk id (UPG-CTX-EVICT part a).
806
+
807
+ A direct ``self._collection.get(ids=...)`` — no embedding, no rerank,
808
+ no ranking of any kind. Every rendered search result carries its
809
+ chunk's exact ChromaDB id (``file_path:start_line-end_line``); this is
810
+ the counterpart lookup that restores the full chunk from that id
811
+ alone, so a chunk cleared from the caller's context (by harness tool-
812
+ result eviction, ``/compact``, or an API context-editing tombstone)
813
+ never requires a re-search or a blind file re-read.
814
+
815
+ Returns one dict per requested id, in REQUEST order (Chroma's own
816
+ ``get(ids=...)`` does not preserve order and silently omits ids it
817
+ doesn't have — both are corrected here):
818
+ {"id": ..., "found": True, "file_path": ..., "start_line": ...,
819
+ "end_line": ..., "symbol_name": ..., "language": ..., "content": ...}
820
+ {"id": ..., "found": False}
821
+
822
+ Raises ValueError if `ids` exceeds FETCH_MAX_IDS_PER_CALL.
823
+ """
824
+ from agent.config import FETCH_MAX_IDS_PER_CALL
825
+ if len(ids) > FETCH_MAX_IDS_PER_CALL:
826
+ raise ValueError(
827
+ f"Too many ids requested ({len(ids)}) — vectr_fetch accepts at "
828
+ f"most {FETCH_MAX_IDS_PER_CALL} per call."
829
+ )
830
+ if not ids:
831
+ return []
832
+ batch = self._collection.get(ids=ids, include=["documents", "metadatas"])
833
+ found_ids = batch.get("ids") or []
834
+ found_docs = batch.get("documents") or []
835
+ found_metas = batch.get("metadatas") or []
836
+ by_id: dict[str, tuple[str, dict]] = {
837
+ cid: (doc, meta) for cid, doc, meta in zip(found_ids, found_docs, found_metas)
838
+ }
839
+ results: list[dict] = []
840
+ for requested_id in ids:
841
+ hit = by_id.get(requested_id)
842
+ if hit is None:
843
+ results.append({"id": requested_id, "found": False})
844
+ continue
845
+ doc, meta = hit
846
+ results.append({
847
+ "id": requested_id,
848
+ "found": True,
849
+ "file_path": meta.get("file_path", ""),
850
+ "start_line": int(meta.get("start_line", 0)),
851
+ "end_line": int(meta.get("end_line", 0)),
852
+ "symbol_name": meta.get("symbol_name", ""),
853
+ "language": meta.get("language", ""),
854
+ "content": doc,
855
+ })
856
+ return results
857
+
858
+ def get_chunk_documents(self, chunk_ids: list[str]) -> dict[str, tuple[str, dict]]:
859
+ """Batch-fetch (document, metadata) from the body collection for given ids.
860
+
861
+ Used to backfill body content/metadata for chunk ids that were only
862
+ discovered via the purpose-vector query (ARCH-4) and therefore did not
863
+ come back from the body `query_vector` call in the same search pass.
864
+ Ids not found in the body collection are absent from the result.
865
+ """
866
+ if not chunk_ids:
867
+ return {}
868
+ try:
869
+ batch = self._collection.get(ids=chunk_ids, include=["documents", "metadatas"])
870
+ except Exception:
871
+ return {}
872
+ ids_raw = batch.get("ids") or []
873
+ docs_raw = batch.get("documents") or []
874
+ metas_raw = batch.get("metadatas") or []
875
+ return {
876
+ cid: (doc, meta)
877
+ for cid, doc, meta in zip(ids_raw, docs_raw, metas_raw)
878
+ }