sky-dev 0.0.5__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.
sky/memory/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """Project Context Memory Module."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from sky.memory.indexer import RepoIndexer
7
+ from sky.memory.vectorstore import VectorStoreManager
8
+
9
+ def get_vector_store(*args, **kwargs):
10
+ from sky.memory.vectorstore import get_vector_store as _get_vector_store
11
+ return _get_vector_store(*args, **kwargs)
12
+
13
+ def get_indexer(*args, **kwargs):
14
+ from sky.memory.indexer import get_indexer as _get_indexer
15
+ return _get_indexer(*args, **kwargs)
16
+
17
+ def search_context(*args, **kwargs):
18
+ from sky.memory.indexer import search_context as _search_context
19
+ return _search_context(*args, **kwargs)
20
+
21
+ __all__ = [
22
+ "VectorStoreManager",
23
+ "RepoIndexer",
24
+ "get_vector_store",
25
+ "get_indexer",
26
+ "search_context",
27
+ ]
sky/memory/indexer.py ADDED
@@ -0,0 +1,450 @@
1
+ """Repository Indexer for Semantic Code Search."""
2
+
3
+ import ast
4
+ import hashlib
5
+ import logging
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any, Callable, Dict, List, Optional
9
+
10
+ from sky.config import DexProjectConfig
11
+ from sky.memory.vectorstore import VectorStoreManager
12
+ from sky.storage import DatabaseManager
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ class RepoIndexer:
17
+ """Handles incremental parsing and indexing of a repository."""
18
+
19
+ def __init__(self, config: DexProjectConfig, db: DatabaseManager, vector_store: VectorStoreManager) -> None:
20
+ """Initialize the repository indexer.
21
+
22
+ Args:
23
+ config: The active project configuration.
24
+ db: Database manager instance for metadata.
25
+ vector_store: LanceDB vector store manager.
26
+ """
27
+ self.config = config
28
+ self.db = db
29
+ self.vector_store = vector_store
30
+
31
+ # Determine excluded directories
32
+ # We will use config.memory_exclude_patterns, fallback to defaults if not present
33
+ if hasattr(self.config, "memory_exclude_patterns"):
34
+ self.excluded_patterns = self.config.memory_exclude_patterns
35
+ else:
36
+ self.excluded_patterns = [".git", "node_modules", "venv", "__pycache__", ".sky", ".pytest_cache", "*.pyc"]
37
+
38
+ def _get_file_hash(self, file_path: Path) -> str:
39
+ """Compute SHA-256 hash of a file's content.
40
+
41
+ Args:
42
+ file_path: Path to the file.
43
+
44
+ Returns:
45
+ SHA-256 hex digest.
46
+ """
47
+ sha256 = hashlib.sha256()
48
+ try:
49
+ with open(file_path, "rb") as f:
50
+ # Read in chunks to handle large files
51
+ while chunk := f.read(8192):
52
+ sha256.update(chunk)
53
+ return sha256.hexdigest()
54
+ except Exception as e:
55
+ logger.warning(f"Failed to hash {file_path}: {e}")
56
+ return ""
57
+
58
+ def _is_binary_file(self, file_path: Path) -> bool:
59
+ """Check if a file is binary by looking for null bytes in the first 8KB.
60
+
61
+ Args:
62
+ file_path: Path to the file.
63
+
64
+ Returns:
65
+ True if the file appears to be binary, False otherwise.
66
+ """
67
+ try:
68
+ with open(file_path, "rb") as f:
69
+ chunk = f.read(8192)
70
+ return b"\x00" in chunk
71
+ except Exception:
72
+ return True # Safe fallback
73
+
74
+ def _chunk_python(self, content: str) -> List[Dict[str, str]]:
75
+ """AST-based chunking for Python files.
76
+
77
+ Extracts functions, classes, and module-level docstrings as distinct chunks.
78
+
79
+ Args:
80
+ content: Python source code.
81
+
82
+ Returns:
83
+ List of chunks with their type.
84
+ """
85
+ chunks = []
86
+ try:
87
+ tree = ast.parse(content)
88
+
89
+ # Extract module docstring
90
+ module_doc = ast.get_docstring(tree)
91
+ if module_doc:
92
+ chunks.append({
93
+ "content": f'"""\n{module_doc}\n"""',
94
+ "language": "python",
95
+ "type": "docstring"
96
+ })
97
+
98
+ lines = content.splitlines()
99
+
100
+ for node in tree.body:
101
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
102
+ start_line = node.lineno - 1
103
+ end_line = getattr(node, "end_lineno", len(lines))
104
+ func_content = "\n".join(lines[start_line:end_line])
105
+ chunks.append({
106
+ "content": func_content,
107
+ "language": "python",
108
+ "type": "function"
109
+ })
110
+ elif isinstance(node, ast.ClassDef):
111
+ start_line = node.lineno - 1
112
+ end_line = getattr(node, "end_lineno", len(lines))
113
+ class_content = "\n".join(lines[start_line:end_line])
114
+ chunks.append({
115
+ "content": class_content,
116
+ "language": "python",
117
+ "type": "class"
118
+ })
119
+ elif isinstance(node, ast.Assign):
120
+ start_line = node.lineno - 1
121
+ end_line = getattr(node, "end_lineno", start_line + 1)
122
+ assign_content = "\n".join(lines[start_line:end_line])
123
+ chunks.append({
124
+ "content": assign_content,
125
+ "language": "python",
126
+ "type": "assignment"
127
+ })
128
+
129
+ if not chunks:
130
+ # If AST parsing succeeds but finds no structure, fallback
131
+ return self._chunk_by_lines(content, max_lines=50, overlap=10)
132
+
133
+ return chunks
134
+ except SyntaxError:
135
+ # Fallback to line-based chunking if AST parsing fails
136
+ return self._chunk_by_lines(content, max_lines=50, overlap=10)
137
+
138
+ def _chunk_by_lines(self, content: str, max_lines: int = 50, overlap: int = 10) -> List[Dict[str, str]]:
139
+ """Chunk text content by a fixed number of lines with overlap.
140
+
141
+ Args:
142
+ content: Text content to chunk.
143
+ max_lines: Maximum lines per chunk.
144
+ overlap: Number of lines to overlap between chunks.
145
+
146
+ Returns:
147
+ List of chunk dictionaries.
148
+ """
149
+ lines = content.splitlines()
150
+ chunks = []
151
+
152
+ if not lines:
153
+ return chunks
154
+
155
+ i = 0
156
+ while i < len(lines):
157
+ end = min(i + max_lines, len(lines))
158
+ chunk_content = "\n".join(lines[i:end])
159
+ chunks.append({
160
+ "content": chunk_content,
161
+ "type": "lines"
162
+ })
163
+ if end == len(lines):
164
+ break
165
+ i += (max_lines - overlap)
166
+
167
+ return chunks
168
+
169
+ def _chunk_by_file_type(self, file_path: Path, content: str) -> List[Dict[str, str]]:
170
+ """Route to appropriate chunker based on file extension."""
171
+ ext = file_path.suffix.lower()
172
+
173
+ if ext == ".py":
174
+ return self._chunk_python(content)
175
+ elif ext in (".md", ".txt"):
176
+ return self._chunk_by_lines(content, max_lines=30, overlap=5)
177
+ elif ext in (".js", ".ts", ".jsx", ".tsx"):
178
+ return self._chunk_by_lines(content, max_lines=40, overlap=10)
179
+ else:
180
+ return self._chunk_by_lines(content, max_lines=50, overlap=10)
181
+
182
+ def _get_language_for_file(self, file_path: Path) -> str:
183
+ """Return a language hint based on file extension."""
184
+ ext = file_path.suffix.lower()
185
+ lang_map = {
186
+ ".py": "python",
187
+ ".js": "javascript",
188
+ ".ts": "typescript",
189
+ ".jsx": "jsx",
190
+ ".tsx": "tsx",
191
+ ".md": "markdown",
192
+ ".html": "html",
193
+ ".css": "css",
194
+ ".json": "json",
195
+ ".yml": "yaml",
196
+ ".yaml": "yaml",
197
+ ".sh": "bash",
198
+ ".rs": "rust",
199
+ ".go": "go",
200
+ ".java": "java",
201
+ ".cpp": "cpp",
202
+ ".c": "c",
203
+ ".h": "c",
204
+ ".hpp": "cpp",
205
+ }
206
+ return lang_map.get(ext, "text")
207
+
208
+ def _should_exclude(self, file_path: Path) -> bool:
209
+ """Check if a file or directory should be excluded from indexing."""
210
+ import fnmatch
211
+
212
+ # Check against configured patterns
213
+ path_str = str(file_path.relative_to(Path.cwd()) if file_path.is_absolute() else file_path).replace("\\", "/")
214
+ parts = path_str.split("/")
215
+
216
+ for pattern in self.excluded_patterns:
217
+ # Check full path
218
+ if fnmatch.fnmatch(path_str, pattern):
219
+ return True
220
+ # Check individual parts (e.g. 'node_modules' pattern matches 'src/node_modules/index.js')
221
+ for part in parts:
222
+ if fnmatch.fnmatch(part, pattern):
223
+ return True
224
+
225
+ return False
226
+
227
+ def index_file(self, file_path: Path) -> bool:
228
+ """Index a single file incrementally.
229
+
230
+ Args:
231
+ file_path: Path to the file to index.
232
+
233
+ Returns:
234
+ True if the file was (re)indexed, False if unchanged or skipped.
235
+ """
236
+ try:
237
+ if not file_path.exists() or not file_path.is_file():
238
+ return False
239
+
240
+ if self._should_exclude(file_path):
241
+ return False
242
+
243
+ if self._is_binary_file(file_path):
244
+ return False
245
+
246
+ # Calculate hash
247
+ content_hash = self._get_file_hash(file_path)
248
+ if not content_hash:
249
+ return False
250
+
251
+ rel_path = str(file_path.relative_to(Path.cwd()) if file_path.is_absolute() else file_path).replace("\\", "/")
252
+
253
+ # Check existing index entry
254
+ existing_entry = self.db.get_index_entry(rel_path)
255
+ if existing_entry and existing_entry.content_hash == content_hash:
256
+ # File is unchanged
257
+ return False
258
+
259
+ # Read content
260
+ try:
261
+ content = file_path.read_text(encoding="utf-8")
262
+ except UnicodeDecodeError:
263
+ # Attempt with fallback
264
+ content = file_path.read_text(encoding="utf-8", errors="replace")
265
+
266
+ # Remove old chunks if this is an update
267
+ if existing_entry:
268
+ self.vector_store.delete_chunks(rel_path)
269
+
270
+ # Chunk content
271
+ chunks = self._chunk_by_file_type(file_path, content)
272
+ language = self._get_language_for_file(file_path)
273
+
274
+ import datetime
275
+ last_modified = datetime.datetime.fromtimestamp(file_path.stat().st_mtime).isoformat()
276
+
277
+ # Insert new chunks
278
+ for idx, chunk in enumerate(chunks):
279
+ self.vector_store.add_chunk(
280
+ file_path=rel_path,
281
+ chunk_index=idx,
282
+ content=chunk["content"],
283
+ content_hash=content_hash,
284
+ last_modified=last_modified,
285
+ language=chunk.get("language", language)
286
+ )
287
+
288
+ # Update database record
289
+ # We assume db.update_index_entry has been updated to accept language, chunks, last_modified in Phase 3
290
+ try:
291
+ self.db.update_index_entry(
292
+ file_path=rel_path,
293
+ content_hash=content_hash,
294
+ summary=f"Indexed {len(chunks)} chunks",
295
+ language=language,
296
+ chunks=len(chunks),
297
+ last_modified=last_modified
298
+ )
299
+ except TypeError:
300
+ # Fallback for old schema
301
+ self.db.update_index_entry(
302
+ file_path=rel_path,
303
+ content_hash=content_hash,
304
+ summary=f"Indexed {len(chunks)} chunks",
305
+ embedding_id="batch"
306
+ )
307
+
308
+ return True
309
+
310
+ except Exception as e:
311
+ logger.warning(f"Failed to index {file_path}: {e}")
312
+ return False
313
+
314
+ def index_directory(self, directory: Path, recursive: bool = True, progress_callback: Optional[Callable[[str], None]] = None) -> Dict[str, int]:
315
+ """Index all files in a directory.
316
+
317
+ Args:
318
+ directory: Directory to index.
319
+ recursive: Whether to index subdirectories.
320
+ progress_callback: Optional function to call with status updates.
321
+
322
+ Returns:
323
+ Dictionary with indexing statistics.
324
+ """
325
+ stats = {"indexed": 0, "skipped": 0, "errors": 0, "total": 0}
326
+
327
+ def walk_dir(current_dir: Path) -> None:
328
+ if self._should_exclude(current_dir):
329
+ return
330
+
331
+ try:
332
+ for item in current_dir.iterdir():
333
+ if item.is_dir():
334
+ if recursive and not self._should_exclude(item):
335
+ walk_dir(item)
336
+ elif item.is_file():
337
+ stats["total"] += 1
338
+ if progress_callback:
339
+ progress_callback(f"Checking {item.name}")
340
+
341
+ try:
342
+ if self.index_file(item):
343
+ stats["indexed"] += 1
344
+ else:
345
+ stats["skipped"] += 1
346
+ except Exception:
347
+ stats["errors"] += 1
348
+ except Exception as e:
349
+ logger.error(f"Error traversing directory {current_dir}: {e}")
350
+
351
+ walk_dir(directory)
352
+
353
+ # Cleanup deleted files
354
+ pruned = self.prune_deleted_files()
355
+ stats["pruned"] = pruned
356
+
357
+ return stats
358
+
359
+ def get_context(self, query: str, top_k: int = 5, file_pattern: Optional[str] = None) -> str:
360
+ """Retrieve relevant context for a query from the vector store.
361
+
362
+ Args:
363
+ query: The user's query or intent.
364
+ top_k: Number of chunks to retrieve.
365
+ file_pattern: Optional file path to filter.
366
+
367
+ Returns:
368
+ Formatted markdown string containing the context snippets.
369
+ """
370
+ results = self.vector_store.search(query, top_k=top_k, file_filter=file_pattern)
371
+
372
+ if not results:
373
+ return ""
374
+
375
+ context_blocks = []
376
+ for res in results:
377
+ path = res["file_path"]
378
+ lang = res.get("language", "text")
379
+ content = res["content"]
380
+ score = res.get("score", 0.0)
381
+
382
+ block = f"### File: {path} (Relevance: {score:.2f})\n```{lang}\n{content}\n```"
383
+ context_blocks.append(block)
384
+
385
+ return "\n\n".join(context_blocks)
386
+
387
+ def get_file_summary(self, file_path: str) -> Optional[str]:
388
+ """Get the summary of a file from the index."""
389
+ entry = self.db.get_index_entry(file_path)
390
+ return entry.summary if entry else None
391
+
392
+ def prune_deleted_files(self) -> int:
393
+ """Remove entries from the index and vector store for files that no longer exist.
394
+
395
+ Returns:
396
+ Number of files removed.
397
+ """
398
+ indexed_files = []
399
+ # We need list_indexed_files from the db
400
+ if hasattr(self.db, "list_indexed_files"):
401
+ indexed_files = self.db.list_indexed_files()
402
+ elif hasattr(self.db, "get_indexed_files"):
403
+ indexed_files = self.db.get_indexed_files()
404
+
405
+ removed = 0
406
+ for rel_path in indexed_files:
407
+ full_path = Path.cwd() / rel_path
408
+ if not full_path.exists():
409
+ # File deleted
410
+ self.vector_store.delete_chunks(rel_path)
411
+ self.db.remove_index_entry(rel_path)
412
+ removed += 1
413
+
414
+ return removed
415
+
416
+ def get_stats(self) -> Dict[str, Any]:
417
+ """Get combined statistics from database and vector store."""
418
+ vs_stats = self.vector_store.get_stats()
419
+
420
+ try:
421
+ indexed_files = self.db.get_indexed_files()
422
+ total_files = len(indexed_files)
423
+ except Exception:
424
+ total_files = 0
425
+
426
+ return {
427
+ "total_files": total_files,
428
+ "total_chunks": vs_stats.get("total_chunks", 0),
429
+ "excluded_dirs": self.excluded_patterns,
430
+ }
431
+
432
+ def close(self) -> None:
433
+ """Close vector store."""
434
+ self.vector_store.close()
435
+
436
+
437
+ _global_indexer: Optional[RepoIndexer] = None
438
+
439
+ def get_indexer(config: DexProjectConfig, db: DatabaseManager, vector_store: VectorStoreManager) -> RepoIndexer:
440
+ """Get global indexer instance."""
441
+ global _global_indexer
442
+ if _global_indexer is None:
443
+ _global_indexer = RepoIndexer(config, db, vector_store)
444
+ return _global_indexer
445
+
446
+ def search_context(query: str, top_k: int = 5) -> str:
447
+ """Search context using the global indexer."""
448
+ if _global_indexer:
449
+ return _global_indexer.get_context(query, top_k)
450
+ return ""