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,286 @@
1
+ """MCP tool definitions for Phase 2 docs search.
2
+
3
+ Registers three tools onto the shared FastMCP instance:
4
+ docs_search — hybrid BM25 + vector search over indexed docs
5
+ docs_get_section — retrieve exact section by heading path (5-tier resolver)
6
+ docs_list_files — enumerate indexed doc files with optional heading tree
7
+
8
+ Feature-flag gate: tools are registered ONLY when the env var
9
+ CONTEXTUAL_DOCS_ENABLED=1
10
+ is set. The existing Phase 1 codebase.* tools are unaffected regardless.
11
+
12
+ Import side-effects:
13
+ This module MUST be imported by ``contextual/mcp/tools.py`` (or server.py)
14
+ AFTER the shared ``mcp`` FastMCP instance is created. It imports ``mcp``
15
+ from the sibling module — zero circular-import risk because tools.py does
16
+ not import from here.
17
+
18
+ Usage in server.py / tools.py::
19
+
20
+ import os
21
+ if os.getenv("CONTEXTUAL_DOCS_ENABLED", "0") == "1":
22
+ import contextual.mcp.docs_tools # noqa: F401 — registers tools as side-effect
23
+
24
+ Never write to stdout — this server runs on stdio MCP transport.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import os
30
+ from dataclasses import asdict
31
+ from pathlib import Path
32
+
33
+ import structlog
34
+
35
+ # Import the shared FastMCP instance and pool helper from the existing tools module.
36
+ # tools.py owns the singleton _pool and _get_pool(); we reuse them directly.
37
+ from contextual.mcp.tools import _get_pool # noqa: PLC2701 (internal import intentional)
38
+ from contextual.mcp.server import mcp
39
+ from contextual.embedding.embedder import get_docs_embedder, get_embedder
40
+ from contextual.docs.retrieval import (
41
+ DocSearchHit,
42
+ DocSection,
43
+ DocFileInfo,
44
+ docs_search,
45
+ docs_get_section,
46
+ docs_list_files,
47
+ )
48
+
49
+ logger = structlog.get_logger(__name__)
50
+
51
+ # Confirm gate at import time so the log is visible in server startup output
52
+ _DOCS_ENABLED = os.getenv("CONTEXTUAL_DOCS_ENABLED", "0") == "1"
53
+ logger.info("Docs tools module loaded", enabled=_DOCS_ENABLED)
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Serialisation helpers
58
+ # ---------------------------------------------------------------------------
59
+
60
+ def _hit_to_dict(hit: DocSearchHit) -> dict:
61
+ return {
62
+ "chunk_id": hit.chunk_id,
63
+ "file_path": hit.file_path,
64
+ "content": hit.content,
65
+ "heading_path": hit.heading_path,
66
+ "heading_level":hit.heading_level,
67
+ "chunk_type": hit.chunk_type,
68
+ "start_line": hit.start_line,
69
+ "end_line": hit.end_line,
70
+ "score": hit.score,
71
+ "bm25_rank": hit.bm25_rank,
72
+ "vector_rank": hit.vector_rank,
73
+ }
74
+
75
+
76
+ def _section_to_dict(sec: DocSection) -> dict:
77
+ d = {
78
+ "file_path": sec.file_path,
79
+ "heading_path": sec.heading_path,
80
+ "heading_level": sec.heading_level,
81
+ "content": sec.content,
82
+ "start_line": sec.start_line,
83
+ "end_line": sec.end_line,
84
+ }
85
+ if sec.subsections:
86
+ d["subsections"] = [_section_to_dict(s) for s in sec.subsections]
87
+ return d
88
+
89
+
90
+ def _fileinfo_to_dict(fi: DocFileInfo) -> dict:
91
+ d = {"file_path": fi.file_path, "chunk_count": fi.chunk_count}
92
+ if fi.headings:
93
+ d["headings"] = fi.headings
94
+ return d
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Tool 1: docs_search
99
+ # ---------------------------------------------------------------------------
100
+
101
+ @mcp.tool()
102
+ def docs_search_tool(
103
+ query: str,
104
+ k: int = 10,
105
+ source_filter: str | None = None,
106
+ ) -> str:
107
+ """Semantically search indexed documentation and markdown files.
108
+
109
+ Performs hybrid BM25 + vector search (RRF fusion) over all docs indexed
110
+ by Contextual (README, ADRs, wikis, changelogs, any .md/.mdx/.rst/.txt).
111
+
112
+ Args:
113
+ query: Natural-language question or keyword string.
114
+ k: Number of results to return (1-50).
115
+ source_filter: Optional JSON string with filter keys:
116
+ repo list[str] — restrict to files under this path prefix
117
+ file list[str] — exact file paths to restrict to
118
+ heading str — LIKE pattern on heading_path
119
+ source_type list[str] — "docs" | "docs_code" | both
120
+
121
+ Returns:
122
+ JSON array of search hits, each with: file_path, content, heading_path,
123
+ heading_level, chunk_type, start_line, end_line, score.
124
+
125
+ Examples:
126
+ docs_search_tool("how does the file watcher work")
127
+ docs_search_tool("installation prerequisites", k=5)
128
+ docs_search_tool("ADR decisions", source_filter='{"repo": ["docs/adr"]}')
129
+ """
130
+ if not _DOCS_ENABLED:
131
+ return json.dumps({"error": "Docs indexing is disabled. Set CONTEXTUAL_DOCS_ENABLED=1."})
132
+
133
+ k = max(1, min(k, 50))
134
+
135
+ parsed_filter: dict | None = None
136
+ if source_filter:
137
+ try:
138
+ parsed_filter = json.loads(source_filter)
139
+ except json.JSONDecodeError as exc:
140
+ return json.dumps({"error": f"Invalid source_filter JSON: {exc}"})
141
+
142
+ pool = _get_pool()
143
+ embedder = get_embedder()
144
+
145
+ try:
146
+ with pool.reader() as conn:
147
+ hits = docs_search(
148
+ query=query,
149
+ conn=conn,
150
+ embedder=embedder,
151
+ k=k,
152
+ source_filter=parsed_filter,
153
+ )
154
+ except Exception as exc: # noqa: BLE001
155
+ logger.error("docs_search_tool failed", query=query, error=str(exc))
156
+ return json.dumps({"error": str(exc)})
157
+
158
+ logger.info("docs_search_tool", query=query[:80], k=k, hits=len(hits))
159
+ return json.dumps([_hit_to_dict(h) for h in hits], ensure_ascii=False, indent=2)
160
+
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # Tool 2: docs_get_section
164
+ # ---------------------------------------------------------------------------
165
+
166
+ @mcp.tool()
167
+ def docs_get_section_tool(
168
+ file: str,
169
+ heading_path: str,
170
+ include_subsections: bool = False,
171
+ fuzzy: bool = True,
172
+ ) -> str:
173
+ """Retrieve an exact section from an indexed document by heading path.
174
+
175
+ Uses a 5-tier resolution strategy:
176
+ 1. Exact heading_path match
177
+ 2. GitHub-slug match (case/punctuation insensitive)
178
+ 3. Suffix/leaf match (just the last heading segment)
179
+ 4. Fuzzy match via RapidFuzz WRatio ≥ 72
180
+ 5. FTS5 content search fallback
181
+
182
+ On miss, returns a JSON error object with ``did_you_mean`` suggestions.
183
+
184
+ Args:
185
+ file: Repo-relative file path (e.g. "docs/README.md").
186
+ heading_path: Section heading or breadcrumb path.
187
+ Accepts: "Installation > macOS > Homebrew"
188
+ "installation/macos/homebrew"
189
+ "Homebrew" (leaf-only)
190
+ include_subsections: If True, child sections are included in the response.
191
+ fuzzy: Enable tiers 3-5 of resolver (default True).
192
+
193
+ Returns:
194
+ JSON object with: file_path, heading_path, heading_level, content,
195
+ start_line, end_line, and optionally subsections[].
196
+ On failure: {"error": "heading_not_found", "did_you_mean": [...]}
197
+
198
+ Examples:
199
+ docs_get_section_tool("docs/README.md", "Installation > macOS")
200
+ docs_get_section_tool("PHASE_1.md", "Success Criteria", include_subsections=True)
201
+ docs_get_section_tool("docs/adr/ADR-004.md", "Decision", fuzzy=False)
202
+ """
203
+ if not _DOCS_ENABLED:
204
+ return json.dumps({"error": "Docs indexing is disabled. Set CONTEXTUAL_DOCS_ENABLED=1."})
205
+
206
+ pool = _get_pool()
207
+
208
+ try:
209
+ with pool.reader() as conn:
210
+ result = docs_get_section(
211
+ file=file,
212
+ heading_path=heading_path,
213
+ conn=conn,
214
+ include_subsections=include_subsections,
215
+ fuzzy=fuzzy,
216
+ )
217
+ except Exception as exc: # noqa: BLE001
218
+ logger.error("docs_get_section_tool failed", file=file, heading=heading_path, error=str(exc))
219
+ return json.dumps({"error": str(exc)})
220
+
221
+ if isinstance(result, dict):
222
+ # Error object from resolver
223
+ return json.dumps(result, ensure_ascii=False)
224
+
225
+ logger.info(
226
+ "docs_get_section_tool",
227
+ file=file,
228
+ heading=heading_path,
229
+ resolved=result.heading_path,
230
+ )
231
+ return json.dumps(_section_to_dict(result), ensure_ascii=False, indent=2)
232
+
233
+
234
+ # ---------------------------------------------------------------------------
235
+ # Tool 3: docs_list_files
236
+ # ---------------------------------------------------------------------------
237
+
238
+ @mcp.tool()
239
+ def docs_list_files_tool(
240
+ repo: str | None = None,
241
+ glob: str | None = None,
242
+ include_headings: bool = False,
243
+ ) -> str:
244
+ """List all documentation files currently indexed by Contextual.
245
+
246
+ Args:
247
+ repo: Optional path prefix to restrict results
248
+ (e.g. "docs" returns only files under docs/).
249
+ glob: fnmatch glob pattern on file path
250
+ (e.g. "docs/adr/*.md").
251
+ include_headings: If True, return heading_path list per file.
252
+ Useful to understand a file's structure before
253
+ calling docs_get_section.
254
+
255
+ Returns:
256
+ JSON array of file info objects with: file_path, chunk_count,
257
+ and optionally headings[].
258
+
259
+ Examples:
260
+ docs_list_files_tool()
261
+ docs_list_files_tool(repo="docs/adr", include_headings=True)
262
+ docs_list_files_tool(glob="**/*.md")
263
+ """
264
+ if not _DOCS_ENABLED:
265
+ return json.dumps({"error": "Docs indexing is disabled. Set CONTEXTUAL_DOCS_ENABLED=1."})
266
+
267
+ pool = _get_pool()
268
+
269
+ try:
270
+ with pool.reader() as conn:
271
+ files = docs_list_files(
272
+ conn=conn,
273
+ repo=repo,
274
+ glob=glob,
275
+ include_headings=include_headings,
276
+ )
277
+ except Exception as exc: # noqa: BLE001
278
+ logger.error("docs_list_files_tool failed", error=str(exc))
279
+ return json.dumps({"error": str(exc)})
280
+
281
+ logger.info("docs_list_files_tool", count=len(files), repo=repo, glob=glob)
282
+ return json.dumps(
283
+ [_fileinfo_to_dict(f) for f in files],
284
+ ensure_ascii=False,
285
+ indent=2,
286
+ )
@@ -0,0 +1,118 @@
1
+ """FastMCP server for Contextual codebase tools.
2
+
3
+ This module initializes the FastMCP server and registers all tools.
4
+ Tools are defined in tools.py and registered here.
5
+
6
+ Server configuration:
7
+ - Name: "contextual"
8
+ - Transport: stdio (MCP standard)
9
+ - Structured logging to stderr (never stdout in stdio mode)
10
+ - Graceful shutdown on SIGTERM/SIGINT
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ # Suppress warnings BEFORE any other imports to prevent stdout pollution
16
+ import warnings
17
+ warnings.filterwarnings('ignore')
18
+
19
+ import signal
20
+ import sys
21
+ from typing import Any
22
+
23
+ from fastmcp import FastMCP
24
+
25
+ from contextual.observability.logging import configure_logging, get_logger
26
+
27
+ # Configure logging IMMEDIATELY so structlog routes to stderr, not stdout.
28
+ # structlog's default PrintLoggerFactory writes to stdout which breaks MCP stdio.
29
+ configure_logging()
30
+
31
+ logger = get_logger(__name__)
32
+
33
+ # ============================================================================
34
+ # SERVER INITIALIZATION
35
+ # ============================================================================
36
+
37
+ # Create FastMCP server instance
38
+ mcp = FastMCP("contextual")
39
+
40
+
41
+ # ============================================================================
42
+ # GRACEFUL SHUTDOWN
43
+ # ============================================================================
44
+
45
+
46
+ def _handle_shutdown(signum: int, frame: Any) -> None:
47
+ """Handle shutdown signals gracefully.
48
+
49
+ On SIGTERM/SIGINT:
50
+ 1. Log shutdown
51
+ 2. Close database connections
52
+ 3. Flush logs
53
+ 4. Exit cleanly
54
+
55
+ Args:
56
+ signum: Signal number.
57
+ frame: Current stack frame.
58
+ """
59
+ logger.info("Shutdown signal received", signal=signum)
60
+
61
+ try:
62
+ # Close database pools
63
+ from contextual.mcp.tools import _pool
64
+ if _pool is not None:
65
+ _pool.close()
66
+ logger.debug("Database pool closed")
67
+
68
+ logger.info("Shutdown complete")
69
+ sys.exit(0)
70
+ except Exception as e:
71
+ logger.error("Shutdown error", error=str(e))
72
+ sys.exit(1)
73
+
74
+
75
+ # Register signal handlers
76
+ signal.signal(signal.SIGTERM, _handle_shutdown)
77
+ signal.signal(signal.SIGINT, _handle_shutdown)
78
+
79
+
80
+ # ============================================================================
81
+ # TOOL REGISTRATION
82
+ # ============================================================================
83
+
84
+ # Import tools module to register @mcp.tool() decorated functions
85
+ # MUST come after mcp is created but before main() runs
86
+ from contextual.mcp import tools # noqa: F401, E402
87
+
88
+
89
+ # ============================================================================
90
+ # SERVER ENTRYPOINT
91
+ # ============================================================================
92
+
93
+
94
+ def main() -> None:
95
+ """Run the MCP server.
96
+
97
+ This is the main entry point called by the contextual CLI.
98
+ Starts the FastMCP server in stdio mode.
99
+ """
100
+ logger.info("Starting Contextual MCP server")
101
+
102
+ try:
103
+ # Run the server (blocking)
104
+ mcp.run()
105
+ except KeyboardInterrupt:
106
+ logger.info("Server interrupted")
107
+ sys.exit(0)
108
+ except Exception as e:
109
+ logger.error("Server error", error=str(e), exc_info=True)
110
+ sys.exit(1)
111
+
112
+ import os
113
+ if os.getenv("CONTEXTUAL_DOCS_ENABLED", "0") == "1":
114
+ import contextual.mcp.docs_tools # noqa: F401
115
+
116
+
117
+ if __name__ == "__main__":
118
+ main()