@corva/ui 3.78.0-15 → 3.78.0-16

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.
@@ -246,9 +246,9 @@ const validateDsn = dsn => {
246
246
  return url.href;
247
247
  };
248
248
 
249
- const MCP_SERVER_VERSION = '1.7.0';
249
+ const MCP_SERVER_VERSION = '1.8.0';
250
250
 
251
- var version = "3.78.0-15";
251
+ var version = "3.78.0-16";
252
252
 
253
253
  const CORVA_UI_VERSION = version;
254
254
 
@@ -1055,6 +1055,8 @@ const SEARCH_CONFIG = {
1055
1055
  exactMatch: 100,
1056
1056
  startsWith: 50,
1057
1057
  contains: 30,
1058
+ // Above keywordExact: a query word that names the entry beats one its keywords merely carry.
1059
+ nameWord: 25,
1058
1060
  keywordExact: 20,
1059
1061
  keywordContains: 10,
1060
1062
  description: 5,
@@ -1064,7 +1066,9 @@ const SEARCH_CONFIG = {
1064
1066
  v2Component: 10
1065
1067
  },
1066
1068
  defaults: {
1067
- maxResults: 10
1069
+ maxResults: 10,
1070
+ // Outside `limit`: the fallback answers "does the other version have it", not "show more".
1071
+ maxFallbackResults: 5
1068
1072
  }
1069
1073
  };
1070
1074
 
