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.
- contextual/__init__.py +18 -0
- contextual/__main__.py +11 -0
- contextual/cli.py +339 -0
- contextual/cli_docs.py +685 -0
- contextual/config.py +7 -0
- contextual/core/__init__.py +11 -0
- contextual/core/errors.py +470 -0
- contextual/core/models.py +590 -0
- contextual/docs/__init__.py +66 -0
- contextual/docs/chunker.py +550 -0
- contextual/docs/pipeline.py +513 -0
- contextual/docs/retrieval.py +654 -0
- contextual/docs/watcher.py +265 -0
- contextual/embedding/__init__.py +87 -0
- contextual/embedding/cache.py +455 -0
- contextual/embedding/embedder.py +414 -0
- contextual/embedding/helpers.py +252 -0
- contextual/git/__init__.py +22 -0
- contextual/git/blame.py +334 -0
- contextual/indexing/__init__.py +20 -0
- contextual/indexing/bug_sweep.py +119 -0
- contextual/indexing/chunker.py +691 -0
- contextual/indexing/embedder.py +271 -0
- contextual/indexing/file_watcher.py +154 -0
- contextual/indexing/incremental.py +260 -0
- contextual/indexing/index_writer.py +442 -0
- contextual/indexing/pipeline.py +438 -0
- contextual/indexing/processor.py +436 -0
- contextual/indexing/queries/readme.md +22 -0
- contextual/indexing/symbol_extractor.py +426 -0
- contextual/indexing/tokenizer.py +203 -0
- contextual/integrations/__init__.py +10 -0
- contextual/mcp/__init__.py +15 -0
- contextual/mcp/__main__.py +24 -0
- contextual/mcp/docs_tools.py +286 -0
- contextual/mcp/server.py +118 -0
- contextual/mcp/tools.py +443 -0
- contextual/observability/__init__.py +21 -0
- contextual/observability/logging.py +115 -0
- contextual/py.typed +0 -0
- contextual/retrieval/__init__.py +24 -0
- contextual/retrieval/context_assembler.py +372 -0
- contextual/retrieval/ranker.py +193 -0
- contextual/retrieval/search.py +548 -0
- contextual/security/__init__.py +52 -0
- contextual/security/paths.py +347 -0
- contextual/security/sanitize.py +349 -0
- contextual/security/workspace.py +348 -0
- contextual/storage/__init__.py +36 -0
- contextual/storage/fts_manager.py +273 -0
- contextual/storage/migration_v2.py +289 -0
- contextual/storage/migrations.py +316 -0
- contextual/storage/schema.py +210 -0
- contextual/storage/sqlite_pool.py +468 -0
- contextual/storage/vec0_manager.py +421 -0
- contextual_engine-0.1.0.dist-info/METADATA +297 -0
- contextual_engine-0.1.0.dist-info/RECORD +60 -0
- contextual_engine-0.1.0.dist-info/WHEEL +4 -0
- contextual_engine-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|