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,289 @@
1
+ """Phase 2 schema migration — Migration version 2.
2
+
3
+ Adds documentation-specific columns to the ``chunks`` table and
4
+ extends the FTS5 index to include ``heading_path`` for boosted BM25.
5
+
6
+ HOW TO WIRE INTO migrations.py
7
+ ================================
8
+ Find the ``run_migrations`` function in ``contextual/storage/migrations.py``
9
+ and add ONE call inside the version dispatch block:
10
+
11
+ # Existing (version 1 was Phase 1 baseline):
12
+ if current_version < 1:
13
+ _apply_migration_1(conn)
14
+ record_migration(conn, 1)
15
+ applied += 1
16
+
17
+ # ADD THIS BLOCK:
18
+ if current_version < 2:
19
+ from contextual.storage.migration_v2 import apply_migration_2
20
+ apply_migration_2(conn)
21
+ record_migration(conn, 2)
22
+ applied += 1
23
+
24
+ return applied
25
+
26
+ That's it. The rest of this file is self-contained.
27
+
28
+ Migration strategy
29
+ ------------------
30
+ SQLite supports ``ALTER TABLE ... ADD COLUMN`` for regular tables since 3.1.3.
31
+ We use it for all five new ``chunks`` columns (safe, idempotent via
32
+ ``PRAGMA table_info`` guard).
33
+
34
+ FTS5 virtual tables do NOT support ALTER TABLE. The correct approach is:
35
+ 1. Create ``fts_chunks_new`` with the extended schema.
36
+ 2. Populate from existing ``chunks`` data (heading_path defaults to '').
37
+ 3. Drop ``fts_chunks``.
38
+ 4. Rename ``fts_chunks_new`` → ``fts_chunks``.
39
+ 5. Recreate the three content-sync triggers.
40
+
41
+ SQLite's ``PRAGMA writable_schema`` is NOT used — we keep everything
42
+ through the public DDL surface for safety.
43
+ """
44
+ from __future__ import annotations
45
+
46
+ import sqlite3
47
+ from sqlite3 import Connection
48
+
49
+ import structlog
50
+
51
+ logger = structlog.get_logger(__name__)
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Column definitions added to ``chunks``
55
+ # ---------------------------------------------------------------------------
56
+
57
+ _NEW_CHUNKS_COLUMNS: list[tuple[str, str]] = [
58
+ # (column_name, ALTER TABLE fragment)
59
+ ("file_path", "ALTER TABLE chunks ADD COLUMN file_path TEXT NOT NULL DEFAULT ''"),
60
+ ("source_type", "ALTER TABLE chunks ADD COLUMN source_type TEXT NOT NULL DEFAULT 'code'"),
61
+ ("heading_path", "ALTER TABLE chunks ADD COLUMN heading_path TEXT NOT NULL DEFAULT ''"),
62
+ ("heading_level", "ALTER TABLE chunks ADD COLUMN heading_level INTEGER NOT NULL DEFAULT 0"),
63
+ ("chunk_type", "ALTER TABLE chunks ADD COLUMN chunk_type TEXT NOT NULL DEFAULT 'code'"),
64
+ ("frontmatter_json","ALTER TABLE chunks ADD COLUMN frontmatter_json TEXT NOT NULL DEFAULT '{}'"),
65
+ ]
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # New FTS5 schema (extends Phase 1 schema with heading_path and symbol_name)
69
+ # ---------------------------------------------------------------------------
70
+
71
+ _FTS5_CREATE = """
72
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts_chunks_new USING fts5(
73
+ chunk_id UNINDEXED,
74
+ content,
75
+ file_path UNINDEXED,
76
+ symbol_name,
77
+ heading_path,
78
+ content='chunks',
79
+ content_rowid='id',
80
+ tokenize='unicode61 remove_diacritics 2 tokenchars "_-."'
81
+ )
82
+ """
83
+
84
+ # Triggers keep fts_chunks_new in sync with chunks (external content table)
85
+ _FTS5_TRIGGERS = [
86
+ # INSERT trigger
87
+ """
88
+ CREATE TRIGGER IF NOT EXISTS fts_chunks_ai AFTER INSERT ON chunks BEGIN
89
+ INSERT INTO fts_chunks(rowid, chunk_id, content, file_path, symbol_name, heading_path)
90
+ VALUES (new.id, new.id, new.content, '',
91
+ COALESCE(new.symbol_name, ''), COALESCE(new.heading_path, ''));
92
+ END
93
+ """,
94
+ # DELETE trigger
95
+ """
96
+ CREATE TRIGGER IF NOT EXISTS fts_chunks_ad AFTER DELETE ON chunks BEGIN
97
+ INSERT INTO fts_chunks(fts_chunks, rowid, chunk_id, content, file_path, symbol_name, heading_path)
98
+ VALUES ('delete', old.id, old.id, old.content, '',
99
+ COALESCE(old.symbol_name, ''), COALESCE(old.heading_path, ''));
100
+ END
101
+ """,
102
+ # UPDATE trigger
103
+ """
104
+ CREATE TRIGGER IF NOT EXISTS fts_chunks_au AFTER UPDATE ON chunks BEGIN
105
+ INSERT INTO fts_chunks(fts_chunks, rowid, chunk_id, content, file_path, symbol_name, heading_path)
106
+ VALUES ('delete', old.id, old.id, old.content, '',
107
+ COALESCE(old.symbol_name, ''), COALESCE(old.heading_path, ''));
108
+ INSERT INTO fts_chunks(rowid, chunk_id, content, file_path, symbol_name, heading_path)
109
+ VALUES (new.id, new.id, new.content, '',
110
+ COALESCE(new.symbol_name, ''), COALESCE(new.heading_path, ''));
111
+ END
112
+ """,
113
+ ]
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # Public migration function
118
+ # ---------------------------------------------------------------------------
119
+
120
+ def apply_migration_2(conn: Connection) -> None:
121
+ """Apply Phase 2 schema migration to an existing Contextual database.
122
+
123
+ Operations (all idempotent — safe to call on an already-migrated DB):
124
+ 1. Add five new columns to ``chunks`` (ALTER TABLE ADD COLUMN, no-op if exists).
125
+ 2. Rebuild ``fts_chunks`` virtual table to include ``heading_path``.
126
+ 3. Backfill the new FTS table from existing code chunks.
127
+ 4. Drop Phase 1 triggers; install Phase 2 three-trigger set.
128
+ 5. Add missing performance indexes.
129
+
130
+ Args:
131
+ conn: Open SQLite connection with write access.
132
+ Must have sqlite_vec and FTS5 available.
133
+
134
+ Raises:
135
+ sqlite3.Error: On any unrecoverable DDL failure.
136
+ """
137
+ logger.info("Applying Phase 2 migration (version 2)")
138
+
139
+ # ------------------------------------------------------------------
140
+ # 1. Add new columns to chunks (idempotent)
141
+ # ------------------------------------------------------------------
142
+ existing_cols = {
143
+ row[1]
144
+ for row in conn.execute("PRAGMA table_info(chunks)").fetchall()
145
+ }
146
+
147
+ added: list[str] = []
148
+ for col_name, alter_sql in _NEW_CHUNKS_COLUMNS:
149
+ if col_name not in existing_cols:
150
+ conn.execute(alter_sql)
151
+ added.append(col_name)
152
+ logger.debug("Added column to chunks", column=col_name)
153
+
154
+ if added:
155
+ logger.info("chunks table columns added", columns=added)
156
+ else:
157
+ logger.info("chunks table columns already present — skipping")
158
+
159
+ # Backfill file_path from the files table for existing rows
160
+ conn.execute("""
161
+ UPDATE chunks
162
+ SET file_path = (
163
+ SELECT f.path FROM files f WHERE f.id = chunks.file_id
164
+ )
165
+ WHERE file_path = '' AND file_id IS NOT NULL
166
+ """)
167
+ logger.info("Backfilled file_path on chunks from files table")
168
+
169
+ # ------------------------------------------------------------------
170
+ # 2. Rebuild fts_chunks to include heading_path
171
+ # Skip if already rebuilt (detect by checking column count via
172
+ # fts5 info query — safest approach without touching writable_schema).
173
+ # ------------------------------------------------------------------
174
+ needs_fts_rebuild = _fts_needs_rebuild(conn)
175
+
176
+ if needs_fts_rebuild:
177
+ logger.info("Rebuilding fts_chunks with heading_path and symbol_name columns")
178
+ _rebuild_fts(conn)
179
+ else:
180
+ logger.info("fts_chunks already has heading_path — skipping rebuild")
181
+
182
+ # ------------------------------------------------------------------
183
+ # 3. Add performance indexes (I2)
184
+ # ------------------------------------------------------------------
185
+ conn.execute("""
186
+ CREATE INDEX IF NOT EXISTS idx_chunks_source_type
187
+ ON chunks(source_type)
188
+ """)
189
+ conn.execute("""
190
+ CREATE INDEX IF NOT EXISTS idx_chunks_heading_path
191
+ ON chunks(heading_path)
192
+ WHERE heading_path != ''
193
+ """)
194
+ conn.execute("""
195
+ CREATE INDEX IF NOT EXISTS idx_chunks_file_path_source
196
+ ON chunks(file_id, source_type)
197
+ """)
198
+
199
+ conn.commit()
200
+ logger.info("Phase 2 migration complete")
201
+
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # FTS rebuild helpers
205
+ # ---------------------------------------------------------------------------
206
+
207
+ def _fts_needs_rebuild(conn: Connection) -> bool:
208
+ """Return True if fts_chunks does NOT have a heading_path column.
209
+
210
+ We probe by attempting a query that references heading_path.
211
+ If it raises OperationalError, the column doesn't exist yet.
212
+ """
213
+ try:
214
+ conn.execute(
215
+ "SELECT heading_path FROM fts_chunks WHERE fts_chunks MATCH 'probe_migration_v2' LIMIT 1"
216
+ )
217
+ return False
218
+ except sqlite3.OperationalError:
219
+ return True
220
+
221
+
222
+ def _rebuild_fts(conn: Connection) -> None:
223
+ """Rebuild fts_chunks virtual table with heading_path support.
224
+
225
+ Steps:
226
+ 1. Create fts_chunks_new (extended schema).
227
+ 2. Populate from chunks table (existing rows, heading_path='').
228
+ 3. Drop fts_chunks (and its auto-generated shadow tables).
229
+ 4. Rename fts_chunks_new → fts_chunks.
230
+ 5. Drop Phase 1 triggers (if they exist).
231
+ 6. Install Phase 2 three-trigger set.
232
+ """
233
+ # Step 1: Create new FTS table
234
+ conn.execute(_FTS5_CREATE)
235
+
236
+ # Step 2: Backfill from existing chunks
237
+ # Existing code chunks have heading_path='' (DEFAULT); docs chunks
238
+ # will be re-indexed by DocsPipeline on first run.
239
+ conn.execute("""
240
+ INSERT INTO fts_chunks_new(rowid, chunk_id, content, file_path, symbol_name, heading_path)
241
+ SELECT id, id, content, '', COALESCE(symbol_name, ''), ''
242
+ FROM chunks
243
+ WHERE id NOT IN (SELECT rowid FROM fts_chunks_new)
244
+ """)
245
+ logger.debug("Backfilled fts_chunks_new from chunks table")
246
+
247
+ # Step 3: Drop old fts_chunks
248
+ # FTS5 drop cascades to shadow tables (_data, _idx, _content, _docsize, _config).
249
+ conn.execute("DROP TABLE IF EXISTS fts_chunks")
250
+
251
+ # Step 4: Rename — SQLite supports ALTER TABLE ... RENAME TO for virtual tables
252
+ conn.execute("ALTER TABLE fts_chunks_new RENAME TO fts_chunks")
253
+ logger.debug("Renamed fts_chunks_new → fts_chunks")
254
+
255
+ # Step 5: Drop Phase 1 triggers (they reference old schema)
256
+ for trigger in ("chunks_ai", "chunks_ad", "chunks_au",
257
+ "fts_chunks_ai", "fts_chunks_ad", "fts_chunks_au"):
258
+ conn.execute(f"DROP TRIGGER IF EXISTS {trigger}")
259
+
260
+ # Step 6: Install Phase 2 triggers
261
+ for trigger_sql in _FTS5_TRIGGERS:
262
+ conn.execute(trigger_sql)
263
+
264
+ logger.info("fts_chunks rebuilt with heading_path column, triggers reinstalled")
265
+
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # Standalone CLI helper (python -m contextual.storage.migration_v2)
269
+ # ---------------------------------------------------------------------------
270
+
271
+ if __name__ == "__main__":
272
+ import sys
273
+ from pathlib import Path
274
+
275
+ if len(sys.argv) < 2:
276
+ print("Usage: python -m contextual.storage.migration_v2 <path/to/contextual.db>")
277
+ sys.exit(1)
278
+
279
+ db_path = Path(sys.argv[1])
280
+ if not db_path.exists():
281
+ print(f"Error: {db_path} not found")
282
+ sys.exit(1)
283
+
284
+ import sqlite3 as _sqlite3
285
+ conn = _sqlite3.connect(str(db_path))
286
+ conn.execute("PRAGMA journal_mode=WAL")
287
+ apply_migration_2(conn)
288
+ conn.close()
289
+ print(f"✓ Migration v2 applied to {db_path}")
@@ -0,0 +1,316 @@
1
+ """SQLite migration runner for schema version management.
2
+
3
+ Tracks schema versions and applies pending migrations in order.
4
+ Migrations:
5
+ - v1: Core bi-temporal schema (entities, facts, episodes)
6
+ - v2: Phase 1 codebase indexing (code_symbols, indexing_state, vec0, FTS5)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sqlite3
12
+ import time
13
+ from typing import TYPE_CHECKING
14
+
15
+ from contextual.core.errors import ErrorCode, StorageError, storage_context
16
+ from contextual.observability.logging import get_logger
17
+
18
+
19
+ if TYPE_CHECKING:
20
+ from sqlite3 import Connection
21
+
22
+ logger = get_logger(__name__)
23
+
24
+
25
+ def get_current_version(conn: Connection) -> int:
26
+ """Get current schema version.
27
+
28
+ Reads from schema_version table. Returns 0 if table doesn't exist
29
+ or is empty (indicating fresh database).
30
+
31
+ Args:
32
+ conn: SQLite connection.
33
+
34
+ Returns:
35
+ Current schema version (0 if no version recorded).
36
+
37
+ Raises:
38
+ StorageError: If version query fails.
39
+ """
40
+ try:
41
+ cursor = conn.cursor()
42
+
43
+ # Check if schema_version table exists.
44
+ cursor.execute(
45
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='schema_version'",
46
+ )
47
+ if not cursor.fetchone():
48
+ return 0
49
+
50
+ # Get the latest recorded version.
51
+ cursor.execute("SELECT MAX(version) FROM schema_version")
52
+ result = cursor.fetchone()
53
+
54
+ if result is None or result[0] is None:
55
+ return 0
56
+
57
+ return int(result[0])
58
+
59
+ except sqlite3.Error as e:
60
+ msg = f"Failed to get schema version: {e}"
61
+ raise StorageError(
62
+ code=ErrorCode.STORAGE_MIGRATION_FAILED,
63
+ message=msg,
64
+ context=storage_context(query="get_current_version"),
65
+ ) from e
66
+
67
+
68
+ def record_migration(conn: Connection, version: int) -> None:
69
+ """Record that a migration was applied.
70
+
71
+ Inserts version and timestamp into schema_version table.
72
+
73
+ Args:
74
+ conn: SQLite connection.
75
+ version: Migration version number.
76
+
77
+ Raises:
78
+ StorageError: If recording fails.
79
+ """
80
+ try:
81
+ cursor = conn.cursor()
82
+ now_ms = int(time.time() * 1000)
83
+
84
+ cursor.execute(
85
+ "INSERT INTO schema_version (version, applied_at) VALUES (?, ?)",
86
+ (version, now_ms),
87
+ )
88
+ conn.commit()
89
+
90
+ except sqlite3.Error as e:
91
+ msg = f"Failed to record migration version {version}: {e}"
92
+ raise StorageError(
93
+ code=ErrorCode.STORAGE_MIGRATION_FAILED,
94
+ message=msg,
95
+ context=storage_context(query=f"record_migration({version})"),
96
+ ) from e
97
+
98
+
99
+ def apply_initial_schema(conn: Connection) -> None:
100
+ """Apply initial schema to a fresh database.
101
+
102
+ Creates all tables, indexes, and FTS5 structures.
103
+ Records this as version 1.
104
+
105
+ Args:
106
+ conn: SQLite connection.
107
+
108
+ Raises:
109
+ StorageError: If schema application fails.
110
+ """
111
+ from contextual.storage.schema import apply_schema
112
+
113
+ try:
114
+ # Apply complete schema for version 1.
115
+ apply_schema(conn)
116
+
117
+ # Record the schema as version 1.
118
+ record_migration(conn, 1)
119
+
120
+ except StorageError:
121
+ # Re-raise storage errors as-is.
122
+ raise
123
+ except Exception as e:
124
+ msg = f"Failed to apply initial schema: {e}"
125
+ raise StorageError(
126
+ code=ErrorCode.STORAGE_MIGRATION_FAILED,
127
+ message=msg,
128
+ context=storage_context(),
129
+ ) from e
130
+
131
+
132
+ def apply_migration_v2(conn: Connection) -> None:
133
+ """Apply migration v2: Phase 1 codebase indexing tables.
134
+
135
+ Adds:
136
+ - code_symbols table (function/class/method metadata)
137
+ - indexing_state table (tracks repo indexing status)
138
+ - chunks table (code chunks with embeddings)
139
+ - files table (indexed files)
140
+ - vec0 virtual table (768-dim vectors for jina-v2-code)
141
+ - fts_chunks virtual table (FTS5 full-text search)
142
+
143
+ Args:
144
+ conn: SQLite connection.
145
+
146
+ Raises:
147
+ StorageError: If migration fails.
148
+ """
149
+ try:
150
+ cursor = conn.cursor()
151
+
152
+ # FILES TABLE - tracks indexed files
153
+ cursor.execute("""
154
+ CREATE TABLE IF NOT EXISTS files (
155
+ id INTEGER PRIMARY KEY,
156
+ path TEXT NOT NULL UNIQUE,
157
+ language TEXT NOT NULL,
158
+ mtime INTEGER NOT NULL,
159
+ content_hash TEXT NOT NULL,
160
+ indexed_at INTEGER NOT NULL
161
+ ) STRICT
162
+ """)
163
+
164
+ # CHUNKS TABLE - code chunks with metadata
165
+ cursor.execute("""
166
+ CREATE TABLE IF NOT EXISTS chunks (
167
+ id INTEGER PRIMARY KEY,
168
+ file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
169
+ start_line INTEGER NOT NULL,
170
+ end_line INTEGER NOT NULL,
171
+ symbol_name TEXT,
172
+ symbol_type TEXT,
173
+ content TEXT NOT NULL,
174
+ content_hash TEXT NOT NULL UNIQUE,
175
+ embedding_id INTEGER,
176
+ indexed_at INTEGER NOT NULL
177
+ ) STRICT
178
+ """)
179
+
180
+ # CODE_SYMBOLS TABLE - extracted symbols with metadata
181
+ cursor.execute("""
182
+ CREATE TABLE IF NOT EXISTS code_symbols (
183
+ id INTEGER PRIMARY KEY,
184
+ chunk_id INTEGER NOT NULL REFERENCES chunks(id) ON DELETE CASCADE,
185
+ name TEXT NOT NULL,
186
+ kind TEXT NOT NULL CHECK (
187
+ kind IN ('function','class','method','interface','struct',
188
+ 'enum','type_alias','variable','import')
189
+ ),
190
+ signature TEXT,
191
+ docstring TEXT,
192
+ UNIQUE(chunk_id, name, kind)
193
+ ) STRICT
194
+ """)
195
+
196
+ # INDEXING_STATE TABLE - repo-level indexing metadata
197
+ cursor.execute("""
198
+ CREATE TABLE IF NOT EXISTS indexing_state (
199
+ repo_root TEXT PRIMARY KEY,
200
+ last_full_index_at INTEGER NOT NULL,
201
+ last_incremental_at INTEGER,
202
+ total_files INTEGER NOT NULL,
203
+ total_chunks INTEGER NOT NULL,
204
+ total_symbols INTEGER NOT NULL
205
+ ) STRICT
206
+ """)
207
+
208
+ # Indexes for performance
209
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_chunks_file ON chunks(file_id)")
210
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_chunks_content_hash ON chunks(content_hash)")
211
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_symbols_name ON code_symbols(name)")
212
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_symbols_kind ON code_symbols(kind)")
213
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_symbols_chunk ON code_symbols(chunk_id)")
214
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_files_path ON files(path)")
215
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_files_content_hash ON files(content_hash)")
216
+
217
+ # VEC0 VIRTUAL TABLE - 768-dim vectors (jina-v2-code)
218
+ # Note: vec0 extension must be loaded before this runs
219
+ try:
220
+ cursor.execute("""
221
+ CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks
222
+ USING vec0(
223
+ chunk_id INTEGER PRIMARY KEY,
224
+ embedding FLOAT[768]
225
+ )
226
+ """)
227
+ except sqlite3.OperationalError as e:
228
+ # vec0 not available - log warning but don't fail migration
229
+ logger.warning(
230
+ "vec0 extension not available - vector search disabled",
231
+ error=str(e)
232
+ )
233
+
234
+ # FTS5 VIRTUAL TABLE - full-text search on chunks
235
+ # Using manual sync approach instead of triggers to avoid FTS5 transaction issues
236
+ cursor.execute("""
237
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts_chunks USING fts5(
238
+ chunk_id UNINDEXED,
239
+ content,
240
+ symbol_name,
241
+ tokenize='unicode61 remove_diacritics 2 tokenchars "_"'
242
+ )
243
+ """)
244
+
245
+ # EMBEDDING CACHE TABLE
246
+ from contextual.embedding.cache import initialize_cache
247
+ try:
248
+ initialize_cache(conn)
249
+ logger.info("Embedding cache initialized")
250
+ except Exception as e:
251
+ logger.warning("Failed to initialize embedding cache", error=str(e))
252
+
253
+
254
+ # NOTE: Triggers disabled to avoid "unsafe use of virtual table" errors with FTS5
255
+ # FTS5 sync is now handled manually in index_writer.py after chunk insertion
256
+
257
+ conn.commit()
258
+
259
+ logger.info("Migration v2 applied successfully")
260
+
261
+ except sqlite3.Error as e:
262
+ conn.rollback()
263
+ msg = f"Failed to apply migration v2: {e}"
264
+ raise StorageError(
265
+ code=ErrorCode.STORAGE_MIGRATION_FAILED,
266
+ message=msg,
267
+ context=storage_context(query="apply_migration_v2"),
268
+ ) from e
269
+
270
+
271
+ def run_migrations(conn: Connection) -> int:
272
+ """Run any pending migrations.
273
+
274
+ Checks current version and applies necessary migrations.
275
+
276
+ Args:
277
+ conn: SQLite connection.
278
+
279
+ Returns:
280
+ Number of migrations applied.
281
+
282
+ Raises:
283
+ StorageError: If migrations fail.
284
+ """
285
+ current_version = get_current_version(conn)
286
+ migrations_applied = 0
287
+
288
+ # Version 0 -> 1: initial schema.
289
+ if current_version == 0:
290
+ apply_initial_schema(conn)
291
+ migrations_applied += 1
292
+ current_version = 1
293
+
294
+ # Version 1 -> 2: Phase 1 codebase indexing
295
+ if current_version == 1:
296
+ apply_migration_v2(conn)
297
+ record_migration(conn, 2)
298
+ migrations_applied += 1
299
+ current_version = 2
300
+
301
+ # Version 2 -> 3: Phase 2 docs schema (heading_path columns, FTS rebuild)
302
+ if current_version == 2:
303
+ from contextual.storage.migration_v2 import apply_migration_2
304
+ apply_migration_2(conn)
305
+ record_migration(conn, 3)
306
+ migrations_applied += 1
307
+ current_version = 3
308
+
309
+ if migrations_applied > 0:
310
+ logger.info(
311
+ "Migrations complete",
312
+ applied=migrations_applied,
313
+ current_version=current_version
314
+ )
315
+
316
+ return migrations_applied