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,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
+ ]