sonance-brand-mcp 1.3.104 → 1.3.106

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.
@@ -141,6 +141,132 @@ function debugLog(message: string, data?: unknown) {
141
141
  }
142
142
  }
143
143
 
144
+ /**
145
+ * Sanitize a JSON string by finding the correct end point using bracket balancing.
146
+ * Handles cases where LLM outputs trailing garbage like extra ]} characters.
147
+ */
148
+ function sanitizeJsonString(text: string): string {
149
+ const trimmed = text.trim();
150
+
151
+ debugLog("[sanitizeJsonString] Starting", {
152
+ inputLength: trimmed.length,
153
+ first100: trimmed.substring(0, 100),
154
+ last100: trimmed.substring(trimmed.length - 100)
155
+ });
156
+
157
+ // Try parsing as-is first
158
+ try {
159
+ JSON.parse(trimmed);
160
+ debugLog("[sanitizeJsonString] Parsed as-is successfully");
161
+ return trimmed;
162
+ } catch (e) {
163
+ const error = e as Error;
164
+ debugLog("[sanitizeJsonString] Initial parse failed", {
165
+ error: error.message,
166
+ // Try to find where the error is
167
+ errorPosition: error.message.match(/position (\d+)/)?.[1]
168
+ });
169
+ }
170
+
171
+ // Find the correct end using balanced bracket counting
172
+ let braceCount = 0;
173
+ let bracketCount = 0;
174
+ let inString = false;
175
+ let escapeNext = false;
176
+ let endIndex = -1;
177
+
178
+ for (let i = 0; i < trimmed.length; i++) {
179
+ const char = trimmed[i];
180
+
181
+ if (escapeNext) {
182
+ escapeNext = false;
183
+ continue;
184
+ }
185
+
186
+ if (char === '\\' && inString) {
187
+ escapeNext = true;
188
+ continue;
189
+ }
190
+
191
+ if (char === '"') {
192
+ inString = !inString;
193
+ continue;
194
+ }
195
+
196
+ if (inString) continue;
197
+
198
+ if (char === '{') braceCount++;
199
+ if (char === '}') braceCount--;
200
+ if (char === '[') bracketCount++;
201
+ if (char === ']') bracketCount--;
202
+
203
+ // Found the end of the root object
204
+ if (braceCount === 0 && bracketCount === 0 && char === '}') {
205
+ endIndex = i;
206
+ break;
207
+ }
208
+ }
209
+
210
+ debugLog("[sanitizeJsonString] Bracket counting complete", {
211
+ endIndex,
212
+ finalBraceCount: braceCount,
213
+ finalBracketCount: bracketCount,
214
+ inString
215
+ });
216
+
217
+ if (endIndex !== -1) {
218
+ const sanitized = trimmed.slice(0, endIndex + 1);
219
+ const removed = trimmed.slice(endIndex + 1);
220
+ debugLog("[sanitizeJsonString] Attempting parse of balanced portion", {
221
+ sanitizedLength: sanitized.length,
222
+ removedLength: removed.length,
223
+ removedChars: removed.substring(0, 50)
224
+ });
225
+ try {
226
+ JSON.parse(sanitized);
227
+ debugLog("[sanitizeJsonString] SUCCESS: Removed trailing garbage", {
228
+ originalLength: trimmed.length,
229
+ sanitizedLength: sanitized.length,
230
+ removedChars: removed
231
+ });
232
+ return sanitized;
233
+ } catch (e) {
234
+ const error = e as Error;
235
+ debugLog("[sanitizeJsonString] Balanced portion still invalid", {
236
+ error: error.message
237
+ });
238
+ }
239
+ }
240
+
241
+ // Last resort: progressively remove trailing characters
242
+ debugLog("[sanitizeJsonString] Starting progressive trimming");
243
+ let attempt = trimmed;
244
+ let iterations = 0;
245
+ const maxIterations = Math.min(1000, trimmed.length); // Safety limit
246
+
247
+ while (attempt.length > 1 && iterations < maxIterations) {
248
+ iterations++;
249
+ try {
250
+ JSON.parse(attempt);
251
+ debugLog("[sanitizeJsonString] SUCCESS via progressive trimming", {
252
+ originalLength: trimmed.length,
253
+ sanitizedLength: attempt.length,
254
+ iterations
255
+ });
256
+ return attempt;
257
+ } catch {
258
+ attempt = attempt.slice(0, -1).trim();
259
+ }
260
+ }
261
+
262
+ debugLog("[sanitizeJsonString] FAILED: Could not sanitize JSON", {
263
+ originalLength: trimmed.length,
264
+ iterations
265
+ });
266
+
267
+ return trimmed; // Return original if all else fails
268
+ }
269
+
144
270
  /**
145
271
  * Extract JSON from LLM response that may contain preamble text
146
272
  * Handles: pure JSON, markdown code fences, and text with embedded JSON
@@ -154,14 +280,14 @@ function debugLog(message: string, data?: unknown) {
154
280
  function extractJsonFromResponse(text: string): string {
155
281
  // Try direct parse first - if it starts with {, it's likely pure JSON
156
282
  const trimmed = text.trim();
157
- if (trimmed.startsWith('{')) return trimmed;
283
+ if (trimmed.startsWith('{')) return sanitizeJsonString(trimmed);
158
284
 
159
285
  // PRIORITY 1: Look specifically for ```json fence (most reliable)
160
286
  const jsonFenceMatch = text.match(/```json\s*([\s\S]*?)```/);
161
287
  if (jsonFenceMatch) {
162
288
  const extracted = jsonFenceMatch[1].trim();
163
289
  debugLog("Extracted JSON from explicit json fence", { previewLength: extracted.length });
164
- return extracted;
290
+ return sanitizeJsonString(extracted);
165
291
  }
166
292
 
167
293
  // PRIORITY 2: Find raw JSON object in text (handles prose + JSON at end)
@@ -169,7 +295,7 @@ function extractJsonFromResponse(text: string): string {
169
295
  const modMatch = text.match(/(\{"modifications"\s*:\s*\[[\s\S]*\](?:\s*,\s*"explanation"\s*:\s*"[^"]*")?\s*\})/);
170
296
  if (modMatch) {
171
297
  debugLog("Extracted JSON via modifications pattern", { length: modMatch[1].length });
172
- return modMatch[1];
298
+ return sanitizeJsonString(modMatch[1]);
173
299
  }
174
300
 
175
301
  // PRIORITY 3: Find any JSON object starting with {"
@@ -181,7 +307,7 @@ function extractJsonFromResponse(text: string): string {
181
307
  preambleLength: jsonStart,
182
308
  jsonLength: extracted.length
183
309
  });
184
- return extracted;
310
+ return sanitizeJsonString(extracted);
185
311
  }
186
312
 
187
313
  // PRIORITY 4 (last resort): Try generic code fence
@@ -192,12 +318,12 @@ function extractJsonFromResponse(text: string): string {
192
318
  // Only use if it looks like JSON
193
319
  if (extracted.startsWith('{')) {
194
320
  debugLog("Extracted JSON from generic fence", { previewLength: extracted.length });
195
- return extracted;
321
+ return sanitizeJsonString(extracted);
196
322
  }
197
323
  }
198
324
 
199
- // Return as-is if no JSON structure found
200
- return text;
325
+ // Return as-is if no JSON structure found (sanitizeJsonString will handle validation)
326
+ return sanitizeJsonString(text);
201
327
  }
202
328
 
203
329
  /**
@@ -328,9 +454,19 @@ function findElementLineInFile(
328
454
  }
329
455
  }
330
456
 
457
+ // Helper: Normalize text for searching (handle bullets, whitespace, special chars)
458
+ const normalizeTextForSearch = (text: string): string => {
459
+ return text
460
+ .replace(/[•·●○◦‣⁃▪▫→←↓↑]/g, '') // Remove bullet/arrow characters
461
+ .replace(/\s+/g, ' ') // Collapse whitespace
462
+ .replace(/[""'']/g, '"') // Normalize quotes
463
+ .trim();
464
+ };
465
+
331
466
  // PRIORITY 2: Exact text content in JSX
332
467
  if (focusedElement.textContent && focusedElement.textContent.trim().length >= 2) {
333
468
  const text = focusedElement.textContent.trim();
469
+ const normalizedText = normalizeTextForSearch(text);
334
470
  // Escape special regex characters in the text
335
471
  const escapedText = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
336
472
 
@@ -370,6 +506,34 @@ function findElementLineInFile(
370
506
  }
371
507
  }
372
508
  }
509
+
510
+ // PRIORITY 2c: Word-based matching for text with special characters (bullets, etc.)
511
+ // Extract significant words and find lines containing multiple of them
512
+ if (normalizedText.length > 10) {
513
+ const commonWords = ['the', 'this', 'that', 'with', 'from', 'have', 'will', 'your', 'for', 'and', 'use'];
514
+ const significantWords = normalizedText.split(' ')
515
+ .filter(word => word.length >= 4 && !commonWords.includes(word.toLowerCase()))
516
+ .slice(0, 5); // Take up to 5 significant words
517
+
518
+ if (significantWords.length >= 2) {
519
+ for (let i = 0; i < lines.length; i++) {
520
+ const lineLower = lines[i].toLowerCase();
521
+ const matchCount = significantWords.filter(word =>
522
+ lineLower.includes(word.toLowerCase())
523
+ ).length;
524
+
525
+ // Require at least 2 word matches for confidence
526
+ if (matchCount >= 2) {
527
+ return {
528
+ lineNumber: i + 1,
529
+ snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
530
+ confidence: 'medium',
531
+ matchedBy: `word match: ${significantWords.slice(0, 3).join(', ')}... (${matchCount} words)`
532
+ };
533
+ }
534
+ }
535
+ }
536
+ }
373
537
  }
374
538
 
375
539
  // PRIORITY 2b: Look for icon patterns if type is button and no text (like X buttons)
@@ -390,163 +554,122 @@ function findElementLineInFile(
390
554
  }
391
555
  }
392
556
 
393
- // PRIORITY 3: Distinctive className patterns (ONLY semantic/custom classes)
557
+ // PRIORITY 3: Distinctive className patterns (semantic classes from design system)
394
558
  if (focusedElement.className) {
395
- // Filter OUT all Tailwind utility patterns - these are too generic and match wrong elements
396
- const tailwindPatterns = [
397
- /^p[xytblr]?-/, // padding: px-4, py-2, pt-1
398
- /^m[xytblr]?-/, // margin: mx-auto, my-4
399
- /^-?m[xytblr]?-/, // negative margins: -mt-4
400
- /^w-/, // width: w-full, w-4
401
- /^h-/, // height: h-10, h-full
402
- /^min-/, // min-width/height
403
- /^max-/, // max-width/height
404
- /^size-/, // size utilities
405
- /^bg-/, // background
406
- /^text-/, // text color/size
407
- /^font-/, // font-weight
408
- /^leading-/, // line-height
409
- /^tracking-/, // letter-spacing
410
- /^rounded/, // border-radius: rounded-sm, rounded-lg
411
- /^border/, // border
412
- /^outline/, // outline
413
- /^ring/, // ring
414
- /^shadow/, // shadow
415
- /^flex/, // flex utilities
416
- /^grow/, // flex-grow
417
- /^shrink/, // flex-shrink
418
- /^basis-/, // flex-basis
419
- /^grid/, // grid utilities
420
- /^col-/, // grid columns
421
- /^row-/, // grid rows
422
- /^gap-/, // gap
423
- /^items-/, // align-items
424
- /^justify-/, // justify-content
425
- /^self-/, // align-self
426
- /^place-/, // place utilities
427
- /^space-/, // space-x, space-y
428
- /^overflow/, // overflow
429
- /^scroll/, // scroll utilities
430
- /^cursor-/, // cursor
431
- /^pointer-/, // pointer-events
432
- /^select-/, // user-select
433
- /^opacity-/, // opacity
434
- /^visible/, // visibility
435
- /^invisible/, // visibility
436
- /^z-/, // z-index
437
- /^inset/, // inset utilities
438
- /^top-/, // positioning
439
- /^right-/, // positioning
440
- /^bottom-/, // positioning
441
- /^left-/, // positioning
442
- /^static/, // position
443
- /^fixed/, // position
444
- /^absolute/, // position
445
- /^relative/, // position
446
- /^sticky/, // position
447
- /^transition/, // transitions
448
- /^duration-/, // duration
449
- /^ease-/, // timing function
450
- /^delay-/, // delay
451
- /^animate-/, // animations
452
- /^transform/, // transform
453
- /^scale-/, // scale
454
- /^rotate-/, // rotate
455
- /^translate-/, // translate
456
- /^skew-/, // skew
457
- /^origin-/, // transform-origin
458
- /^appearance-/, // appearance
459
- /^accent-/, // accent-color
460
- /^caret-/, // caret-color
461
- /^fill-/, // SVG fill
462
- /^stroke-/, // SVG stroke
463
- /^object-/, // object-fit/position
464
- /^aspect-/, // aspect-ratio
465
- /^container/, // container
466
- /^columns-/, // columns
467
- /^break-/, // word-break
468
- /^truncate/, // text-overflow
469
- /^whitespace-/, // whitespace
470
- /^list-/, // list-style
471
- /^decoration-/, // text-decoration
472
- /^underline/, // underline
473
- /^overline/, // overline
474
- /^line-through/, // line-through
475
- /^no-underline/, // no underline
476
- /^antialiased/, // font-smoothing
477
- /^subpixel/, // subpixel-antialiased
478
- /^italic/, // font-style
479
- /^not-italic/, // font-style
480
- /^uppercase/, // text-transform
481
- /^lowercase/, // text-transform
482
- /^capitalize/, // text-transform
483
- /^normal-case/, // text-transform
484
- /^align-/, // vertical-align
485
- /^indent-/, // text-indent
486
- /^content-/, // content
487
- /^will-change/, // will-change
488
- /^hover:/, // hover states
489
- /^focus:/, // focus states
490
- /^focus-within:/, // focus-within states
491
- /^focus-visible:/, // focus-visible states
492
- /^active:/, // active states
493
- /^visited:/, // visited states
494
- /^disabled:/, // disabled states
495
- /^checked:/, // checked states
496
- /^group-/, // group utilities
497
- /^peer-/, // peer utilities
498
- /^dark:/, // dark mode
499
- /^light:/, // light mode
500
- /^motion-/, // motion utilities
501
- /^print:/, // print utilities
502
- /^portrait:/, // orientation
503
- /^landscape:/, // orientation
504
- /^sm:|^md:|^lg:|^xl:|^2xl:/, // responsive prefixes
505
- /^data-\[/, // data attribute variants
506
- /^aria-/, // aria variants
507
- /^supports-/, // supports variants
508
- /^has-/, // has variants
509
- /^group$/, // group class itself
510
- /^peer$/, // peer class itself
511
- /^sr-only/, // screen reader only
512
- /^not-sr-only/, // not screen reader only
513
- /^isolate/, // isolation
514
- /^block$/, // display
515
- /^inline/, // display
516
- /^hidden$/, // display
517
- /^table/, // display table
559
+ // SEMANTIC CLASS DETECTION: Instead of filtering OUT utilities, filter IN semantics
560
+ // These patterns identify classes that reference CSS variables (design tokens)
561
+ const semanticClassPatterns = [
562
+ // Background colors referencing CSS variables
563
+ /^bg-(card|background|foreground|primary|secondary|accent|destructive|muted|popover|input|sidebar)/,
564
+ // Text colors referencing CSS variables
565
+ /^text-(foreground|muted|primary|secondary|accent|destructive|card)/,
566
+ // Border colors referencing CSS variables
567
+ /^border-(border|card|input|primary|accent|destructive|muted)/,
568
+ // Any class containing these semantic tokens (catches hover:bg-card-hover, etc.)
569
+ /-card($|-)/,
570
+ /-foreground($|-)/,
571
+ /-background($|-)/,
572
+ /-border($|-)/,
573
+ /-primary($|-)/,
574
+ /-secondary($|-)/,
575
+ /-accent($|-)/,
576
+ /-muted($|-)/,
577
+ /-destructive($|-)/,
578
+ /-popover($|-)/,
579
+ /-sidebar($|-)/,
580
+ // Component-specific classes (likely custom)
581
+ /^(btn|card|modal|dialog|panel|sidebar|nav|menu|dropdown|tooltip|badge|chip|tag|avatar|icon)-/,
582
+ // Data attribute classes (often semantic)
583
+ /^\[data-/,
518
584
  ];
519
585
 
520
- const isTailwindUtility = (cls: string) =>
521
- tailwindPatterns.some(pattern => pattern.test(cls));
586
+ const isSemanticClass = (cls: string): boolean => {
587
+ // Remove variant prefixes for checking (hover:, focus:, dark:, etc.)
588
+ const baseClass = cls.replace(/^(hover:|focus:|active:|dark:|light:|md:|lg:|xl:|2xl:|sm:)+/, '');
589
+ return semanticClassPatterns.some(pattern => pattern.test(baseClass));
590
+ };
522
591
 
523
- // Only use classes that are NOT Tailwind utilities (likely custom/semantic)
592
+ // Find semantic classes
524
593
  const semanticClasses = focusedElement.className.split(/\s+/)
525
- .filter(c => c.length > 3 && !isTailwindUtility(c));
594
+ .filter(c => c.length > 3 && isSemanticClass(c));
526
595
 
527
596
  // Only proceed if we found semantic classes
528
597
  if (semanticClasses.length > 0) {
598
+ debugLog("Found semantic classes for element search", {
599
+ semanticClasses,
600
+ originalClasses: focusedElement.className.substring(0, 80)
601
+ });
602
+
529
603
  for (const cls of semanticClasses) {
604
+ // Remove variant prefix for searching (hover:bg-card -> bg-card)
605
+ const searchClass = cls.replace(/^(hover:|focus:|active:|dark:|light:|md:|lg:|xl:|2xl:|sm:)+/, '');
606
+
530
607
  for (let i = 0; i < lines.length; i++) {
531
- if (lines[i].includes(cls)) {
608
+ if (lines[i].includes(searchClass)) {
532
609
  return {
533
610
  lineNumber: i + 1,
534
611
  snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
535
612
  confidence: 'medium',
536
- matchedBy: `semantic className "${cls}"`
613
+ matchedBy: `semantic className "${searchClass}"`
537
614
  };
538
615
  }
539
616
  }
540
617
  }
541
618
  }
542
619
 
543
- // Log when we filtered out all classes (helpful for debugging)
620
+ // Log when we couldn't find semantic classes (helpful for debugging)
544
621
  if (semanticClasses.length === 0 && focusedElement.className.trim().length > 0) {
545
- console.log('[findElementLineInFile] All classes filtered as Tailwind utilities:',
622
+ console.log('[findElementLineInFile] No semantic classes found in:',
546
623
  focusedElement.className.substring(0, 100));
547
624
  }
548
625
  }
549
626
 
627
+ // ========== FALLBACK MATCHING (for non-Sonance codebases) ==========
628
+ // These are lower confidence but help find elements in ANY codebase
629
+
630
+ // FALLBACK 1: Flexible text search (just find the text anywhere)
631
+ if (focusedElement.textContent && focusedElement.textContent.trim().length > 5) {
632
+ const searchText = focusedElement.textContent.trim().substring(0, 30);
633
+ for (let i = 0; i < lines.length; i++) {
634
+ if (lines[i].includes(searchText)) {
635
+ debugLog("Fallback: Flexible text match found", { searchText, lineNumber: i + 1 });
636
+ return {
637
+ lineNumber: i + 1,
638
+ snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
639
+ confidence: 'low',
640
+ matchedBy: `flexible text match "${searchText}${focusedElement.textContent.length > 30 ? '...' : ''}"`
641
+ };
642
+ }
643
+ }
644
+ }
645
+
646
+ // FALLBACK 2: Class fingerprint (use first few classes as unique identifier)
647
+ if (focusedElement.className && focusedElement.className.trim().length > 15) {
648
+ // Take first 2-3 distinctive classes as a fingerprint
649
+ const classes = focusedElement.className.trim().split(/\s+/);
650
+ // Filter out very common single-word utilities
651
+ const distinctiveClasses = classes.filter(c =>
652
+ c.length > 4 && !['flex', 'grid', 'block', 'hidden', 'relative', 'absolute'].includes(c)
653
+ ).slice(0, 3);
654
+
655
+ if (distinctiveClasses.length >= 2) {
656
+ const fingerprint = distinctiveClasses.join(' ');
657
+ for (let i = 0; i < lines.length; i++) {
658
+ // Check if line contains ALL fingerprint classes
659
+ const lineHasAll = distinctiveClasses.every(cls => lines[i].includes(cls));
660
+ if (lineHasAll) {
661
+ debugLog("Fallback: Class fingerprint match found", { fingerprint, lineNumber: i + 1 });
662
+ return {
663
+ lineNumber: i + 1,
664
+ snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
665
+ confidence: 'low',
666
+ matchedBy: `class fingerprint "${fingerprint}"`
667
+ };
668
+ }
669
+ }
670
+ }
671
+ }
672
+
550
673
  return null;
551
674
  }
552
675
 
@@ -578,9 +701,14 @@ function scoreFilesForTextContent(
578
701
  filesCount: importedFiles.length
579
702
  });
580
703
 
581
- // Only search component files (where JSX text lives)
704
+ // Search component files and page files (where JSX text lives)
705
+ // Expanded to work with ANY codebase structure
582
706
  const componentFiles = importedFiles.filter(f =>
583
- f.path.includes('components/') || f.path.includes('/ui/')
707
+ f.path.includes('components/') ||
708
+ f.path.includes('/ui/') ||
709
+ f.path.includes('/_components/') ||
710
+ f.path.endsWith('.tsx') ||
711
+ f.path.endsWith('.jsx')
584
712
  );
585
713
 
586
714
  const results: { path: string; content: string; score: number; matchedTexts: string[]; firstMatchLine: number }[] = [];
@@ -651,39 +779,121 @@ function scoreFilesForTextContent(
651
779
  */
