contextual-engine 0.1.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 (60) hide show
  1. contextual/__init__.py +18 -0
  2. contextual/__main__.py +11 -0
  3. contextual/cli.py +339 -0
  4. contextual/cli_docs.py +685 -0
  5. contextual/config.py +7 -0
  6. contextual/core/__init__.py +11 -0
  7. contextual/core/errors.py +470 -0
  8. contextual/core/models.py +590 -0
  9. contextual/docs/__init__.py +66 -0
  10. contextual/docs/chunker.py +550 -0
  11. contextual/docs/pipeline.py +513 -0
  12. contextual/docs/retrieval.py +654 -0
  13. contextual/docs/watcher.py +265 -0
  14. contextual/embedding/__init__.py +87 -0
  15. contextual/embedding/cache.py +455 -0
  16. contextual/embedding/embedder.py +414 -0
  17. contextual/embedding/helpers.py +252 -0
  18. contextual/git/__init__.py +22 -0
  19. contextual/git/blame.py +334 -0
  20. contextual/indexing/__init__.py +20 -0
  21. contextual/indexing/bug_sweep.py +119 -0
  22. contextual/indexing/chunker.py +691 -0
  23. contextual/indexing/embedder.py +271 -0
  24. contextual/indexing/file_watcher.py +154 -0
  25. contextual/indexing/incremental.py +260 -0
  26. contextual/indexing/index_writer.py +442 -0
  27. contextual/indexing/pipeline.py +438 -0
  28. contextual/indexing/processor.py +436 -0
  29. contextual/indexing/queries/readme.md +22 -0
  30. contextual/indexing/symbol_extractor.py +426 -0
  31. contextual/indexing/tokenizer.py +203 -0
  32. contextual/integrations/__init__.py +10 -0
  33. contextual/mcp/__init__.py +15 -0
  34. contextual/mcp/__main__.py +24 -0
  35. contextual/mcp/docs_tools.py +286 -0
  36. contextual/mcp/server.py +118 -0
  37. contextual/mcp/tools.py +443 -0
  38. contextual/observability/__init__.py +21 -0
  39. contextual/observability/logging.py +115 -0
  40. contextual/py.typed +0 -0
  41. contextual/retrieval/__init__.py +24 -0
  42. contextual/retrieval/context_assembler.py +372 -0
  43. contextual/retrieval/ranker.py +193 -0
  44. contextual/retrieval/search.py +548 -0
  45. contextual/security/__init__.py +52 -0
  46. contextual/security/paths.py +347 -0
  47. contextual/security/sanitize.py +349 -0
  48. contextual/security/workspace.py +348 -0
  49. contextual/storage/__init__.py +36 -0
  50. contextual/storage/fts_manager.py +273 -0
  51. contextual/storage/migration_v2.py +289 -0
  52. contextual/storage/migrations.py +316 -0
  53. contextual/storage/schema.py +210 -0
  54. contextual/storage/sqlite_pool.py +468 -0
  55. contextual/storage/vec0_manager.py +421 -0
  56. contextual_engine-0.1.0.dist-info/METADATA +297 -0
  57. contextual_engine-0.1.0.dist-info/RECORD +60 -0
  58. contextual_engine-0.1.0.dist-info/WHEEL +4 -0
  59. contextual_engine-0.1.0.dist-info/entry_points.txt +2 -0
  60. contextual_engine-0.1.0.dist-info/licenses/LICENSE +111 -0
