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,265 @@
|
|
|
1
|
+
"""Docs file watcher for Contextual Phase 2.
|
|
2
|
+
|
|
3
|
+
Extends the Phase 1 watchdog integration to handle markdown/RST/txt files.
|
|
4
|
+
Routes filesystem events to ``DocsPipeline.index_file()`` or
|
|
5
|
+
``DocsPipeline.delete_file()`` with 400ms debounce to absorb editor
|
|
6
|
+
atomic-write sequences (Vim, VS Code, JetBrains all do write-rename).
|
|
7
|
+
|
|
8
|
+
Design:
|
|
9
|
+
- Mirrors the existing ``contextual/indexing/file_watcher.py`` pattern exactly.
|
|
10
|
+
- Composable: the MCP server runs BOTH watchers from a single background
|
|
11
|
+
thread pool (one Observer, two Handlers scheduled on the same path).
|
|
12
|
+
- Never blocks the main thread. All indexing happens on a daemon thread pool.
|
|
13
|
+
- Logging to stderr via structlog (never stdout — MCP stdio transport).
|
|
14
|
+
|
|
15
|
+
Vim/JetBrains atomic-rename caveat:
|
|
16
|
+
These editors write to a temp file then rename to the target. Watchdog
|
|
17
|
+
fires ``FileMovedEvent`` (not ``FileModifiedEvent``). We handle BOTH
|
|
18
|
+
``on_modified`` and ``on_moved`` (dest path) so no event is missed.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
import structlog
|
|
28
|
+
from watchdog.events import (
|
|
29
|
+
FileCreatedEvent,
|
|
30
|
+
FileDeletedEvent,
|
|
31
|
+
FileModifiedEvent,
|
|
32
|
+
FileMovedEvent,
|
|
33
|
+
FileSystemEvent,
|
|
34
|
+
PatternMatchingEventHandler,
|
|
35
|
+
)
|
|
36
|
+
from watchdog.observers import Observer
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from contextual.docs.pipeline import DocsPipeline
|
|
40
|
+
|
|
41
|
+
logger = structlog.get_logger(__name__)
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Constants
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
DOC_PATTERNS: list[str] = ["*.md", "*.mdx", "*.rst", "*.txt"]
|
|
48
|
+
DOC_EXTENSIONS: frozenset[str] = frozenset({".md", ".mdx", ".rst", ".txt"})
|
|
49
|
+
|
|
50
|
+
# Debounce window: editor atomic-write sequences complete within 200ms;
|
|
51
|
+
# 400ms gives a comfortable margin on slow NFS/network mounts.
|
|
52
|
+
DEBOUNCE_SECONDS: float = 0.4
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Event handler
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
class _DocsEventHandler(PatternMatchingEventHandler):
|
|
60
|
+
"""Watchdog event handler for documentation files.
|
|
61
|
+
|
|
62
|
+
Debounces rapid successive events for the same file and dispatches
|
|
63
|
+
indexing work to a background thread pool.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, pipeline: "DocsPipeline") -> None:
|
|
67
|
+
super().__init__(
|
|
68
|
+
patterns=DOC_PATTERNS,
|
|
69
|
+
ignore_directories=True,
|
|
70
|
+
case_sensitive=False,
|
|
71
|
+
)
|
|
72
|
+
self._pipeline = pipeline
|
|
73
|
+
self._lock = threading.Lock()
|
|
74
|
+
# Pending timers keyed by absolute file path string
|
|
75
|
+
self._timers: dict[str, threading.Timer] = {}
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
# Watchdog callbacks
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def on_created(self, event: FileSystemEvent) -> None:
|
|
82
|
+
self._schedule(Path(event.src_path), "created")
|
|
83
|
+
|
|
84
|
+
def on_modified(self, event: FileSystemEvent) -> None:
|
|
85
|
+
self._schedule(Path(event.src_path), "modified")
|
|
86
|
+
|
|
87
|
+
def on_deleted(self, event: FileSystemEvent) -> None:
|
|
88
|
+
self._schedule_delete(Path(event.src_path))
|
|
89
|
+
|
|
90
|
+
def on_moved(self, event: FileMovedEvent) -> None: # type: ignore[override]
|
|
91
|
+
# Old path → delete; new path → index (if doc extension)
|
|
92
|
+
self._schedule_delete(Path(event.src_path))
|
|
93
|
+
dest = Path(event.dest_path)
|
|
94
|
+
if dest.suffix.lower() in DOC_EXTENSIONS:
|
|
95
|
+
self._schedule(dest, "moved")
|
|
96
|
+
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
# Debounce helpers
|
|
99
|
+
# ------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def _schedule(self, path: Path, event_type: str) -> None:
|
|
102
|
+
"""Debounce an index event for ``path``."""
|
|
103
|
+
key = str(path.resolve())
|
|
104
|
+
with self._lock:
|
|
105
|
+
# Cancel any pending timer for this path
|
|
106
|
+
if key in self._timers:
|
|
107
|
+
self._timers[key].cancel()
|
|
108
|
+
timer = threading.Timer(
|
|
109
|
+
DEBOUNCE_SECONDS,
|
|
110
|
+
self._run_index,
|
|
111
|
+
args=(path, event_type),
|
|
112
|
+
)
|
|
113
|
+
timer.daemon = True
|
|
114
|
+
self._timers[key] = timer
|
|
115
|
+
timer.start()
|
|
116
|
+
logger.debug("Docs event debounced", path=str(path), event=event_type)
|
|
117
|
+
|
|
118
|
+
def _schedule_delete(self, path: Path) -> None:
|
|
119
|
+
"""Debounce a delete event for ``path``."""
|
|
120
|
+
key = str(path.resolve())
|
|
121
|
+
with self._lock:
|
|
122
|
+
if key in self._timers:
|
|
123
|
+
self._timers[key].cancel()
|
|
124
|
+
timer = threading.Timer(
|
|
125
|
+
DEBOUNCE_SECONDS,
|
|
126
|
+
self._run_delete,
|
|
127
|
+
args=(path,),
|
|
128
|
+
)
|
|
129
|
+
timer.daemon = True
|
|
130
|
+
self._timers[key] = timer
|
|
131
|
+
timer.start()
|
|
132
|
+
|
|
133
|
+
def _run_index(self, path: Path, event_type: str) -> None:
|
|
134
|
+
"""Execute index_file on the pipeline (runs on timer thread)."""
|
|
135
|
+
with self._lock:
|
|
136
|
+
self._timers.pop(str(path.resolve()), None)
|
|
137
|
+
|
|
138
|
+
if not path.exists():
|
|
139
|
+
logger.debug("File gone before index — treating as delete", path=str(path))
|
|
140
|
+
self._run_delete(path)
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
t0 = time.perf_counter()
|
|
144
|
+
try:
|
|
145
|
+
stats = self._pipeline.index_file(path)
|
|
146
|
+
elapsed = time.perf_counter() - t0
|
|
147
|
+
logger.info(
|
|
148
|
+
"Docs file re-indexed",
|
|
149
|
+
path=str(path),
|
|
150
|
+
event=event_type,
|
|
151
|
+
chunks=stats.chunks_created,
|
|
152
|
+
elapsed_ms=round(elapsed * 1000, 1),
|
|
153
|
+
)
|
|
154
|
+
except Exception as exc: # noqa: BLE001
|
|
155
|
+
logger.error("Docs incremental index failed", path=str(path), error=str(exc))
|
|
156
|
+
|
|
157
|
+
def _run_delete(self, path: Path) -> None:
|
|
158
|
+
"""Execute delete_file on the pipeline (runs on timer thread)."""
|
|
159
|
+
with self._lock:
|
|
160
|
+
self._timers.pop(str(path.resolve()), None)
|
|
161
|
+
try:
|
|
162
|
+
deleted = self._pipeline.delete_file(path)
|
|
163
|
+
if deleted:
|
|
164
|
+
logger.info("Docs file removed from index", path=str(path), chunks=deleted)
|
|
165
|
+
except Exception as exc: # noqa: BLE001
|
|
166
|
+
logger.error("Docs delete failed", path=str(path), error=str(exc))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
# Public watcher class
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
class DocsFileWatcher:
|
|
174
|
+
"""Watches a repository for documentation file changes.
|
|
175
|
+
|
|
176
|
+
Runs a watchdog ``Observer`` in a background daemon thread.
|
|
177
|
+
Composable with the existing ``FileWatcher`` (code watcher) — both
|
|
178
|
+
can share the same ``Observer`` instance by calling ``attach(observer)``.
|
|
179
|
+
|
|
180
|
+
Typical usage (standalone)::
|
|
181
|
+
|
|
182
|
+
watcher = DocsFileWatcher(repo_root, docs_pipeline)
|
|
183
|
+
watcher.start()
|
|
184
|
+
# ... server runs ...
|
|
185
|
+
watcher.stop()
|
|
186
|
+
|
|
187
|
+
Composing with the existing code watcher::
|
|
188
|
+
|
|
189
|
+
observer = Observer()
|
|
190
|
+
code_watcher.attach(observer) # Phase 1 watcher registers its handler
|
|
191
|
+
docs_watcher.attach(observer) # Phase 2 watcher registers its handler
|
|
192
|
+
observer.start()
|
|
193
|
+
# ...
|
|
194
|
+
observer.stop()
|
|
195
|
+
observer.join()
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
def __init__(self, repo_root: Path, pipeline: "DocsPipeline") -> None:
|
|
199
|
+
self._root = repo_root.resolve()
|
|
200
|
+
self._handler = _DocsEventHandler(pipeline)
|
|
201
|
+
self._observer: Observer | None = None
|
|
202
|
+
self._started = False
|
|
203
|
+
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
# Lifecycle
|
|
206
|
+
# ------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
def start(self) -> None:
|
|
209
|
+
"""Start the file watcher in its own Observer thread."""
|
|
210
|
+
if self._started:
|
|
211
|
+
logger.warning("DocsFileWatcher already started")
|
|
212
|
+
return
|
|
213
|
+
observer = Observer()
|
|
214
|
+
self.attach(observer)
|
|
215
|
+
observer.start()
|
|
216
|
+
self._observer = observer
|
|
217
|
+
self._started = True
|
|
218
|
+
logger.info(
|
|
219
|
+
"DocsFileWatcher started",
|
|
220
|
+
root=str(self._root),
|
|
221
|
+
patterns=DOC_PATTERNS,
|
|
222
|
+
debounce_ms=int(DEBOUNCE_SECONDS * 1000),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def stop(self) -> None:
|
|
226
|
+
"""Stop the file watcher and join the Observer thread."""
|
|
227
|
+
if not self._started or self._observer is None:
|
|
228
|
+
return
|
|
229
|
+
# Cancel all pending debounce timers before stopping (W2: Fix memory leak)
|
|
230
|
+
with self._handler._lock:
|
|
231
|
+
for timer in self._handler._timers.values():
|
|
232
|
+
timer.cancel()
|
|
233
|
+
self._handler._timers.clear()
|
|
234
|
+
self._observer.stop()
|
|
235
|
+
self._observer.join(timeout=5.0)
|
|
236
|
+
self._observer = None
|
|
237
|
+
self._started = False
|
|
238
|
+
logger.info("DocsFileWatcher stopped")
|
|
239
|
+
|
|
240
|
+
def attach(self, observer: Observer) -> None:
|
|
241
|
+
"""Register this handler on an external Observer instance.
|
|
242
|
+
|
|
243
|
+
Use this when composing with the Phase 1 code watcher so both
|
|
244
|
+
handlers share a single OS-level inotify/FSEvents subscription.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
observer: A watchdog ``Observer`` that has NOT been started yet.
|
|
248
|
+
"""
|
|
249
|
+
observer.schedule(
|
|
250
|
+
self._handler,
|
|
251
|
+
str(self._root),
|
|
252
|
+
recursive=True,
|
|
253
|
+
)
|
|
254
|
+
logger.debug("DocsFileWatcher attached to observer", root=str(self._root))
|
|
255
|
+
|
|
256
|
+
# ------------------------------------------------------------------
|
|
257
|
+
# Context manager support
|
|
258
|
+
# ------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
def __enter__(self) -> "DocsFileWatcher":
|
|
261
|
+
self.start()
|
|
262
|
+
return self
|
|
263
|
+
|
|
264
|
+
def __exit__(self, *_: object) -> None:
|
|
265
|
+
self.stop()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Embedding module: fastembed ONNX inference with content-hash caching.
|
|
2
|
+
|
|
3
|
+
This module provides efficient code embedding generation using:
|
|
4
|
+
- Model: jina-embeddings-v2-base-code (768-dim, int8 ONNX)
|
|
5
|
+
- Caching: Content-hash based deduplication
|
|
6
|
+
- Batching: Up to 64 documents per batch
|
|
7
|
+
- Thread-safety: Singleton embedder with locks
|
|
8
|
+
|
|
9
|
+
Public API (High-level - recommended):
|
|
10
|
+
- embed_chunks_with_cache: Batch embed chunks with cache lookup/write
|
|
11
|
+
- embed_query_simple: Embed search query (no cache)
|
|
12
|
+
- get_embedding_stats: Cache statistics and efficiency metrics
|
|
13
|
+
- warm_cache_from_chunks: Pre-populate cache from database
|
|
14
|
+
|
|
15
|
+
Public API (Low-level - advanced use):
|
|
16
|
+
- get_embedder: Access embedder singleton directly
|
|
17
|
+
- CodeEmbedder: Embedder class (use singleton via get_embedder)
|
|
18
|
+
- initialize_cache: Setup cache table
|
|
19
|
+
- get_cached_embedding: Single cache lookup
|
|
20
|
+
- put_cached_embedding: Single cache write
|
|
21
|
+
|
|
22
|
+
Performance characteristics:
|
|
23
|
+
- Model load: ~2s (one-time, cached)
|
|
24
|
+
- Embedding generation: ~55ms per chunk
|
|
25
|
+
- Cache hit: ~0.1ms (550x faster)
|
|
26
|
+
- Batch of 64: ~3.5s total (~18 docs/sec)
|
|
27
|
+
|
|
28
|
+
Example usage:
|
|
29
|
+
>>> from contextual.embedding import embed_chunks_with_cache, embed_query_simple
|
|
30
|
+
>>> from contextual.storage import SQLitePool
|
|
31
|
+
>>>
|
|
32
|
+
>>> # Indexing workflow
|
|
33
|
+
>>> pool = SQLitePool(db_path)
|
|
34
|
+
>>> chunks = [("hash1", "def parse():"), ("hash2", "class Config:")]
|
|
35
|
+
>>> with pool.writer() as conn:
|
|
36
|
+
... results = embed_chunks_with_cache(conn, chunks)
|
|
37
|
+
>>>
|
|
38
|
+
>>> # Search workflow
|
|
39
|
+
>>> query_vector = embed_query_simple("parse JSON configuration")
|
|
40
|
+
"""
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
# High-level API (recommended)
|
|
44
|
+
from contextual.embedding.helpers import (
|
|
45
|
+
embed_chunks,
|
|
46
|
+
embed_query_simple,
|
|
47
|
+
get_embedding_stats,
|
|
48
|
+
warm_cache_from_chunks,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Core embedder
|
|
52
|
+
from contextual.embedding.embedder import (
|
|
53
|
+
CodeEmbedder,
|
|
54
|
+
DocsEmbedder,
|
|
55
|
+
get_embedder,
|
|
56
|
+
get_docs_embedder,
|
|
57
|
+
reset_embedder,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Cache operations
|
|
61
|
+
from contextual.embedding.cache import (
|
|
62
|
+
clear_cache,
|
|
63
|
+
get_cache_stats,
|
|
64
|
+
get_cached_embedding,
|
|
65
|
+
initialize_cache,
|
|
66
|
+
put_cached_embedding,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
__all__ = [
|
|
70
|
+
# High-level API (use these)
|
|
71
|
+
"embed_chunks",
|
|
72
|
+
"embed_query_simple",
|
|
73
|
+
"get_embedding_stats",
|
|
74
|
+
"warm_cache_from_chunks",
|
|
75
|
+
# Core embedder
|
|
76
|
+
"CodeEmbedder",
|
|
77
|
+
"DocsEmbedder",
|
|
78
|
+
"get_embedder",
|
|
79
|
+
"get_docs_embedder",
|
|
80
|
+
"reset_embedder",
|
|
81
|
+
# Cache operations
|
|
82
|
+
"clear_cache",
|
|
83
|
+
"get_cache_stats",
|
|
84
|
+
"get_cached_embedding",
|
|
85
|
+
"initialize_cache",
|
|
86
|
+
"put_cached_embedding",
|
|
87
|
+
]
|