652
780
  function findElementInImportedFiles(
653
781
  focusedElement: VisionFocusedElement,
654
- importedFiles: { path: string; content: string }[]
782
+ importedFiles: { path: string; content: string }[],
783
+ pageFilePath?: string // Pass page file for proximity scoring
655
784
  ): { path: string; lineNumber: number; matchedBy: string; content: string; confidence: string } | null {
656
785
  debugLog("Searching imported components for element", {
657
786
  elementType: focusedElement.type,
658
787
  textContent: focusedElement.textContent?.substring(0, 30),
659
- filesCount: importedFiles.length
788
+ filesCount: importedFiles.length,
789
+ pageFilePath
660
790
  });
661
791
 
792
+ // Collect ALL matches with scores instead of returning first match
793
+ const matches: Array<{
794
+ path: string;
795
+ lineNumber: number;
796
+ matchedBy: string;
797
+ content: string;
798
+ confidence: string;
799
+ score: number;
800
+ }> = [];
801
+
802
+ // Extract route/directory info from page file for proximity scoring
803
+ const pageDir = pageFilePath ? path.dirname(pageFilePath) : '';
804
+ const routeName = pageDir.split('/').pop() || ''; // e.g., "typography"
805
+
662
806
  for (const file of importedFiles) {
663
- // Focus on component files (where UI elements live)
807
+ // Focus on component/page files (where UI elements live)
664
808
  // Skip types, stores, utils, hooks - they don't contain JSX elements
665
- if (!file.path.includes('components/') && !file.path.includes('/ui/')) continue;
809
+ // Expanded to work with ANY codebase structure
810
+ const isComponentFile =
811
+ file.path.includes('components/') ||
812
+ file.path.includes('/ui/') ||
813
+ file.path.includes('/_components/') ||
814
+ file.path.endsWith('.tsx') ||
815
+ file.path.endsWith('.jsx');
816
+
817
+ // Skip non-component files but allow page files
818
+ if (!isComponentFile) continue;
819
+
820
+ // Skip known non-UI files
821
+ if (file.path.includes('/types') ||
822
+ file.path.includes('/hooks/') ||
823
+ file.path.includes('/utils/') ||
824
+ file.path.includes('/lib/') ||
825
+ file.path.includes('.d.ts')) continue;
666
826
 
667
827
  const result = findElementLineInFile(file.content, focusedElement);
668
828
  if (result && result.confidence !== 'low') {
669
- debugLog("Found element in imported component", {
670
- path: file.path,
671
- lineNumber: result.lineNumber,
672
- matchedBy: result.matchedBy,
673
- confidence: result.confidence
674
- });
675
- return {
829
+ let score = 0;
830
+
831
+ // Text content match is highest priority (+100)
832
+ if (result.matchedBy.includes('textContent') || result.matchedBy.includes('word match')) {
833
+ score += 100;
834
+ }
835
+
836
+ // Directory proximity - same directory tree as page file (+50)
837
+ // e.g., for page src/app/typography/page.tsx, prefer src/app/typography/_components/
838
+ if (pageDir && file.path.includes(pageDir)) {
839
+ score += 50;
840
+ }
841
+
842
+ // Route name in filename (+30)
843
+ // e.g., for /typography route, prefer DigitalTypography.tsx
844
+ if (routeName && routeName.length > 2 && file.path.toLowerCase().includes(routeName.toLowerCase())) {
845
+ score += 30;
846
+ }
847
+
848
+ // Confidence bonus
849
+ score += result.confidence === 'high' ? 20 : 10;
850
+
851
+ matches.push({
676
852
  path: file.path,
677
853
  lineNumber: result.lineNumber,
678
854
  matchedBy: result.matchedBy,
679
855
  content: file.content,
680
- confidence: result.confidence
681
- };
856
+ confidence: result.confidence,
857
+ score
858
+ });
682
859
  }
683
860
  }
684
861
 
685
- debugLog("Element not found in any imported component files");
686
- return null;
862
+ // Return highest-scoring match
863
+ if (matches.length === 0) {
864
+ debugLog("Element not found in any imported component files");
865
+ return null;
866
+ }
867
+
868
+ // Sort by score descending
869
+ matches.sort((a, b) => b.score - a.score);
870
+
871
+ debugLog("Scored imported component matches", {
872
+ matchCount: matches.length,
873
+ topMatches: matches.slice(0, 3).map(m => ({
874
+ path: m.path,
875
+ score: m.score,
876
+ matchedBy: m.matchedBy,
877
+ confidence: m.confidence
878
+ }))
879
+ });
880
+
881
+ const bestMatch = matches[0];
882
+ debugLog("Found element in imported component (best match)", {
883
+ path: bestMatch.path,
884
+ lineNumber: bestMatch.lineNumber,
885
+ matchedBy: bestMatch.matchedBy,
886
+ confidence: bestMatch.confidence,
887
+ score: bestMatch.score
888
+ });
889
+
890
+ return {
891
+ path: bestMatch.path,
892
+ lineNumber: bestMatch.lineNumber,
893
+ matchedBy: bestMatch.matchedBy,
894
+ content: bestMatch.content,
895
+ confidence: bestMatch.confidence
896
+ };
687
897
  }