@@ -0,0 +1,271 @@
1
+ """Embedding provider abstraction using fastembed/Jina.
2
+
3
+ Provides a simple interface for the indexing pipeline while delegating
4
+ to contextual.embedding.CodeEmbedder for actual embedding generation.
5
+
6
+ Key features:
7
+ - Singleton pattern for shared embedder instance
8
+ - Batch processing support
9
+ - 768-dimensional embeddings (jina-v2-code)
10
+ - Content-hash caching handled by CodeEmbedder layer
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Protocol
16
+
17
+ from contextual.embedding.embedder import CodeEmbedder
18
+ from contextual.observability.logging import get_logger
19
+
20
+
21
+ logger = get_logger(__name__)
22
+
23
+
24
+ class EmbeddingProvider(Protocol):
25
+ """Protocol for embedding providers.
26
+
27
+ Defines interface that all embedding implementations must follow.
28
+ """
29
+
30
+ def embed(self, texts: list[str]) -> list[list[float]]:
31
+ """Generate embeddings for batch of texts.
32
+
33
+ Args:
34
+ texts: List of text strings to embed.
35
+
36
+ Returns:
37
+ List of embedding vectors (same order as input).
38
+
39
+ Raises:
40
+ Exception: If embedding generation fails.
41
+ """
42
+ ...
43
+
44
+ def embed_batch(
45
+ self,
46
+ texts: list[str],
47
+ batch_size: int = 64,
48
+ ) -> list[list[float]]:
49
+ """Embed large list of texts in batches.
50
+
51
+ Args:
52
+ texts: List of texts to embed.
53
+ batch_size: Number of texts per batch.
54
+
55
+ Returns:
56
+ List of embedding vectors (same order as input).
57
+ """
58
+ ...
59
+
60
+ @property
61
+ def dimension(self) -> int:
62
+ """Embedding dimension for this provider."""
63
+ ...
64
+
65
+ @property
66
+ def model_name(self) -> str:
67
+ """Model identifier."""
68
+ ...
69
+
70
+
71
+ class JinaEmbedder:
72
+ """Jina embedding provider via fastembed.
73
+
74
+ Wraps contextual.embedding.CodeEmbedder to provide a consistent
75
+ interface for the indexing pipeline.
76
+
77
+ Uses jina-embeddings-v2-base-code model (768-dim) optimized for code.
78
+ Content-hash caching is handled by the underlying CodeEmbedder.
79
+
80
+ Attributes:
81
+ dimension: 768 (fixed for jina-v2-code)
82
+ model_name: "jina-v2-code"
83
+ """
84
+
85
+ def __init__(self):
86
+ """Initialize Jina embedder.
87
+
88
+ Creates underlying CodeEmbedder instance which handles:
89
+ - Model loading (fastembed with jina-v2-code)
90
+ - Content-hash caching (SQLite-backed)
91
+ - Batch processing optimization
92
+ """
93
+ self._embedder = CodeEmbedder()
94
+ self._dimension = 768
95
+ self._model_name = "jina-v2-code"
96
+
97
+ logger.info(
98
+ "JinaEmbedder initialized",
99
+ model=self._model_name,
100
+ dimension=self._dimension,
101
+ provider="fastembed",
102
+ )
103
+
104
+ @property
105
+ def dimension(self) -> int:
106
+ """Embedding dimension (768 for jina-v2-code)."""
107
+ return self._dimension
108
+
109
+ @property
110
+ def model_name(self) -> str:
111
+ """Model identifier."""
112
+ return self._model_name
113
+
114
+ def embed(self, texts: list[str]) -> list[list[float]]:
115
+ """Generate embeddings for batch of texts.
116
+
117
+ Delegates to CodeEmbedder.embed() which handles:
118
+ - Content-hash based caching
119
+ - Batch processing (64 texts per batch)
120
+ - Model inference via fastembed
121
+
122
+ Args:
123
+ texts: List of text strings to embed.
124
+
125
+ Returns:
126
+ List of 768-dim embedding vectors (same order as input).
127
+
128
+ Raises:
129
+ Exception: If embedding generation fails.
130
+ """
131
+ if not texts:
132
+ return []
133
+
134
+ # CodeEmbedder.embed() handles caching and batching internally
135
+ embeddings = self._embedder.embed(texts)
136
+
137
+ # Validate output
138
+ if len(embeddings) != len(texts):
139
+ raise ValueError(
140
+ f"Embedding count mismatch: got {len(embeddings)}, expected {len(texts)}"
141
+ )
142
+
143
+ # Validate dimensions
144
+ for i, embedding in enumerate(embeddings):
145
+ if len(embedding) != self._dimension:
146
+ raise ValueError(
147
+ f"Invalid embedding dimension at index {i}: "
148
+ f"got {len(embedding)}, expected {self._dimension}"
149
+ )
150
+
151
+ logger.debug(
152
+ "Embeddings generated",
153
+ count=len(texts),
154
+ model=self._model_name,
155
+ )
156
+
157
+ return embeddings
158
+
159
+ def embed_batch(
160
+ self,
161
+ texts: list[str],
162
+ batch_size: int = 64,
163
+ ) -> list[list[float]]:
164
+ """Embed large list of texts in batches.
165
+
166
+ Note: CodeEmbedder already handles batching internally,
167
+ so this is essentially a pass-through. Kept for interface
168
+ compatibility with previous NomicEmbedder.
169
+
170
+ Args:
171
+ texts: List of texts to embed.
172
+ batch_size: Number of texts per batch (passed to CodeEmbedder).
173
+
174
+ Returns:
175
+ List of embedding vectors (same order as input).
176
+
177
+ Raises:
178
+ Exception: If any batch fails.
179
+ """
180
+ if not texts:
181
+ return []
182
+
183
+ # CodeEmbedder handles batching, but we respect the batch_size hint
184
+ all_embeddings = []
185
+
186
+ for i in range(0, len(texts), batch_size):
187
+ batch = texts[i:i + batch_size]
188
+ batch_embeddings = self.embed(batch)
189
+ all_embeddings.extend(batch_embeddings)
190
+
191
+ logger.debug(
192
+ "Batch processed",
193
+ batch_num=i // batch_size + 1,
194
+ batch_size=len(batch),
195
+ total_batches=(len(texts) + batch_size - 1) // batch_size,
196
+ )
197
+
198
+ return all_embeddings
199
+
200
+ def clear_cache(self) -> None:
201
+ """Clear the embedding cache.
202
+
203
+ Delegates to underlying CodeEmbedder's cache clearing.
204
+ """
205
+ # CodeEmbedder uses external SQLite cache, not in-memory
206
+ # Cache clearing would need to be implemented at the cache layer
207
+ logger.warning(
208
+ "Cache clearing not implemented - cache is persistent SQLite",
209
+ hint="Use contextual.embedding.cache.EmbeddingCache.clear() if needed",
210
+ )
211
+
212
+
213
+ # Factory function for creating embedder instances
214
+ def create_embedder(**kwargs) -> EmbeddingProvider:
215
+ """Create embedding provider instance.
216
+
217
+ Args:
218
+ **kwargs: Reserved for future extensibility (currently ignored).
219
+
220
+ Returns:
221
+ JinaEmbedder instance.
222
+
223
+ Note:
224
+ Previous versions supported a 'provider' parameter for switching
225
+ between Nomic/Jina. Now we only support Jina (fastembed).
226
+ """
227
+ # Ignore kwargs for now - only one provider (Jina)
228
+ if kwargs:
229
+ logger.warning(
230
+ "create_embedder() kwargs ignored",
231
+ kwargs=list(kwargs.keys()),
232
+ hint="Only Jina provider supported",
233
+ )
234
+
235
+ return JinaEmbedder()
236
+
237
+
238
+ # Module-level singleton instance (lazy initialized)
239
+ _default_embedder: JinaEmbedder | None = None
240
+
241
+
242
+ def get_embedder() -> JinaEmbedder:
243
+ """Get or create default embedder instance.
244
+
245
+ Uses singleton pattern to share embedder instance across
246
+ indexing pipeline. The underlying CodeEmbedder also uses
247
+ a singleton, so this creates a single shared model instance.
248
+
249
+ Returns:
250
+ Singleton JinaEmbedder instance.
251
+ """
252
+ global _default_embedder
253
+ if _default_embedder is None:
254
+ _default_embedder = JinaEmbedder()
255
+ logger.info(
256
+ "Default embedder initialized",
257
+ type="JinaEmbedder",
258
+ singleton=True,
259
+ )
260
+ return _default_embedder
261
+
262
+
263
+ def reset_embedder() -> None:
264
+ """Reset singleton embedder instance.
265
+
266
+ Useful for testing or forcing re-initialization.
267
+ Not typically needed in production.
268
+ """
269
+ global _default_embedder
270
+ _default_embedder = None
271
+ logger.info("Default embedder reset")
@@ -0,0 +1,154 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from pathlib import Path
5
+ from threading import Lock, Timer
6
+ from typing import TYPE_CHECKING
7
+
8
+ from watchdog.events import FileSystemEvent, FileSystemEventHandler
9
+ from watchdog.observers import Observer
10
+
11
+ from contextual.observability.logging import get_logger
12
+
13
+
14
+ if TYPE_CHECKING:
15
+ from contextual.indexing.incremental import IncrementalIndexer
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ class DebouncedFileWatcher(FileSystemEventHandler):
21
+ """Watches a repository directory for file changes, debounces rapid events,
22
+ and triggers incremental re-indexing.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ repo_root: Path,
28
+ incremental_indexer: IncrementalIndexer,
29
+ debounce_ms: int = 500,
30
+ ):
31
+ """Initialize file watcher.
32
+
33
+ Args:
34
+ repo_root: Repository root to watch.
35
+ incremental_indexer: Indexer to call on changes.
36
+ debounce_ms: Debounce delay in milliseconds (default 500).
37
+ """
38
+ self._repo_root = repo_root
39
+ self._incremental_indexer = incremental_indexer
40
+ self._debounce_ms = debounce_ms
41
+ self._observer = Observer()
42
+ self._pending_timers: dict[str, Timer] = {}
43
+ self._lock = Lock()
44
+
45
+ def start(self) -> None:
46
+ """Creates and starts the watchdog observer to monitor the repository.
47
+ """
48
+ self._observer.schedule(self, str(self._repo_root), recursive=True)
49
+ self._observer.start()
50
+ logger.info("File watcher started", path=str(self._repo_root))
51
+
52
+ def stop(self) -> None:
53
+ """Stops the file watcher and cleans up pending operations.
54
+ """
55
+ if self._observer.is_alive():
56
+ self._observer.stop()
57
+ self._observer.join()
58
+
59
+ with self._lock:
60
+ for timer in self._pending_timers.values():
61
+ timer.cancel()
62
+ self._pending_timers.clear()
63
+ logger.info("File watcher stopped")
64
+
65
+ def _schedule_index(self, file_path: Path, event_type: str) -> None:
66
+ """Schedules an indexing operation for a file, debouncing rapid events.
67
+ """
68
+ with self._lock:
69
+ # Cancel any existing timer for this file
70
+ if str(file_path) in self._pending_timers:
71
+ self._pending_timers[str(file_path)].cancel()
72
+
73
+ # Create a new timer
74
+ timer = Timer(
75
+ self._debounce_ms / 1000.0,
76
+ self._process_file,
77
+ args=[file_path, event_type],
78
+ )
79
+ self._pending_timers[str(file_path)] = timer
80
+ timer.start()
81
+
82
+ def _process_file(self, file_path: Path, event_type: str) -> None:
83
+ """Processes a single file event after the debounce period.
84
+ """
85
+ try:
86
+ with self._lock:
87
+ # Remove timer from pending list
88
+ self._pending_timers.pop(str(file_path), None)
89
+
90
+ if not self._incremental_indexer.should_index_file(file_path):
91
+ return
92
+
93
+ start_time = time.monotonic()
94
+ if event_type in ("modified", "created"):
95
+ self._incremental_indexer.handle_file_change(file_path)
96
+ elif event_type == "deleted":
97
+ self._incremental_indexer.handle_file_deletion(file_path)
98
+
99
+ duration_ms = (time.monotonic() - start_time) * 1000
100
+ logger.debug(
101
+ "Processed file event",
102
+ path=str(file_path),
103
+ event=event_type,
104
+ duration_ms=duration_ms,
105
+ )
106
+ except Exception:
107
+ logger.exception(
108
+ "Error processing file event", path=str(file_path), event=event_type
109
+ )
110
+
111
+ def on_modified(self, event: FileSystemEvent) -> None:
112
+ """Handle file modification events."""
113
+ if not event.is_directory:
114
+ try:
115
+ file_path = Path(event.src_path)
116
+ self._schedule_index(file_path, "modified")
117
+ except Exception:
118
+ logger.exception("Error in on_modified handler", path=event.src_path)
119
+
120
+ def on_created(self, event: FileSystemEvent) -> None:
121
+ """Handle file creation events."""
122
+ if not event.is_directory:
123
+ try:
124
+ file_path = Path(event.src_path)
125
+ self._schedule_index(file_path, "created")
126
+ except Exception:
127
+ logger.exception("Error in on_created handler", path=event.src_path)
128
+
129
+ def on_deleted(self, event: FileSystemEvent) -> None:
130
+ """Handle file deletion events."""
131
+ if not event.is_directory:
132
+ try:
133
+ file_path = Path(event.src_path)
134
+ self._schedule_index(file_path, "deleted")
135
+ except Exception:
136
+ logger.exception("Error in on_deleted handler", path=event.src_path)
137
+
138
+ def on_moved(self, event: FileSystemEvent) -> None:
139
+ """Handle file move/rename events."""
140
+ if not event.is_directory:
141
+ try:
142
+ old_path = Path(event.src_path)
143
+ new_path = Path(event.dest_path)
144
+
145
+ # Cancel any pending timers for the old path
146
+ with self._lock:
147
+ if str(old_path) in self._pending_timers:
148
+ self._pending_timers[str(old_path)].cancel()
149
+ self._pending_timers.pop(str(old_path), None)
150
+
151
+ self._incremental_indexer.handle_file_rename(old_path, new_path)
152
+ logger.debug("Processed file move event", src=str(old_path), dest=str(new_path))
153
+ except Exception:
154
+ logger.exception("Error in on_moved handler", src=event.src_path, dest=event.dest_path)
@@ -0,0 +1,260 @@
1
+ """Incremental indexing with smart change detection.
2
+
3
+ Handles file modifications, additions, deletions, and renames efficiently
4
+ using content-hash comparison and mtime checks.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ from contextual.indexing.index_writer import IndexWriter
14
+ from contextual.observability.logging import get_logger
15
+
16
+
17
+ if TYPE_CHECKING:
18
+ from contextual.indexing.pipeline import IndexingPipeline
19
+
20
+ logger = get_logger(__name__)
21
+
22
+
23
+ def compute_file_hash(file_path: Path) -> str:
24
+ """Compute SHA-256 hash of file content.
25
+
26
+ Args:
27
+ file_path: Path to file.
28
+
29
+ Returns:
30
+ Hex digest of file hash.
31
+ """
32
+ sha256 = hashlib.sha256()
33
+
34
+ try:
35
+ with open(file_path, "rb") as f:
36
+ # Read in chunks to handle large files
37
+ for chunk in iter(lambda: f.read(65536), b""):
38
+ sha256.update(chunk)
39
+ return sha256.hexdigest()
40
+ except Exception as e:
41
+ logger.error(
42
+ "Failed to compute file hash",
43
+ path=str(file_path),
44
+ error=str(e),
45
+ )
46
+ # Return empty string to force re-index
47
+ return ""
48
+
49
+
50
+ class IncrementalIndexer:
51
+ """Handles incremental indexing of changed files.
52
+
53
+ Detects changes via:
54
+ 1. Content hash comparison (primary)
55
+ 2. Modification time check (secondary)
56
+ 3. File existence check
57
+ """
58
+
59
+ def __init__(self, pipeline: IndexingPipeline):
60
+ """Initialize incremental indexer.
61
+
62
+ Args:
63
+ pipeline: Indexing pipeline instance.
64
+ """
65
+ self.pipeline = pipeline
66
+ self.repo_root = pipeline.repo_root
67
+ self.db_pool = pipeline.db_pool
68
+
69
+ def handle_file_change(self, file_path: Path) -> dict[str, int]:
70
+ """Handle file modification or creation.
71
+
72
+ Checks if file actually changed before re-indexing.
73
+
74
+ Args:
75
+ file_path: Absolute path to changed file.
76
+
77
+ Returns:
78
+ Stats dict from indexing.
79
+ """
80
+ # Get relative path
81
+ try:
82
+ rel_path = str(file_path.resolve().relative_to(self.repo_root.resolve()))
83
+ except ValueError:
84
+ logger.warning(
85
+ "File outside repo root",
86
+ path=str(file_path),
87
+ repo_root=str(self.repo_root),
88
+ )
89
+ return {"files": 0, "chunks": 0, "symbols": 0, "embeddings": 0}
90
+
91
+ # Check if file exists
92
+ if not file_path.exists():
93
+ logger.debug("File no longer exists", path=str(file_path))
94
+ return self.handle_file_deletion(file_path)
95
+
96
+ # Compute current file hash
97
+ current_hash = compute_file_hash(file_path)
98
+
99
+ # Check existing file hash in DB
100
+ with self.db_pool.reader() as reader_conn:
101
+ cursor = reader_conn.cursor()
102
+ cursor.execute(
103
+ "SELECT content_hash, mtime FROM files WHERE path = ?",
104
+ (rel_path,)
105
+ )
106
+ existing = cursor.fetchone()
107
+
108
+ if existing:
109
+ existing_hash, existing_mtime = existing
110
+ current_mtime = int(file_path.stat().st_mtime)
111
+
112
+ # Check if content unchanged
113
+ if existing_hash == current_hash:
114
+ logger.debug(
115
+ "File content unchanged (hash match)",
116
+ path=str(file_path),
117
+ hash=current_hash[:8],
118
+ )
119
+
120
+ # Update mtime if changed (file was touched)
121
+ if current_mtime != existing_mtime:
122
+ with self.db_pool.writer() as writer_conn:
123
+ cursor = writer_conn.cursor()
124
+ cursor.execute(
125
+ "UPDATE files SET mtime = ? WHERE path = ?",
126
+ (current_mtime, rel_path)
127
+ )
128
+ writer_conn.commit()
129
+
130
+ return {"files": 0, "chunks": 0, "symbols": 0, "embeddings": 0}
131
+
132
+ # File is new or changed - re-index
133
+ logger.info(
134
+ "File changed, re-indexing",
135
+ path=str(file_path),
136
+ new=existing is None,
137
+ )
138
+
139
+ stats = self.pipeline.index_single_file(file_path)
140
+
141
+ return stats
142
+
143
+ def handle_file_deletion(self, file_path: Path) -> dict[str, int]:
144
+ """Handle file deletion.
145
+
146
+ Args:
147
+ file_path: Absolute path to deleted file.
148
+
149
+ Returns:
150
+ Stats dict with chunks_deleted count.
151
+ """
152
+ logger.info("File deleted", path=str(file_path))
153
+
154
+ chunks_deleted = self.pipeline.delete_file(file_path)
155
+
156
+ return {
157
+ "files": -1,
158
+ "chunks": -chunks_deleted,
159
+ "symbols": 0, # Cascade delete handled by DB
160
+ "embeddings": 0,
161
+ }
162
+
163
+ def handle_file_rename(
164
+ self,
165
+ old_path: Path,
166
+ new_path: Path,
167
+ ) -> dict[str, int]:
168
+ """Handle file rename/move.
169
+
170
+ Deletes old entry and creates new one.
171
+
172
+ Args:
173
+ old_path: Old absolute path.
174
+ new_path: New absolute path.
175
+
176
+ Returns:
177
+ Combined stats dict.
178
+ """
179
+ logger.info(
180
+ "File renamed",
181
+ old_path=str(old_path),
182
+ new_path=str(new_path),
183
+ )
184
+
185
+ # Delete old entry
186
+ delete_stats = self.handle_file_deletion(old_path)
187
+
188
+ # Index new entry
189
+ add_stats = self.handle_file_change(new_path)
190
+
191
+ # Combine stats
192
+ return {
193
+ "files": delete_stats["files"] + add_stats["files"],
194
+ "chunks": delete_stats["chunks"] + add_stats["chunks"],
195
+ "symbols": delete_stats["symbols"] + add_stats["symbols"],
196
+ "embeddings": delete_stats["embeddings"] + add_stats["embeddings"],
197
+ }
198
+
199
+ def update_indexing_state(self, stats: dict[str, int]) -> None:
200
+ """Update incremental indexing state.
201
+
202
+ Args:
203
+ stats: Stats from incremental indexing.
204
+ """
205
+ # Get current totals from DB
206
+ with self.db_pool.reader() as reader_conn:
207
+ cursor = reader_conn.cursor()
208
+
209
+ cursor.execute("SELECT COUNT(*) FROM files")
210
+ total_files = cursor.fetchone()[0]
211
+
212
+ cursor.execute("SELECT COUNT(*) FROM chunks")
213
+ total_chunks = cursor.fetchone()[0]
214
+
215
+ cursor.execute("SELECT COUNT(*) FROM code_symbols")
216
+ total_symbols = cursor.fetchone()[0]
217
+
218
+ # Update state
219
+ with self.db_pool.writer() as writer_conn:
220
+ writer = IndexWriter(writer_conn)
221
+ writer.update_indexing_state(
222
+ repo_root=str(self.repo_root),
223
+ total_files=total_files,
224
+ total_chunks=total_chunks,
225
+ total_symbols=total_symbols,
226
+ full_index=False, # Incremental
227
+ )
228
+
229
+ def should_index_file(self, file_path: Path) -> bool:
230
+ """Check if file should be indexed.
231
+
232
+ Uses FileProcessor's filtering logic.
233
+
234
+ Args:
235
+ file_path: Path to check.
236
+
237
+ Returns:
238
+ True if file should be indexed.
239
+ """
240
+ # Check if file exists
241
+ if not file_path.exists() or not file_path.is_file():
242
+ return False
243
+
244
+ # Get relative path
245
+ try:
246
+ rel_path = file_path.resolve().relative_to(self.repo_root.resolve())
247
+ except ValueError:
248
+ return False
249
+
250
+ # Check if should skip
251
+ if self.pipeline.file_processor._should_skip(file_path, rel_path):
252
+ return False
253
+
254
+ # Check if language supported
255
+ from contextual.indexing.chunker import detect_language
256
+ language = detect_language(file_path)
257
+ if not language:
258
+ return False
259
+
260
+ return True