@@ -21548,6 +21552,35 @@ const withTimeout = async (promise, ms) => {
21548
21552
  */
21549
21553
  const singleLine = text => text.replace(/\s+/g, ' ').trim();
21550
21554
 
21555
+ const parseQuery = queryLower => {
21556
+ const rawWords = queryLower.trim().split(/\s+/);
21557
+ const queryWords = rawWords.filter(w => w.length > 1);
21558
+ const isMultiWord = rawWords.length >= 2 && queryWords.length >= 2;
21559
+ // When the query has filler words around a single meaningful word (e.g. "a button"),
21560
+ // the single-word path should match against just that word instead of the whole query.
21561
+ const singleWordQuery = !isMultiWord && queryWords.length === 1 ? queryWords[0] : queryLower;
21562
+ return {
21563
+ queryWords,
21564
+ isMultiWord,
21565
+ singleWordQuery
21566
+ };
21567
+ };
21568
+
21569
+ /**
21570
+ * Whether the name equals the query or starts with it — the query read the way the name scorers
21571
+ * read it: filler words dropped, a multi-word query collapsed ("date picker" → "datepicker").
21572
+ * Only such a match proves the version holds what was asked for; a keyword or prose hit does not.
21573
+ */
21574
+ const hasNameMatch = (name, query) => {
21575
+ const {
21576
+ queryWords,
21577
+ isMultiWord,
21578
+ singleWordQuery
21579
+ } = parseQuery(query.toLowerCase());
21580
+ const needle = (isMultiWord ? queryWords.join('') : singleWordQuery).trim();
21581
+ return needle.length > 0 && name.toLowerCase().startsWith(needle);
21582
+ };
21583
+
21551
21584
  /**
21552
21585
  * "icon" says which kind of result is wanted, not what it shows: against an icon it would match
21553
21586
  * every name, so "plus add icon" ranked arrows above PlusIcon. Other kinds keep the word —
@@ -21568,6 +21601,13 @@ const withoutIconStopWords = queryLower => queryLower.trim().split(/\s+/).filter
21568
21601
  */
21569
21602
  const isBuriedIcon = (entry, queryLower) => entry.type === 'icon' && entry.isDeprecated === true && entry.name.toLowerCase() !== withoutIconStopWords(queryLower);
21570
21603
 
21604
+ /**
21605
+ * Whole-word match for prose: a substring would let "table" hit "editable", and such weak matches
21606
+ * would keep the V1/V2 fallback from ever showing the real `Table`. `_` is a boundary because
21607
+ * keywords carry constant names like `asset_types`. Both arguments are lower case.
21608
+ */
21609
+ const includesWord = (textLower, word) => new RegExp(`(?<![a-z0-9])${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![a-z0-9])`).test(textLower);
21610
+
21571
21611
  /**
21572
21612
  * The best a keyword list can do for ONE query word. The single-word branch of `handleSearch` used
21573
21613
  * to SUM over every keyword, so the score measured how long a keyword list is — `corvaAPI`'s 369
@@ -21581,12 +21621,12 @@ const scoreKeywordMatch = (keywords, word, weights) => keywords.reduce((best, ke
21581
21621
  }, 0);
21582
21622
 
21583
21623
  /**
21584
- * Split camelCase or PascalCase string into words
21585
- * e.g., "CustomGradient" -> ["custom", "gradient"]
21624
+ * The lower-case words of an entry name, for matching query words against. Acronym- and
21625
+ * `_`-aware — `NPT` → `npt`, `ASSET_TYPES` → `asset`, `types` — where the extractors'
21626
+ * `splitCamelCase` cuts at every capital and would leave such names no word to match. One-letter
21627
+ * pieces are dropped: a query word is never that short.
21586
21628
  */
21587
- const splitCamelCase = name => {
21588
- return name.replace(/([A-Z])/g, ' $1').toLowerCase().trim().split(/\s+/).filter(w => w.length > 0);
21589
- };
21629
+ const nameWords = name => (name.match(/[A-Z]+(?![a-z])|[A-Z]?[a-z]+|\d+/g) ?? []).map(word => word.toLowerCase()).filter(word => word.length > 1);
21590
21630
 
21591
21631
  const scoreMultiWordQuery = (entry, queryWords, weights) => {
21592
21632
  let score = 0;
@@ -21605,7 +21645,7 @@ const scoreMultiWordQuery = (entry, queryWords, weights) => {
21605
21645
 
21606
21646
  // 2. All-words-in-name-parts matching
21607
21647
  let namePartsScore = 0;
21608
- const nameParts = splitCamelCase(entry.name).filter(part => part.length > 1);
21648
+ const nameParts = nameWords(entry.name);
21609
21649
  const allWordsInName = queryWords.every(word => nameParts.some(part => part === word));
21610
21650
  if (allWordsInName) {
21611
21651
  namePartsScore = weights.contains;
@@ -21614,19 +21654,19 @@ const scoreMultiWordQuery = (entry, queryWords, weights) => {
21614
21654
  // Take max of collapsed and name-parts
21615
21655
  score += Math.max(collapsedNameScore, namePartsScore);
21616
21656
 
21617
- // 3. Per-word keyword scoring
21657
+ // 3. Per-word scoring: the best keyword per word, never a sum — unless the word is a word of the
21658
+ // name, which outweighs any keyword. Otherwise "map layers toggle" scores LayersIcon like every
21659
+ // icon that only has "toggle" as a keyword, and file order decides.
21618
21660
  const descriptionLower = entry.description.toLowerCase();
21619
-
21620
- // Behaviour-identical to the inline loop this replaced: the best keyword per word, never a sum.
21621
- score += queryWords.reduce((keywordScore, word) => keywordScore + scoreKeywordMatch(entry.keywords, word, weights), 0);
21661
+ score += queryWords.reduce((wordScore, word) => wordScore + Math.max(nameParts.includes(word) ? weights.nameWord : 0, scoreKeywordMatch(entry.keywords, word, weights)), 0);
21622
21662
 
21623
21663
  // 4. All-words-in-description
21624
- if (queryWords.every(word => descriptionLower.includes(word))) {
21664
+ if (queryWords.every(word => includesWord(descriptionLower, word))) {
21625
21665
  score += weights.description;
21626
21666
  }
21627
21667
 
21628
21668
  // 5. All-words-in-searchText
21629
- if (queryWords.every(word => entry.searchText.includes(word))) {
21669
+ if (queryWords.every(word => includesWord(entry.searchText, word))) {
21630
21670
  score += weights.fullText;
21631
21671
  }
21632
21672
  return score;
@@ -21641,19 +21681,6 @@ const deprecationOrder = entry => entry.isDeprecated ? 1 : 0;
21641
21681
  * fell to generation order before, an accident of extractor discovery.
21642
21682
  */
21643
21683
  const compareScoredEntries = queryLower => (a, b) => Number(isBuriedIcon(a.entry, queryLower)) - Number(isBuriedIcon(b.entry, queryLower)) || b.score - a.score || deprecationOrder(a.entry) - deprecationOrder(b.entry);
21644
- const parseQuery = queryLower => {
21645
- const rawWords = queryLower.trim().split(/\s+/);
21646
- const queryWords = rawWords.filter(w => w.length > 1);
21647
- const isMultiWord = rawWords.length >= 2 && queryWords.length >= 2;
21648
- // When the query has filler words around a single meaningful word (e.g. "a button"),
21649
- // the single-word path should match against just that word instead of the whole query.
21650
- const singleWordQuery = !isMultiWord && queryWords.length === 1 ? queryWords[0] : queryLower;
21651
- return {
21652
- queryWords,
21653
- isMultiWord,
21654
- singleWordQuery
21655
- };
21656
- };
21657
21684
  const rankEntries = (entries, query) => {
21658
21685
  const {
21659
21686
  weights,
@@ -21690,10 +21717,10 @@ const rankEntries = (entries, query) => {
21690
21717
 
21691
21718
  // One word, one keyword payment — the same rule the multi-word branch applies per word.
21692
21719
  score += scoreKeywordMatch(entry.keywords, singleWordQuery, weights);
21693
- if (entry.description.toLowerCase().includes(singleWordQuery)) {
21720
+ if (includesWord(entry.description.toLowerCase(), singleWordQuery)) {
21694
21721
  score += weights.description;
21695
21722
  }
21696
- if (entry.searchText.includes(singleWordQuery)) {
21723
+ if (includesWord(entry.searchText, singleWordQuery)) {
21697
21724
  score += weights.fullText;
21698
21725
  }
21699
21726
  }
@@ -21748,12 +21775,12 @@ Examples:
21748
21775
  - "date picker" - find date/time components
21749
21776
  - "useSubscriptions" - find specific hook
21750
21777
 
21751
- When category is 'v2' or 'v1' and the requested version has no match, the response surfaces a labeled fallback block from the other version so a single search still discovers a candidate. The agent should still prefer V2 when both versions match.`;
21778
+ When category is 'v2' or 'v1', type is 'all' or 'component', and no component of the requested version is named by the query (its name equals or starts with it), up to 5 matching components of the other version follow the results, labeled, so a single search still discovers a candidate. The agent should still prefer V2 when both versions match.`;
21752
21779
  const searchToolSchema = {
21753
21780
  query: z.string().describe('Search query - component name, use case, or description'),
21754
21781
  type: z.enum(SEARCH_TYPES).optional().describe('Filter by type (default: all)'),
21755
21782
  category: z.enum(['all', 'v2', 'v1']).optional().describe('For components: v2 (modern) or v1 (default: all)'),
21756
- limit: z.number().optional().describe('Maximum results to return (default: 10)')
21783
+ limit: z.number().optional().describe('Maximum results to return (default: 10); a V1/V2 fallback adds up to 5 more')
21757
21784
  };
21758
21785
  const handleSearch = args => {
21759
21786
  const {
@@ -21784,7 +21811,10 @@ const handleSearch = args => {
21784
21811
  }
21785
21812
  const matchesRequested = entry => requestedCategory === null || entry.type !== COMPONENT_TYPE || entry.category === requestedCategory;
21786
21813
  const primary = ranked.filter(matchesRequested).slice(0, limit);
21787
- const fallback = requestedCategory && primary.length === 0 ? ranked.filter(entry => entry.type === COMPONENT_TYPE && entry.category !== requestedCategory).slice(0, limit) : [];
21814
+ // Weak matches — a keyword, a word of prose, an icon — do not prove the requested version has the
21815
+ // component, so they must not hide the other version's. Read before `limit` cuts the list.
21816
+ const requestedHasName = ranked.some(entry => entry.type === COMPONENT_TYPE && entry.category === requestedCategory && hasNameMatch(entry.name, query));
21817
+ const fallback = requestedCategory && (type === 'all' || type === COMPONENT_TYPE) && !requestedHasName ? ranked.filter(entry => entry.type === COMPONENT_TYPE && entry.category !== requestedCategory).slice(0, SEARCH_CONFIG.defaults.maxFallbackResults) : [];
21788
21818
  if (primary.length === 0 && fallback.length === 0) {
21789
21819
  return {
21790
21820
  response: createToolResponse(`No results found for ${echoedQuery}. Try:\n- A different search term\n- Using list_corva_ui to see available items`),
@@ -21801,20 +21831,24 @@ const handleSearch = args => {
21801
21831
  const fields = [notice, singleLine(r.description) || NO_DESCRIPTION, ...importLines(r)];
21802
21832
  return [`**${r.name}**${categoryLabel} - ${r.type}`, ...fields.filter(Boolean).map(field => `${INDENT}${field}`)].join('\n');
21803
21833
  };
21804
- if (primary.length > 0) {
21805
- const formatted = primary.map(formatEntry).join('\n\n');
21834
+ const formatted = [...primary, ...fallback].map(formatEntry).join('\n\n');
21835
+ const resultCount = primary.length + fallback.length;
21836
+ if (fallback.length === 0) {
21806
21837
  return {
21807
21838
  response: createToolResponse(`Found ${primary.length} result(s) for ${echoedQuery}:\n\n${formatted}`),
21808
- resultCount: primary.length
21839
+ resultCount
21809
21840
  };
21810
21841
  }
21811
21842
  const requestedVersion = requestedCategory.replace(COMPONENTS_CATEGORY_PREFIX, '').toUpperCase();
21812
21843
  const fallbackVersion = requestedVersion === 'V2' ? 'V1' : 'V2';
21813
21844
  const preferenceHint = requestedVersion === 'V2' ? ' V2 is preferred when available; consider these only if no V2 alternative exists.' : '';
21814
- const formatted = fallback.map(formatEntry).join('\n\n');
21845
+ // The label lives in the preamble, not in a block of its own: the blank line separates results
21846
+ // and nothing else, so a consumer can still count and split them. Each fallback block carries
21847
+ // its version in its heading.
21848
+ const preamble = primary.length === 0 ? `No ${requestedVersion} matches for ${echoedQuery}. ${fallbackVersion} fallback — ${fallbackVersion} components that match the query.` : `Found ${primary.length} result(s) for ${echoedQuery}, but no ${requestedVersion} component name equals or starts with it. The last ${fallback.length} result(s) are the ${fallbackVersion} fallback — ${fallbackVersion} components that match the query.`;
21815
21849
  return {
21816
- response: createToolResponse(`No ${requestedVersion} matches for ${echoedQuery}. ${fallbackVersion} fallback — ${fallbackVersion} components that match the query.${preferenceHint}\n\n${formatted}`),
21817
- resultCount: fallback.length
21850
+ response: createToolResponse(`${preamble}${preferenceHint}\n\n${formatted}`),
21851
+ resultCount
21818
21852
  };
21819
21853
  };
21820
21854
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@corva/ui",
3
- "version": "3.78.0-15",
3
+ "version": "3.78.0-16",
4
4
  "license": "UNLICENSED",
5
5
  "description": "Shared components/utils for Corva ui projects",
6
6
  "repository": {