688
898
 
689
899
  /**
@@ -1817,6 +2027,7 @@ export async function POST(request: Request) {
1817
2027
  let smartSearchFiles: { path: string; content: string }[] = [];
1818
2028
  let recommendedFile: { path: string; reason: string } | null = null;
1819
2029
  let deterministicMatch: { path: string; lineNumber: number } | null = null;
2030
+ let phase1VisibleText: string[] = []; // Store Phase 1 visible text for use in file scoring
1820
2031
 
1821
2032
  // ========================================================================
1822
2033
  // PHASE 0: Deterministic Element ID Search (Cursor-style explicit selection)
@@ -1882,6 +2093,9 @@ export async function POST(request: Request) {
1882
2093
  if (!deterministicMatch && screenshot) {
1883
2094
  debugLog("Starting Phase 1: LLM screenshot analysis");
1884
2095
  const analysis = await analyzeScreenshotForSearch(screenshot, userPrompt, apiKey);
2096
+
2097
+ // Store visible text for use in file scoring when no element is focused
2098
+ phase1VisibleText = analysis.visibleText || [];
1885
2099
 
1886
2100
  const hasSearchTerms = analysis.visibleText.length > 0 ||
1887
2101
  analysis.componentNames.length > 0 ||
@@ -2192,7 +2406,8 @@ export async function POST(request: Request) {
2192
2406
 
2193
2407
  const importedMatch = findElementInImportedFiles(
2194
2408
  focusedElements[0],
2195
- pageContext.componentSources
2409
+ pageContext.componentSources,
2410
+ actualTargetFile?.path // Pass page file for proximity scoring
2196
2411
  );
2197
2412
 
2198
2413
  if (importedMatch) {
@@ -2211,6 +2426,48 @@ export async function POST(request: Request) {
2211
2426
  }
2212
2427
  }
2213
2428
  }
2429
+ // ========== AUTO TEXT SCORING (no focused element) ==========
2430
+ // When no element is clicked but we have Phase 1 visible text, use it to find the best file
2431
+ else if (actualTargetFile && (!focusedElements || focusedElements.length === 0) && phase1VisibleText.length > 0) {
2432
+ debugLog("No focused element - using Phase 1 visible text for file scoring", {
2433
+ textCount: phase1VisibleText.length,
2434
+ texts: phase1VisibleText.slice(0, 5)
2435
+ });
2436
+
2437
+ // Score all imported files based on visible text from Phase 1
2438
+ const bestMatch = scoreFilesForTextContent(
2439
+ phase1VisibleText,
2440
+ pageContext.componentSources
2441
+ );
2442
+
2443
+ // Check how well the current target file matches
2444
+ const currentFileScore = phase1VisibleText.filter(text =>
2445
+ actualTargetFile!.content.includes(text.substring(0, 20))
2446
+ ).length;
2447
+
2448
+ debugLog("Phase 1 text scoring comparison", {
2449
+ currentFile: actualTargetFile.path,
2450
+ currentScore: currentFileScore,
2451
+ bestImport: bestMatch?.path || 'none',
2452
+ bestImportScore: bestMatch?.score || 0
2453
+ });
2454
+
2455
+ // Redirect if an imported file has significantly more text matches
2456
+ if (bestMatch && bestMatch.score > currentFileScore && bestMatch.score >= 2) {
2457
+ debugLog("TEXT REDIRECT (no element): Phase 1 visible text found better file", {
2458
+ originalFile: actualTargetFile.path,
2459
+ originalScore: currentFileScore,
2460
+ redirectTo: bestMatch.path,
2461
+ redirectScore: bestMatch.score,
2462
+ matchedTexts: bestMatch.matchedTexts
2463
+ });
2464
+
2465
+ actualTargetFile = {
2466
+ path: bestMatch.path,
2467
+ content: bestMatch.content
2468
+ };
2469
+ }
2470
+ }
2214
2471
 
2215
2472
  debugLog("File redirect complete", {
2216
2473
  originalRecommended: recommendedFileContent?.path || 'none',
@@ -2390,7 +2647,7 @@ ${linesWithNumbers}
2390
2647
  path: actualTargetFile.path,
2391
2648
  lines: targetContent.split('\n').length,
2392
2649
  size: targetContent.length,
2393
- wasRedirected: actualTargetFile.path !== recommendedFileContent.path
2650
+ wasRedirected: actualTargetFile.path !== recommendedFileContent?.path
2394
2651
  });
2395
2652
  } else if (pageContext.pageContent) {
2396
2653
  // Fallback: use page file if no recommended file
@@ -2601,11 +2858,24 @@ This is better than generating patches with made-up code.`,
2601
2858
  try {
2602
2859
  // Use robust JSON extraction that handles preamble text, code fences, etc.
2603
2860
  const jsonText = extractJsonFromResponse(textResponse.text);
2861
+ debugLog("[JSON Parse] Attempting to parse extracted JSON", {
2862
+ extractedLength: jsonText.length,
2863
+ extractedFirst100: jsonText.substring(0, 100),
2864
+ extractedLast100: jsonText.substring(jsonText.length - 100)
2865
+ });
2604
2866
  aiResponse = JSON.parse(jsonText);
2605
- } catch {
2867
+ debugLog("[JSON Parse] Successfully parsed AI response");
2868
+ } catch (parseError) {
2869
+ const error = parseError as Error;
2606
2870
  console.error("Failed to parse AI response:", textResponse.text);
2871
+ debugLog("[JSON Parse] FAILED to parse", {
2872
+ error: error.message,
2873
+ originalLength: textResponse.text.length,
2874
+ originalFirst100: textResponse.text.substring(0, 100),
2875
+ originalLast100: textResponse.text.substring(textResponse.text.length - 100)
2876
+ });
2607
2877
  return NextResponse.json(
2608
- { error: "Failed to parse AI response. Please try again." },
2878
+ { error: `Failed to parse AI response: ${error.message}` },
2609
2879
  { status: 500 }
2610
2880
  );
2611
2881
  }