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,548 @@
1
+ """Hybrid search engine: BM25 + vector similarity with Reciprocal Rank Fusion.
2
+
3
+ Implements the search pipeline per TECHNICAL_SPEC §5:
4
+ - BM25 via FTS5 (top-100 candidates)
5
+ - Vector cosine similarity via vec0 (top-100 candidates)
6
+ - RRF fusion with configurable weights (BM25=0.6, Dense=0.4)
7
+ - Returns ranked results with scores
8
+
9
+ This module is the retrieval workhorse - all MCP search queries flow through here.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sqlite3
15
+ from dataclasses import dataclass
16
+ from typing import TYPE_CHECKING
17
+
18
+ from contextual.core.errors import ErrorCode, RetrievalError, retrieval_context
19
+ from contextual.observability.logging import get_logger
20
+ from contextual.storage.vec0_manager import search_vectors
21
+
22
+
23
+ if TYPE_CHECKING:
24
+ from sqlite3 import Connection
25
+
26
+
27
+ logger = get_logger(__name__)
28
+
29
+ # ============================================================================
30
+ # CONSTANTS (from TECHNICAL_SPEC §5)
31
+ # ============================================================================
32
+
33
+ BM25_CANDIDATES = 100
34
+ """Number of candidates to fetch from BM25 index."""
35
+
36
+ DENSE_CANDIDATES = 100
37
+ """Number of candidates to fetch from vector search."""
38
+
39
+ RRF_K = 60
40
+ """Reciprocal Rank Fusion constant (Cormack et al. 2009)."""
41
+
42
+ RRF_BM25_WEIGHT = 0.6
43
+ """Weight for BM25 results in RRF scoring (identifiers dominate code queries)."""
44
+
45
+ RRF_DENSE_WEIGHT = 0.4
46
+ """Weight for dense vector results in RRF scoring."""
47
+
48
+
49
+ # ============================================================================
50
+ # DATA MODELS
51
+ # ============================================================================
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class SearchResult:
56
+ """A single search result with metadata and score.
57
+
58
+ Attributes:
59
+ chunk_id: Database chunk ID.
60
+ path: File path relative to project root.
61
+ symbol_name: Function/class name (or None for module chunks).
62
+ chunk_type: Type of code structure.
63
+ start_line: First line of chunk.
64
+ end_line: Last line of chunk.
65
+ score: Combined relevance score (0.0-1.0, higher = more relevant).
66
+ bm25_rank: Rank in BM25 results (None if not in BM25 top-100).
67
+ dense_rank: Rank in vector results (None if not in vector top-100).
68
+ snippet: Preview of chunk content (first 200 chars).
69
+ """
70
+
71
+ chunk_id: int
72
+ path: str
73
+ symbol_name: str | None
74
+ chunk_type: str
75
+ start_line: int
76
+ end_line: int
77
+ score: float
78
+ bm25_rank: int | None
79
+ dense_rank: int | None
80
+ snippet: str
81
+
82
+
83
+ # ============================================================================
84
+ # BM25 SEARCH (FTS5)
85
+ # ============================================================================
86
+
87
+
88
+ def _bm25_search(
89
+ conn: Connection,
90
+ query: str,
91
+ k: int = BM25_CANDIDATES,
92
+ language: str | None = None,
93
+ ) -> list[tuple[int, float, int]]:
94
+ """Execute BM25 full-text search via FTS5.
95
+
96
+ Args:
97
+ conn: SQLite connection.
98
+ query: Search query string (supports FTS5 syntax).
99
+ k: Number of results to return.
100
+ language: Optional language filter (e.g., 'python', 'typescript').
101
+
102
+ Returns:
103
+ List of (chunk_id, bm25_score, rank) tuples ordered by descending score.
104
+
105
+ Raises:
106
+ RetrievalError: If FTS5 query fails.
107
+ """
108
+ try:
109
+ cursor = conn.cursor()
110
+
111
+ # Extract keywords from natural language query
112
+ # Remove common stop words and keep only meaningful terms
113
+ stop_words = {
114
+ 'a', 'an', 'the', 'is', 'are', 'was', 'were', 'be', 'been',
115
+ 'how', 'what', 'when', 'where', 'why', 'which', 'who',
116
+ 'does', 'do', 'did', 'can', 'could', 'should', 'would',
117
+ 'to', 'of', 'in', 'on', 'at', 'for', 'with', 'from', 'by',
118
+ 'i', 'you', 'we', 'they', 'it', 'this', 'that', 'these', 'those',
119
+ }
120
+
121
+ # Tokenize and filter
122
+ tokens = query.lower().split()
123
+ keywords = [t for t in tokens if t not in stop_words and len(t) > 2]
124
+
125
+ # Join keywords with OR operator for FTS5
126
+ fts_query = ' OR '.join(keywords) if keywords else query.strip()
127
+
128
+ # Preserve exact symbols with underscores (snake_case identifiers)
129
+ # For single-word queries containing underscores, force phrase match
130
+ if '_' in query and len(query.split()) == 1:
131
+ fts_query = f'"{query}"' # Force phrase match for snake_case identifiers
132
+
133
+ logger.debug("BM25 query processing", original=query, keywords=keywords, fts_query=fts_query)
134
+
135
+ # Build query parameters
136
+ params: list[str | int] = [fts_query]
137
+
138
+ # Add language filter if specified
139
+ language_filter = ""
140
+ if language:
141
+ language_filter = "AND f.language = ?"
142
+ params.append(language)
143
+
144
+ # Query FTS5 with BM25 ranking (C4: Boost symbol_name with 1.0, 2.5, 0.0)
145
+ # Note: Use subquery to compute bm25() scores before window function
146
+ # (FTS5 bm25() cannot be used directly inside window function context)
147
+ sql = f"""
148
+ SELECT
149
+ chunk_id,
150
+ bm25_score,
151
+ ROW_NUMBER() OVER (ORDER BY bm25_score DESC) AS rank
152
+ FROM (
153
+ SELECT
154
+ c.id as chunk_id,
155
+ -bm25(fts_chunks) AS bm25_score
156
+ FROM fts_chunks fts
157
+ JOIN chunks c ON fts.rowid = c.id
158
+ JOIN files f ON c.file_id = f.id
159
+ WHERE fts_chunks MATCH ?
160
+ {language_filter}
161
+ )
162
+ ORDER BY bm25_score DESC
163
+ LIMIT ?
164
+ """
165
+ params.append(k)
166
+
167
+ cursor.execute(sql, tuple(params))
168
+ results = cursor.fetchall()
169
+
170
+ logger.debug(
171
+ "BM25 search completed",
172
+ query=query,
173
+ results_count=len(results),
174
+ language=language,
175
+ )
176
+
177
+ return results
178
+
179
+ except sqlite3.Error as e:
180
+ msg = f"BM25 search failed: {e}"
181
+ raise RetrievalError(
182
+ code=ErrorCode.RETRIEVAL_BM25_FAILED,
183
+ message=msg,
184
+ context=retrieval_context(
185
+ query=query,
186
+ ),
187
+ ) from e
188
+
189
+
190
+ # ============================================================================
191
+ # VECTOR SEARCH (vec0)
192
+ # ============================================================================
193
+
194
+
195
+ def _vector_search(
196
+ conn: Connection,
197
+ query_embedding: list[float],
198
+ k: int = DENSE_CANDIDATES,
199
+ language: str | None = None,
200
+ ) -> list[tuple[int, float, int]]:
201
+ """Execute vector similarity search via vec0.
202
+
203
+ Args:
204
+ conn: SQLite connection.
205
+ query_embedding: Query vector (768-dim).
206
+ k: Number of results to return.
207
+ language: Optional language filter.
208
+
209
+ Returns:
210
+ List of (chunk_id, similarity_score, rank) tuples ordered by descending score.
211
+
212
+ Raises:
213
+ RetrievalError: If vector search fails.
214
+ """
215
+ try:
216
+ # Get raw vector results (chunk_id, score)
217
+ raw_results = search_vectors(conn, query_embedding, k=k)
218
+
219
+ # If language filter specified, fetch chunk metadata and filter
220
+ if language:
221
+ cursor = conn.cursor()
222
+ chunk_ids = [chunk_id for chunk_id, _ in raw_results]
223
+
224
+ if not chunk_ids:
225
+ return []
226
+
227
+ # Fetch chunks with language filter
228
+ placeholders = ",".join("?" * len(chunk_ids))
229
+ sql = f"""
230
+ SELECT id
231
+ FROM chunks
232
+ WHERE id IN ({placeholders})
233
+ AND language = ?
234
+ """
235
+
236
+ cursor.execute(sql, (*chunk_ids, language))
237
+ valid_ids = {row[0] for row in cursor.fetchall()}
238
+
239
+ # Filter results to only valid chunk IDs
240
+ filtered_results = [
241
+ (chunk_id, score)
242
+ for chunk_id, score in raw_results
243
+ if chunk_id in valid_ids
244
+ ]
245
+
246
+ results = filtered_results[:k]
247
+ else:
248
+ results = raw_results
249
+
250
+ # Add rank (1-indexed)
251
+ ranked_results = [
252
+ (chunk_id, score, rank + 1)
253
+ for rank, (chunk_id, score) in enumerate(results)
254
+ ]
255
+
256
+ logger.debug(
257
+ "Vector search completed",
258
+ results_count=len(ranked_results),
259
+ language=language,
260
+ )
261
+
262
+ return ranked_results
263
+
264
+ except Exception as e:
265
+ msg = f"Vector search failed: {e}"
266
+ raise RetrievalError(
267
+ code=ErrorCode.RETRIEVAL_DENSE_SEARCH_FAILED,
268
+ message=msg,
269
+ context=retrieval_context(),
270
+ ) from e
271
+
272
+
273
+ # ============================================================================
274
+ # RECIPROCAL RANK FUSION
275
+ # ============================================================================
276
+
277
+
278
+ def _reciprocal_rank_fusion(
279
+ bm25_results: list[tuple[int, float, int]],
280
+ vector_results: list[tuple[int, float, int]],
281
+ k: int = RRF_K,
282
+ bm25_weight: float = RRF_BM25_WEIGHT,
283
+ dense_weight: float = RRF_DENSE_WEIGHT,
284
+ ) -> list[tuple[int, float, int | None, int | None]]:
285
+ """Fuse BM25 and vector results using Reciprocal Rank Fusion.
286
+
287
+ RRF formula: score(d) = Σ_s [ weight_s / (k + rank_s(d)) ]
288
+ where s ∈ {bm25, dense}, weight_s is the source weight, rank_s(d)
289
+ is the rank of document d in source s.
290
+
291
+ Args:
292
+ bm25_results: BM25 results as (chunk_id, score, rank).
293
+ vector_results: Vector results as (chunk_id, score, rank).
294
+ k: RRF constant (higher = less emphasis on top ranks).
295
+ bm25_weight: Weight for BM25 results.
296
+ dense_weight: Weight for vector results.
297
+
298
+ Returns:
299
+ List of (chunk_id, rrf_score, bm25_rank, dense_rank) tuples
300
+ ordered by descending RRF score.
301
+ """
302
+ # Build rank maps: chunk_id -> rank
303
+ bm25_ranks = {chunk_id: rank for chunk_id, _, rank in bm25_results}
304
+ dense_ranks = {chunk_id: rank for chunk_id, _, rank in vector_results}
305
+
306
+ # Collect all unique chunk IDs from both sources
307
+ all_chunk_ids = set(bm25_ranks.keys()) | set(dense_ranks.keys())
308
+
309
+ # Compute RRF scores
310
+ rrf_scores: dict[int, float] = {}
311
+ for chunk_id in all_chunk_ids:
312
+ score = 0.0
313
+
314
+ if chunk_id in bm25_ranks:
315
+ score += bm25_weight / (k + bm25_ranks[chunk_id])
316
+
317
+ if chunk_id in dense_ranks:
318
+ score += dense_weight / (k + dense_ranks[chunk_id])
319
+
320
+ rrf_scores[chunk_id] = score
321
+
322
+ # Sort by descending RRF score
323
+ sorted_results = sorted(
324
+ rrf_scores.items(),
325
+ key=lambda x: x[1],
326
+ reverse=True,
327
+ )
328
+
329
+ # Build final result list with source ranks
330
+ fused_results = [
331
+ (
332
+ chunk_id,
333
+ score,
334
+ bm25_ranks.get(chunk_id),
335
+ dense_ranks.get(chunk_id),
336
+ )
337
+ for chunk_id, score in sorted_results
338
+ ]
339
+
340
+ logger.debug(
341
+ "RRF fusion completed",
342
+ total_candidates=len(all_chunk_ids),
343
+ bm25_only=len(bm25_ranks - dense_ranks.keys()),
344
+ dense_only=len(dense_ranks - bm25_ranks.keys()),
345
+ overlap=len(bm25_ranks.keys() & dense_ranks.keys()),
346
+ )
347
+
348
+ return fused_results
349
+
350
+
351
+ # ============================================================================
352
+ # RESULT HYDRATION
353
+ # ============================================================================
354
+
355
+
356
+ def _hydrate_results(
357
+ conn: Connection,
358
+ fused_results: list[tuple[int, float, int | None, int | None]],
359
+ top_k: int,
360
+ ) -> list[SearchResult]:
361
+ """Fetch chunk metadata and build SearchResult objects.
362
+
363
+ Args:
364
+ conn: SQLite connection.
365
+ fused_results: RRF results as (chunk_id, score, bm25_rank, dense_rank).
366
+ top_k: Number of results to return.
367
+
368
+ Returns:
369
+ List of SearchResult objects with full metadata.
370
+
371
+ Raises:
372
+ RetrievalError: If metadata fetch fails.
373
+ """
374
+ if not fused_results:
375
+ return []
376
+
377
+ # Take top-k
378
+ top_results = fused_results[:top_k]
379
+ chunk_ids = [chunk_id for chunk_id, _, _, _ in top_results]
380
+
381
+ try:
382
+ cursor = conn.cursor()
383
+
384
+ # Fetch chunk metadata
385
+ placeholders = ",".join("?" * len(chunk_ids))
386
+ sql = f"""
387
+ SELECT
388
+ c.id,
389
+ f.path,
390
+ c.symbol_name,
391
+ c.symbol_type,
392
+ c.start_line,
393
+ c.end_line,
394
+ c.content
395
+ FROM chunks c
396
+ JOIN files f ON c.file_id = f.id
397
+ WHERE c.id IN ({placeholders})
398
+ """
399
+
400
+ cursor.execute(sql, chunk_ids)
401
+ rows = cursor.fetchall()
402
+
403
+ # Build chunk metadata map
404
+ chunk_meta = {
405
+ row[0]: {
406
+ "path": row[1],
407
+ "symbol_name": row[2],
408
+ "chunk_type": row[3],
409
+ "start_line": row[4],
410
+ "end_line": row[5],
411
+ "content": row[6],
412
+ }
413
+ for row in rows
414
+ }
415
+
416
+ # Build SearchResult objects preserving RRF order
417
+ results = []
418
+ for chunk_id, score, bm25_rank, dense_rank in top_results:
419
+ if chunk_id not in chunk_meta:
420
+ # Chunk was deleted between search and hydration - skip it
421
+ logger.warning("Chunk not found during hydration", chunk_id=chunk_id)
422
+ continue
423
+
424
+ meta = chunk_meta[chunk_id]
425
+
426
+ # Generate snippet (first 200 chars)
427
+ content = meta["content"]
428
+ snippet = content[:200] + "..." if len(content) > 200 else content
429
+
430
+ result = SearchResult(
431
+ chunk_id=chunk_id,
432
+ path=meta["path"],
433
+ symbol_name=meta["symbol_name"],
434
+ chunk_type=meta["chunk_type"],
435
+ start_line=meta["start_line"],
436
+ end_line=meta["end_line"],
437
+ score=score,
438
+ bm25_rank=bm25_rank,
439
+ dense_rank=dense_rank,
440
+ snippet=snippet,
441
+ )
442
+
443
+ results.append(result)
444
+
445
+ logger.debug(
446
+ "Results hydrated",
447
+ requested=top_k,
448
+ returned=len(results),
449
+ )
450
+
451
+ return results
452
+
453
+ except sqlite3.Error as e:
454
+ msg = f"Failed to hydrate search results: {e}"
455
+ raise RetrievalError(
456
+ code=ErrorCode.RETRIEVAL_RERANK_FAILED, # Using existing code for now
457
+ message=msg,
458
+ context=retrieval_context(),
459
+ ) from e
460
+
461
+
462
+ # ============================================================================
463
+ # PUBLIC API
464
+ # ============================================================================
465
+
466
+
467
+ def hybrid_search(
468
+ conn: Connection,
469
+ query: str,
470
+ query_embedding: list[float],
471
+ top_k: int = 10,
472
+ language: str | None = None,
473
+ bm25_weight: float | None = None,
474
+ dense_weight: float | None = None,
475
+ ) -> list[SearchResult]:
476
+ """Execute hybrid BM25 + vector search with RRF fusion.
477
+
478
+ This is the main search entry point. It:
479
+ 1. Runs BM25 search (top-100)
480
+ 2. Runs vector search (top-100)
481
+ 3. Fuses results with RRF
482
+ 4. Hydrates metadata
483
+ 5. Returns top-k results
484
+
485
+ Args:
486
+ conn: SQLite connection (must have vec0 loaded).
487
+ query: Natural language query string.
488
+ query_embedding: Query embedding vector (768-dim).
489
+ top_k: Number of results to return (1-100).
490
+ language: Optional language filter.
491
+ bm25_weight: Optional BM25 weight for RRF (default: RRF_BM25_WEIGHT).
492
+ Source-type aware: pass 0.55 for code, 0.4 for docs.
493
+ dense_weight: Optional dense weight for RRF (default: RRF_DENSE_WEIGHT).
494
+ Source-type aware: pass 0.45 for code, 0.6 for docs.
495
+
496
+ Returns:
497
+ List of SearchResult objects ordered by relevance.
498
+
499
+ Raises:
500
+ RetrievalError: If search fails at any stage.
501
+
502
+ Example:
503
+ >>> from contextual.embedding import get_embedder
504
+ >>> embedder = get_embedder()
505
+ >>> query = "parse JSON configuration"
506
+ >>> embedding = embedder.embed([query])[0]
507
+ >>> results = hybrid_search(conn, query, embedding, top_k=10)
508
+ >>> for r in results:
509
+ ... print(f"{r.path}:{r.start_line} - {r.score:.3f}")
510
+ """
511
+ # Use provided weights or fall back to defaults
512
+ _bm25_weight = bm25_weight if bm25_weight is not None else RRF_BM25_WEIGHT
513
+ _dense_weight = dense_weight if dense_weight is not None else RRF_DENSE_WEIGHT
514
+
515
+ logger.info(
516
+ "Starting hybrid search",
517
+ query=query,
518
+ top_k=top_k,
519
+ language=language,
520
+ bm25_weight=_bm25_weight,
521
+ dense_weight=_dense_weight,
522
+ )
523
+
524
+ # Execute both searches in parallel (SQLite WAL allows concurrent reads)
525
+ bm25_results = _bm25_search(conn, query, k=BM25_CANDIDATES, language=language)
526
+ vector_results = _vector_search(conn, query_embedding, k=DENSE_CANDIDATES, language=language)
527
+
528
+ # Fuse with RRF using source-type-aware weights
529
+ fused_results = _reciprocal_rank_fusion(
530
+ bm25_results,
531
+ vector_results,
532
+ k=RRF_K,
533
+ bm25_weight=_bm25_weight,
534
+ dense_weight=_dense_weight,
535
+ )
536
+
537
+ # Hydrate and return
538
+ results = _hydrate_results(conn, fused_results, top_k)
539
+
540
+ logger.info(
541
+ "Hybrid search completed",
542
+ query=query,
543
+ results_count=len(results),
544
+ bm25_candidates=len(bm25_results),
545
+ vector_candidates=len(vector_results),
546
+ )
547
+
548
+ return results
@@ -0,0 +1,52 @@
1
+ """Security hardening: path safety, input sanitization, and workspace isolation.
2
+
3
+ Prevents path traversal, SQL/FTS5 injection, Unicode-based attacks, and prompt
4
+ injection via indexed content. Implements per-project workspace isolation.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from contextual.security.paths import (
9
+ ensure_parent_exists,
10
+ is_safe_path,
11
+ safe_join,
12
+ safe_path,
13
+ validate_filename,
14
+ )
15
+ from contextual.security.sanitize import (
16
+ detect_prompt_injection_patterns,
17
+ escape_for_logging,
18
+ escape_fts5_match_query,
19
+ sanitize_file_content,
20
+ sanitize_user_input,
21
+ strip_unicode_control_chars,
22
+ validate_sql_identifier,
23
+ wrap_untrusted_content,
24
+ )
25
+ from contextual.security.workspace import (
26
+ WorkspaceManager,
27
+ get_workspace_manager,
28
+ init_workspace,
29
+ )
30
+
31
+
32
+ __all__ = [
33
+ # Path safety
34
+ "safe_path",
35
+ "is_safe_path",
36
+ "safe_join",
37
+ "validate_filename",
38
+ "ensure_parent_exists",
39
+ # Input sanitization
40
+ "strip_unicode_control_chars",
41
+ "escape_fts5_match_query",
42
+ "validate_sql_identifier",
43
+ "sanitize_user_input",
44
+ "sanitize_file_content",
45
+ "detect_prompt_injection_patterns",
46
+ "wrap_untrusted_content",
47
+ "escape_for_logging",
48
+ # Workspace management
49
+ "WorkspaceManager",
50
+ "init_workspace",
51
+ "get_workspace_manager",
52
+ ]