snow-ai 0.3.30 → 0.3.32

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.
@@ -0,0 +1,640 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import crypto from 'node:crypto';
4
+ import ignore from 'ignore';
5
+ import { logger } from '../utils/logger.js';
6
+ import { CodebaseDatabase } from '../utils/codebaseDatabase.js';
7
+ import { createEmbeddings } from '../api/embedding.js';
8
+ import { loadCodebaseConfig, } from '../utils/codebaseConfig.js';
9
+ import { withRetry } from '../utils/retryUtils.js';
10
+ /**
11
+ * Codebase Index Agent
12
+ * Handles automatic code scanning, chunking, and embedding
13
+ */
14
+ export class CodebaseIndexAgent {
15
+ constructor(projectRoot) {
16
+ Object.defineProperty(this, "db", {
17
+ enumerable: true,
18
+ configurable: true,
19
+ writable: true,
20
+ value: void 0
21
+ });
22
+ Object.defineProperty(this, "config", {
23
+ enumerable: true,
24
+ configurable: true,
25
+ writable: true,
26
+ value: void 0
27
+ });
28
+ Object.defineProperty(this, "projectRoot", {
29
+ enumerable: true,
30
+ configurable: true,
31
+ writable: true,
32
+ value: void 0
33
+ });
34
+ Object.defineProperty(this, "ignoreFilter", {
35
+ enumerable: true,
36
+ configurable: true,
37
+ writable: true,
38
+ value: void 0
39
+ });
40
+ Object.defineProperty(this, "isRunning", {
41
+ enumerable: true,
42
+ configurable: true,
43
+ writable: true,
44
+ value: false
45
+ });
46
+ Object.defineProperty(this, "shouldStop", {
47
+ enumerable: true,
48
+ configurable: true,
49
+ writable: true,
50
+ value: false
51
+ });
52
+ Object.defineProperty(this, "progressCallback", {
53
+ enumerable: true,
54
+ configurable: true,
55
+ writable: true,
56
+ value: void 0
57
+ });
58
+ Object.defineProperty(this, "consecutiveFailures", {
59
+ enumerable: true,
60
+ configurable: true,
61
+ writable: true,
62
+ value: 0
63
+ });
64
+ Object.defineProperty(this, "MAX_CONSECUTIVE_FAILURES", {
65
+ enumerable: true,
66
+ configurable: true,
67
+ writable: true,
68
+ value: 3
69
+ });
70
+ Object.defineProperty(this, "fileWatcher", {
71
+ enumerable: true,
72
+ configurable: true,
73
+ writable: true,
74
+ value: null
75
+ });
76
+ Object.defineProperty(this, "watchDebounceTimers", {
77
+ enumerable: true,
78
+ configurable: true,
79
+ writable: true,
80
+ value: new Map()
81
+ });
82
+ this.projectRoot = projectRoot;
83
+ this.config = loadCodebaseConfig();
84
+ this.db = new CodebaseDatabase(projectRoot);
85
+ this.ignoreFilter = ignore();
86
+ // Load .gitignore if exists
87
+ this.loadGitignore();
88
+ // Add default ignore patterns
89
+ this.addDefaultIgnorePatterns();
90
+ }
91
+ /**
92
+ * Start indexing process
93
+ */
94
+ async start(progressCallback) {
95
+ if (this.isRunning) {
96
+ logger.warn('Indexing already in progress');
97
+ return;
98
+ }
99
+ if (!this.config.enabled) {
100
+ logger.info('Codebase indexing is disabled');
101
+ return;
102
+ }
103
+ this.isRunning = true;
104
+ this.shouldStop = false;
105
+ this.progressCallback = progressCallback;
106
+ try {
107
+ // Initialize database
108
+ this.db.initialize();
109
+ // Check if stopped before starting
110
+ if (this.shouldStop) {
111
+ logger.info('Indexing cancelled before start');
112
+ return;
113
+ }
114
+ // Check if we should resume or start fresh
115
+ const progress = this.db.getProgress();
116
+ const isResuming = progress.status === 'indexing';
117
+ if (isResuming) {
118
+ logger.info('Resuming previous indexing session');
119
+ }
120
+ // Scan files first
121
+ this.notifyProgress({
122
+ totalFiles: 0,
123
+ processedFiles: 0,
124
+ totalChunks: 0,
125
+ currentFile: '',
126
+ status: 'scanning',
127
+ });
128
+ const files = await this.scanFiles();
129
+ logger.info(`Found ${files.length} code files to index`);
130
+ // Reset progress if file count changed (project structure changed)
131
+ // or if previous session was interrupted abnormally
132
+ const shouldReset = isResuming &&
133
+ (progress.totalFiles !== files.length ||
134
+ progress.processedFiles > files.length);
135
+ if (shouldReset) {
136
+ logger.info('File count changed or progress corrupted, resetting progress');
137
+ this.db.updateProgress({
138
+ totalFiles: files.length,
139
+ processedFiles: 0,
140
+ totalChunks: this.db.getTotalChunks(),
141
+ status: 'indexing',
142
+ startedAt: Date.now(),
143
+ lastProcessedFile: undefined,
144
+ });
145
+ }
146
+ else {
147
+ // Update status to indexing
148
+ this.db.updateProgress({
149
+ status: 'indexing',
150
+ totalFiles: files.length,
151
+ startedAt: isResuming ? progress.startedAt : Date.now(),
152
+ });
153
+ }
154
+ // Check if stopped after initialization
155
+ if (this.shouldStop) {
156
+ logger.info('Indexing cancelled after initialization');
157
+ return;
158
+ }
159
+ // Process files with concurrency control
160
+ await this.processFiles(files);
161
+ // Only mark as completed if not stopped by user
162
+ if (!this.shouldStop) {
163
+ // Mark as completed
164
+ this.db.updateProgress({
165
+ status: 'completed',
166
+ completedAt: Date.now(),
167
+ });
168
+ this.notifyProgress({
169
+ totalFiles: files.length,
170
+ processedFiles: files.length,
171
+ totalChunks: this.db.getTotalChunks(),
172
+ currentFile: '',
173
+ status: 'completed',
174
+ });
175
+ logger.info('Indexing completed successfully');
176
+ }
177
+ else {
178
+ logger.info('Indexing paused by user, progress saved');
179
+ }
180
+ }
181
+ catch (error) {
182
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
183
+ this.db.updateProgress({
184
+ status: 'error',
185
+ lastError: errorMessage,
186
+ });
187
+ this.notifyProgress({
188
+ totalFiles: 0,
189
+ processedFiles: 0,
190
+ totalChunks: 0,
191
+ currentFile: '',
192
+ status: 'error',
193
+ error: errorMessage,
194
+ });
195
+ logger.error('Indexing failed', error);
196
+ throw error;
197
+ }
198
+ finally {
199
+ this.isRunning = false;
200
+ this.shouldStop = false;
201
+ // Don't change status to 'idle' if indexing was stopped
202
+ // This allows resuming when returning to chat screen
203
+ // Status will remain as 'indexing' so it can be resumed
204
+ }
205
+ }
206
+ /**
207
+ * Stop indexing gracefully
208
+ */
209
+ async stop() {
210
+ if (!this.isRunning) {
211
+ return;
212
+ }
213
+ logger.info('Stopping indexing...');
214
+ this.shouldStop = true;
215
+ // Wait for current operation to finish
216
+ while (this.isRunning) {
217
+ await new Promise(resolve => setTimeout(resolve, 100));
218
+ }
219
+ }
220
+ /**
221
+ * Check if indexing is in progress
222
+ */
223
+ isIndexing() {
224
+ return this.isRunning;
225
+ }
226
+ /**
227
+ * Get current progress
228
+ */
229
+ getProgress() {
230
+ // Initialize database if not already done
231
+ if (!this.db) {
232
+ this.db = new CodebaseDatabase(this.projectRoot);
233
+ }
234
+ this.db.initialize();
235
+ return this.db.getProgress();
236
+ }
237
+ /**
238
+ * Clear all indexed data
239
+ */
240
+ clear() {
241
+ this.db.clear();
242
+ }
243
+ /**
244
+ * Close database connection
245
+ */
246
+ close() {
247
+ this.stopWatching();
248
+ this.db.close();
249
+ }
250
+ /**
251
+ * Check if watcher is enabled in database
252
+ */
253
+ isWatcherEnabled() {
254
+ try {
255
+ this.db.initialize();
256
+ return this.db.isWatcherEnabled();
257
+ }
258
+ catch (error) {
259
+ return false;
260
+ }
261
+ }
262
+ /**
263
+ * Start watching for file changes
264
+ */
265
+ startWatching(progressCallback) {
266
+ if (this.fileWatcher) {
267
+ logger.debug('File watcher already running');
268
+ return;
269
+ }
270
+ if (!this.config.enabled) {
271
+ logger.info('Codebase indexing is disabled, not starting watcher');
272
+ return;
273
+ }
274
+ // Save progress callback for file change notifications
275
+ if (progressCallback) {
276
+ this.progressCallback = progressCallback;
277
+ }
278
+ try {
279
+ this.fileWatcher = fs.watch(this.projectRoot, { recursive: true }, (_eventType, filename) => {
280
+ if (!filename)
281
+ return;
282
+ // Convert to absolute path
283
+ const filePath = path.join(this.projectRoot, filename);
284
+ const relativePath = path.relative(this.projectRoot, filePath);
285
+ // Check if file should be ignored
286
+ if (this.ignoreFilter.ignores(relativePath)) {
287
+ return;
288
+ }
289
+ // Check if it's a code file
290
+ const ext = path.extname(filename);
291
+ if (!CodebaseIndexAgent.CODE_EXTENSIONS.has(ext)) {
292
+ return;
293
+ }
294
+ // Check if file exists (might be deleted)
295
+ if (!fs.existsSync(filePath)) {
296
+ logger.debug(`File deleted, removing from index: ${relativePath}`);
297
+ this.db.deleteChunksByFile(relativePath);
298
+ return;
299
+ }
300
+ // Debounce file changes
301
+ this.debounceFileChange(filePath, relativePath);
302
+ });
303
+ // Persist watcher state to database
304
+ this.db.setWatcherEnabled(true);
305
+ logger.info('File watcher started successfully');
306
+ }
307
+ catch (error) {
308
+ logger.error('Failed to start file watcher', error);
309
+ }
310
+ }
311
+ /**
312
+ * Stop watching for file changes
313
+ */
314
+ stopWatching() {
315
+ if (this.fileWatcher) {
316
+ this.fileWatcher.close();
317
+ this.fileWatcher = null;
318
+ // Persist watcher state to database
319
+ this.db.setWatcherEnabled(false);
320
+ logger.info('File watcher stopped');
321
+ }
322
+ // Clear all pending debounce timers
323
+ for (const timer of this.watchDebounceTimers.values()) {
324
+ clearTimeout(timer);
325
+ }
326
+ this.watchDebounceTimers.clear();
327
+ }
328
+ /**
329
+ * Debounce file changes to avoid multiple rapid updates
330
+ */
331
+ debounceFileChange(filePath, relativePath) {
332
+ // Clear existing timer for this file
333
+ const existingTimer = this.watchDebounceTimers.get(relativePath);
334
+ if (existingTimer) {
335
+ clearTimeout(existingTimer);
336
+ }
337
+ // Set new timer
338
+ const timer = setTimeout(() => {
339
+ this.watchDebounceTimers.delete(relativePath);
340
+ this.handleFileChange(filePath, relativePath);
341
+ }, 5000); // 5 second debounce - optimized for AI code editing
342
+ this.watchDebounceTimers.set(relativePath, timer);
343
+ }
344
+ /**
345
+ * Handle file change event
346
+ */
347
+ async handleFileChange(filePath, relativePath) {
348
+ try {
349
+ // Notify UI that file is being reindexed
350
+ this.notifyProgress({
351
+ totalFiles: 0,
352
+ processedFiles: 0,
353
+ totalChunks: this.db.getTotalChunks(),
354
+ currentFile: relativePath,
355
+ status: 'indexing',
356
+ });
357
+ await this.processFile(filePath);
358
+ // Notify UI that reindexing is complete
359
+ this.notifyProgress({
360
+ totalFiles: 0,
361
+ processedFiles: 0,
362
+ totalChunks: this.db.getTotalChunks(),
363
+ currentFile: '',
364
+ status: 'completed',
365
+ });
366
+ }
367
+ catch (error) {
368
+ logger.error(`Failed to reindex file: ${relativePath}`, error);
369
+ }
370
+ }
371
+ /**
372
+ * Load .gitignore file
373
+ */
374
+ loadGitignore() {
375
+ const gitignorePath = path.join(this.projectRoot, '.gitignore');
376
+ if (fs.existsSync(gitignorePath)) {
377
+ const content = fs.readFileSync(gitignorePath, 'utf-8');
378
+ this.ignoreFilter.add(content);
379
+ }
380
+ }
381
+ /**
382
+ * Add default ignore patterns
383
+ */
384
+ addDefaultIgnorePatterns() {
385
+ this.ignoreFilter.add([
386
+ 'node_modules',
387
+ '.git',
388
+ '.snow',
389
+ 'dist',
390
+ 'build',
391
+ 'out',
392
+ 'coverage',
393
+ '.next',
394
+ '.nuxt',
395
+ '.cache',
396
+ '*.min.js',
397
+ '*.min.css',
398
+ '*.map',
399
+ 'package-lock.json',
400
+ 'yarn.lock',
401
+ 'pnpm-lock.yaml',
402
+ ]);
403
+ }
404
+ /**
405
+ * Scan project directory for code files
406
+ */
407
+ async scanFiles() {
408
+ const files = [];
409
+ const scanDir = (dir) => {
410
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
411
+ for (const entry of entries) {
412
+ if (this.shouldStop)
413
+ break;
414
+ const fullPath = path.join(dir, entry.name);
415
+ const relativePath = path.relative(this.projectRoot, fullPath);
416
+ // Check if should be ignored
417
+ if (this.ignoreFilter.ignores(relativePath)) {
418
+ continue;
419
+ }
420
+ if (entry.isDirectory()) {
421
+ scanDir(fullPath);
422
+ }
423
+ else if (entry.isFile()) {
424
+ const ext = path.extname(entry.name);
425
+ if (CodebaseIndexAgent.CODE_EXTENSIONS.has(ext)) {
426
+ files.push(fullPath);
427
+ }
428
+ }
429
+ }
430
+ };
431
+ scanDir(this.projectRoot);
432
+ return files;
433
+ }
434
+ /**
435
+ * Process files with concurrency control
436
+ */
437
+ async processFiles(files) {
438
+ const concurrency = this.config.batch.concurrency;
439
+ // Process files in batches
440
+ for (let i = 0; i < files.length; i += concurrency) {
441
+ if (this.shouldStop) {
442
+ logger.info('Indexing stopped by user');
443
+ break;
444
+ }
445
+ const batch = files.slice(i, i + concurrency);
446
+ const promises = batch.map(file => this.processFile(file));
447
+ await Promise.allSettled(promises);
448
+ // Update processed count accurately (current batch end index)
449
+ const processedCount = Math.min(i + batch.length, files.length);
450
+ this.db.updateProgress({
451
+ processedFiles: processedCount,
452
+ });
453
+ }
454
+ }
455
+ /**
456
+ * Process single file
457
+ */
458
+ async processFile(filePath) {
459
+ try {
460
+ const relativePath = path.relative(this.projectRoot, filePath);
461
+ this.notifyProgress({
462
+ totalFiles: this.db.getProgress().totalFiles,
463
+ processedFiles: this.db.getProgress().processedFiles,
464
+ totalChunks: this.db.getTotalChunks(),
465
+ currentFile: relativePath,
466
+ status: 'indexing',
467
+ });
468
+ // Read file content
469
+ const content = fs.readFileSync(filePath, 'utf-8');
470
+ // Calculate file hash for change detection
471
+ const fileHash = crypto
472
+ .createHash('sha256')
473
+ .update(content)
474
+ .digest('hex');
475
+ // Check if file has been indexed and unchanged
476
+ if (this.db.hasFileHash(fileHash)) {
477
+ logger.debug(`File unchanged, skipping: ${relativePath}`);
478
+ return;
479
+ }
480
+ // Delete old chunks for this file
481
+ this.db.deleteChunksByFile(relativePath);
482
+ // Split content into chunks
483
+ const chunks = this.splitIntoChunks(content, relativePath);
484
+ if (chunks.length === 0) {
485
+ logger.debug(`No chunks generated for: ${relativePath}`);
486
+ return;
487
+ }
488
+ // Generate embeddings in batches
489
+ const maxLines = this.config.batch.maxLines;
490
+ const embeddingBatches = [];
491
+ for (let i = 0; i < chunks.length; i += maxLines) {
492
+ const batch = chunks.slice(i, i + maxLines);
493
+ embeddingBatches.push(batch);
494
+ }
495
+ for (const batch of embeddingBatches) {
496
+ if (this.shouldStop)
497
+ break;
498
+ try {
499
+ // Extract text content for embedding
500
+ const texts = batch.map(chunk => chunk.content);
501
+ // Call embedding API with retry
502
+ const response = await withRetry(async () => {
503
+ return await createEmbeddings({
504
+ input: texts,
505
+ });
506
+ }, {
507
+ maxRetries: 3,
508
+ baseDelay: 2000,
509
+ onRetry: (error, attempt, nextDelay) => {
510
+ logger.warn(`Embedding API failed for ${relativePath} (attempt ${attempt}/3), retrying in ${nextDelay}ms...`, error.message);
511
+ },
512
+ });
513
+ // Attach embeddings to chunks
514
+ for (let i = 0; i < batch.length; i++) {
515
+ batch[i].embedding = response.data[i].embedding;
516
+ batch[i].fileHash = fileHash;
517
+ batch[i].createdAt = Date.now();
518
+ batch[i].updatedAt = Date.now();
519
+ }
520
+ // Store chunks to database with retry
521
+ await withRetry(async () => {
522
+ this.db.insertChunks(batch);
523
+ }, {
524
+ maxRetries: 2,
525
+ baseDelay: 500,
526
+ });
527
+ // Update total chunks count
528
+ this.db.updateProgress({
529
+ totalChunks: this.db.getTotalChunks(),
530
+ lastProcessedFile: relativePath,
531
+ });
532
+ // Reset failure counter on success
533
+ this.consecutiveFailures = 0;
534
+ }
535
+ catch (error) {
536
+ this.consecutiveFailures++;
537
+ logger.error(`Failed to process batch for ${relativePath} (consecutive failures: ${this.consecutiveFailures}):`, error);
538
+ // Stop indexing if too many consecutive failures
539
+ if (this.consecutiveFailures >= this.MAX_CONSECUTIVE_FAILURES) {
540
+ logger.error(`Stopping indexing after ${this.MAX_CONSECUTIVE_FAILURES} consecutive failures`);
541
+ this.db.updateProgress({
542
+ status: 'error',
543
+ lastError: `Too many failures: ${error instanceof Error ? error.message : 'Unknown error'}`,
544
+ });
545
+ throw new Error(`Indexing stopped after ${this.MAX_CONSECUTIVE_FAILURES} consecutive failures`);
546
+ }
547
+ // Skip this batch and continue
548
+ continue;
549
+ }
550
+ }
551
+ logger.debug(`Indexed ${chunks.length} chunks from: ${relativePath}`);
552
+ }
553
+ catch (error) {
554
+ logger.error(`Failed to process file: ${filePath}`, error);
555
+ // Continue with next file
556
+ }
557
+ }
558
+ /**
559
+ * Split file content into chunks
560
+ */
561
+ splitIntoChunks(content, filePath) {
562
+ const lines = content.split('\n');
563
+ const chunks = [];
564
+ const maxLinesPerChunk = 100; // Max lines per chunk
565
+ const overlapLines = 10; // Overlap between chunks for context
566
+ for (let i = 0; i < lines.length; i += maxLinesPerChunk - overlapLines) {
567
+ const startLine = i;
568
+ const endLine = Math.min(i + maxLinesPerChunk, lines.length);
569
+ const chunkLines = lines.slice(startLine, endLine);
570
+ const chunkContent = chunkLines.join('\n');
571
+ // Skip empty chunks
572
+ if (chunkContent.trim().length === 0) {
573
+ continue;
574
+ }
575
+ chunks.push({
576
+ filePath,
577
+ content: chunkContent,
578
+ startLine: startLine + 1, // 1-indexed
579
+ endLine: endLine,
580
+ embedding: [], // Will be filled later
581
+ fileHash: '', // Will be filled later
582
+ createdAt: 0,
583
+ updatedAt: 0,
584
+ });
585
+ }
586
+ return chunks;
587
+ }
588
+ /**
589
+ * Notify progress to callback
590
+ */
591
+ notifyProgress(progress) {
592
+ if (this.progressCallback) {
593
+ this.progressCallback(progress);
594
+ }
595
+ }
596
+ }
597
+ // Supported code file extensions
598
+ Object.defineProperty(CodebaseIndexAgent, "CODE_EXTENSIONS", {
599
+ enumerable: true,
600
+ configurable: true,
601
+ writable: true,
602
+ value: new Set([
603
+ '.ts',
604
+ '.tsx',
605
+ '.js',
606
+ '.jsx',
607
+ '.py',
608
+ '.java',
609
+ '.cpp',
610
+ '.c',
611
+ '.h',
612
+ '.hpp',
613
+ '.cs',
614
+ '.go',
615
+ '.rs',
616
+ '.rb',
617
+ '.php',
618
+ '.swift',
619
+ '.kt',
620
+ '.scala',
621
+ '.m',
622
+ '.mm',
623
+ '.sh',
624
+ '.bash',
625
+ '.sql',
626
+ '.graphql',
627
+ '.proto',
628
+ '.json',
629
+ '.yaml',
630
+ '.yml',
631
+ '.toml',
632
+ '.xml',
633
+ '.html',
634
+ '.css',
635
+ '.scss',
636
+ '.less',
637
+ '.vue',
638
+ '.svelte',
639
+ ])
640
+ });
@@ -0,0 +1,34 @@
1
+ export interface EmbeddingOptions {
2
+ model?: string;
3
+ input: string[];
4
+ baseUrl?: string;
5
+ apiKey?: string;
6
+ dimensions?: number;
7
+ task?: string;
8
+ }
9
+ export interface EmbeddingResponse {
10
+ model: string;
11
+ object: string;
12
+ usage: {
13
+ total_tokens: number;
14
+ prompt_tokens: number;
15
+ };
16
+ data: Array<{
17
+ object: string;
18
+ index: number;
19
+ embedding: number[];
20
+ }>;
21
+ }
22
+ /**
23
+ * Create embeddings for text array (single API call)
24
+ * @param options Embedding options
25
+ * @returns Embedding response with vectors
26
+ */
27
+ export declare function createEmbeddings(options: EmbeddingOptions): Promise<EmbeddingResponse>;
28
+ /**
29
+ * Create embedding for single text
30
+ * @param text Single text to embed
31
+ * @param options Optional embedding options
32
+ * @returns Embedding vector
33
+ */
34
+ export declare function createEmbedding(text: string, options?: Partial<EmbeddingOptions>): Promise<number[]>;