kloudy 1.0.1 → 1.0.3

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.
package/src/recommend.mjs CHANGED
@@ -1,15 +1,23 @@
1
- // Offline stack recommender. No network and no model call.
2
- // `kloudy recommend <goal> [--json]` and `kloudy stack <goal> [--json]`.
1
+ // Stack recommender. Kloudy never runs an LLM.
2
+ // `kloudy recommend <goal> [--json] [--limit N] [--offline]`
3
+ // `kloudy stack` is the same command.
4
+ // Ranking reads the bundled catalog. recommendCommand may apply cached score overrides.
5
+ import {createHash} from 'node:crypto';
3
6
  import {readFileSync} from 'node:fs';
4
7
  import {flags,loadLocal,localEndpoints} from './bots.mjs';
5
8
  import {primarySortHint} from './modalities.mjs';
9
+ import {loadScoreOverrides,normalizeScores} from './scores.mjs';
6
10
 
7
11
  export const RECOMMEND_SCHEMA='kloudy.recommend/1';
8
12
  export const CATALOG_NAME='kloudy 1.0';
9
- const PER_MODALITY=5;
13
+ export const DEFAULT_LIMIT=5;
14
+ export const MIN_LIMIT=1;
15
+ export const MAX_LIMIT=20;
16
+ const PER_MODALITY=DEFAULT_LIMIT;
10
17
  // Class order for recommendations is A, then B, then O.
11
- // Primary-modality hint, keyword hits, catalog score, and index tie-break
12
- // follow the comparator in localSearch.
18
+ // Score overrides apply after class: boost, then unchanged, then demote.
19
+ // Exclude drops a bot before the cap. Primary-modality hint, keyword hits,
20
+ // catalog score, and index tie-break follow the comparator in localSearch.
13
21
  const CLASS_TIER={A:0,B:1,O:2};
14
22
  const words=s=>String(s||'').toLowerCase().split(/[^\p{L}\p{N}_]+/u).filter(w=>w.length>1);
15
23
 
@@ -70,8 +78,6 @@ const RULE_LIST=[
70
78
  ['audio',['audio']],
71
79
  ['whisper',['audio']],
72
80
  ['microphone',['audio']],
73
- ['phone',['audio']],
74
- ['calls',['audio']],
75
81
  ['listen',['audio']],
76
82
  ['asr',['audio']],
77
83
  ['tts',['voice_generation']],
@@ -125,13 +131,13 @@ const RULE_LIST=[
125
131
  ['graphql',['api_calls']],
126
132
  ['apis',['api_calls']],
127
133
  ['api',['api_calls']],
128
- ['scheduling',['scheduling']],
129
- ['schedules',['scheduling']],
130
- ['schedule',['scheduling']],
134
+ ['scheduling',['scheduling'],{family:'timer'}],
135
+ ['schedules',['scheduling'],{family:'timer'}],
136
+ ['schedule',['scheduling'],{family:'timer'}],
131
137
  ['calendar',['scheduling']],
132
138
  ['reminders',['scheduling']],
133
139
  ['reminder',['scheduling']],
134
- ['cron',['scheduling']],
140
+ ['cron',['scheduling'],{family:'timer'}],
135
141
  ['notifications',['notifications']],
136
142
  ['notification',['notifications']],
137
143
  ['notify',['notifications']],
@@ -268,11 +274,72 @@ const RULE_LIST=[
268
274
  ['code',['code']],
269
275
  ['sandbox',['code_execution']],
270
276
  ['interpreter',['code_execution']],
271
- ['repl',['code_execution']]
277
+ ['repl',['code_execution']],
278
+ // Phone and calls are audio only when an audio cue is in the same clause.
279
+ // Otherwise phone / Android / mobile are device sync, not voice tools.
280
+ ['phone call',['audio']],
281
+ ['phone calls',['audio']],
282
+ ['call audio',['audio']],
283
+ ['phone',['audio'],{when:'audio'}],
284
+ ['calls',['audio'],{when:'audio'}],
285
+ ['call',['audio'],{when:'audio'}],
286
+ ['phones',['notifications','comms'],{family:'sync',when:'no-audio'}],
287
+ ['phone',['notifications','comms'],{family:'sync',when:'no-audio'}],
288
+ ['android',['notifications','comms'],{family:'sync'}],
289
+ ['mobile',['notifications','comms'],{family:'sync'}],
290
+ ['syncing',['notifications','comms'],{family:'sync'}],
291
+ ['synced',['notifications','comms'],{family:'sync'}],
292
+ ['syncs',['notifications','comms'],{family:'sync'}],
293
+ ['sync',['notifications','comms'],{family:'sync'}],
294
+ ['push notification',['notifications','comms'],{family:'sync'}],
295
+ ['push notifications',['notifications','comms'],{family:'sync'}],
296
+ ['galaxy watch',['data'],{family:'wearable'}],
297
+ ['wear os',['data'],{family:'wearable'}],
298
+ ['health connect',['data'],{family:'wearable'}],
299
+ ['apple health',['data'],{family:'wearable'}],
300
+ ['smartwatch',['data'],{family:'wearable'}],
301
+ ['smart watch',['data'],{family:'wearable'}],
302
+ ['pedometer',['data'],{family:'wearable'}],
303
+ ['accelerometer',['data'],{family:'wearable'}],
304
+ ['wearable',['data'],{family:'wearable'}],
305
+ ['fitness',['data'],{family:'wearable'}],
306
+ ['steps',['data'],{family:'wearable',when:'not-device'}],
307
+ ['step',['data'],{family:'wearable',when:'not-device'}],
308
+ ['watch',['data'],{family:'wearable'}],
309
+ ['timers',['scheduling'],{family:'timer'}],
310
+ ['timer',['scheduling'],{family:'timer'}],
311
+ ['triggers',['scheduling'],{family:'timer',when:'timer'}],
312
+ ['trigger',['scheduling'],{family:'timer',when:'timer'}],
313
+ ['triggered',['scheduling'],{family:'timer',when:'timer'}],
314
+ ['movement',['scheduling'],{family:'timer',when:'movement-timer'}],
315
+ ['movement',['data'],{family:'wearable',when:'movement-sensor'}],
316
+ ['calories',['text'],{family:'nutrition'}],
317
+ ['calorie',['text'],{family:'nutrition'}],
318
+ ['nutrition',['text'],{family:'nutrition'}],
319
+ ['recipes',['text'],{family:'nutrition'}],
320
+ ['recipe',['text'],{family:'nutrition'}],
321
+ ['meals',['text'],{family:'nutrition'}],
322
+ ['meal',['text'],{family:'nutrition'}],
323
+ ['diets',['text'],{family:'nutrition'}],
324
+ ['diet',['text'],{family:'nutrition'}],
325
+ ['foods',['text'],{family:'nutrition'}],
326
+ ['food',['text'],{family:'nutrition'}],
327
+ ['facebook',['comms'],{family:'social'}],
328
+ ['instagram',['comms'],{family:'social'}],
329
+ ['twitter',['comms'],{family:'social'}],
330
+ ['shares',['comms'],{family:'social',when:'social'}],
331
+ ['shared',['comms'],{family:'social',when:'social'}],
332
+ ['sharing',['comms'],{family:'social',when:'social'}],
333
+ ['share',['comms'],{family:'social',when:'social'}],
334
+ ['posting',['comms'],{family:'social',when:'social'}],
335
+ ['posts',['comms'],{family:'social',when:'social'}],
336
+ ['posted',['comms'],{family:'social',when:'social'}],
337
+ ['post',['comms'],{family:'social',when:'social'}],
338
+ ['x',['comms'],{family:'social',when:'social-x'}]
272
339
  ];
273
340
 
274
- export const KEYWORD_RULES=RULE_LIST.map(([phrase,modalities])=>({phrase,modalities:[...modalities]}))
275
- .sort((a,b)=>b.phrase.length-a.phrase.length||a.phrase.localeCompare(b.phrase));
341
+ export const KEYWORD_RULES=RULE_LIST.map(([phrase,modalities,meta])=>({phrase,modalities:[...modalities],...(meta||{})}))
342
+ .sort((a,b)=>b.phrase.length-a.phrase.length||a.phrase.localeCompare(b.phrase)||(a.when||'').localeCompare(b.when||''));
276
343
 
277
344
  const CLAUSE_SPLIT=/\s*(?:,|;|&)\s*|\s+\b(?:and|then)\b\s+/i;
278
345
  const EDGE_STOP=new Set(['a','an','the','that','which','who','to','for','of','in','on','with','and','then','into','from','by','or','also']);
@@ -283,16 +350,38 @@ function bounded(text,start,end){
283
350
  return before&&after;
284
351
  }
285
352
 
353
+ const AUDIO_CUE=/\b(?:transcri(?:be|bes|bed|bing|ption|ptions)?|voices?|speech|audio|whisper|microphones?|asr|listen(?:s|ing|ed)?|phone calls?|call audio)\b/i;
354
+ const DEVICE_CUE=/\b(?:android|mobile|phones?|sync(?:ing|ed|s)?)\b/i;
355
+ const TIMER_CUE=/\b(?:timers?|schedul\w*|cron|triggers?|triggered)\b/i;
356
+ const DB_CUE=/\b(?:databases?|sql|postgres(?:ql)?|mysql|sqlite|mongodb)\b/i;
357
+ const SOCIAL_NET=/\b(?:facebook|instagram|twitter)\b/i;
358
+ const SOCIAL_X=/(?:\bx\.com\b|\b(?:post|share|tweet)(?:s|ed|ing)?\b[^.!?\n]{0,40}\bx\b|\bx\b[^.!?\n]{0,40}\b(?:post|share|tweet)(?:s|ed|ing)?\b)/i;
359
+
360
+ function ruleApplies(rule,text){
361
+ switch(rule.when){
362
+ case 'audio':return AUDIO_CUE.test(text);
363
+ case 'no-audio':return !AUDIO_CUE.test(text);
364
+ case 'social':return SOCIAL_NET.test(text)||SOCIAL_X.test(text);
365
+ case 'social-x':return SOCIAL_X.test(text);
366
+ case 'timer':return TIMER_CUE.test(text)&&!DB_CUE.test(text);
367
+ case 'movement-timer':return TIMER_CUE.test(text);
368
+ case 'movement-sensor':return !TIMER_CUE.test(text);
369
+ case 'not-device':return !DEVICE_CUE.test(text);
370
+ default:return true;
371
+ }
372
+ }
373
+
286
374
  export function findKeywordHits(text){
287
375
  const lower=String(text||'').toLowerCase();
288
376
  const found=[];
289
377
  for(const rule of KEYWORD_RULES){
378
+ if(!ruleApplies(rule,lower))continue;
290
379
  let from=0;
291
380
  while(from<=lower.length-rule.phrase.length){
292
381
  const i=lower.indexOf(rule.phrase,from);
293
382
  if(i<0)break;
294
383
  const end=i+rule.phrase.length;
295
- if(bounded(lower,i,end)&&!found.some(h=>i<h.end&&end>h.start))found.push({start:i,end,phrase:rule.phrase,modalities:rule.modalities});
384
+ if(bounded(lower,i,end)&&!found.some(h=>i<h.end&&end>h.start))found.push({start:i,end,phrase:rule.phrase,modalities:rule.modalities,family:rule.family||null});
296
385
  from=i+1;
297
386
  }
298
387
  }
@@ -319,7 +408,7 @@ function partsFromClause(clause){
319
408
  const hits=findKeywordHits(clause);
320
409
  if(!hits.length){
321
410
  const text=clauseLabel(clause);
322
- return text?[{text,modalities:[],keywords:[]}]:[];
411
+ return text?[{text,modalities:[],keywords:[],family:null}]:[];
323
412
  }
324
413
  const groups=[];
325
414
  for(const hit of hits){
@@ -328,17 +417,18 @@ function partsFromClause(clause){
328
417
  last.hits.push(hit);
329
418
  for(const m of hit.modalities)if(!last.modalities.includes(m))last.modalities.push(m);
330
419
  if(!last.keywords.includes(hit.phrase))last.keywords.push(hit.phrase);
331
- }else groups.push({modalities:[...hit.modalities],keywords:[hit.phrase],hits:[hit]});
420
+ if(hit.family&&!last.family)last.family=hit.family;
421
+ }else groups.push({modalities:[...hit.modalities],keywords:[hit.phrase],hits:[hit],family:hit.family||null});
332
422
  }
333
423
  if(groups.length===1){
334
424
  const text=clauseLabel(clause)||groups[0].keywords.join(' ');
335
- return [{text,modalities:groups[0].modalities,keywords:groups[0].keywords}];
425
+ return [{text,modalities:groups[0].modalities,keywords:groups[0].keywords,family:groups[0].family}];
336
426
  }
337
427
  return groups.map((group,i)=>{
338
428
  const start=i===0?0:splitPoint(clause,groups[i-1].hits.at(-1).end,group.hits[0].start);
339
429
  const end=i===groups.length-1?clause.length:splitPoint(clause,group.hits.at(-1).end,groups[i+1].hits[0].start);
340
430
  const text=clauseLabel(clause.slice(start,end))||group.keywords.join(' ');
341
- return {text,modalities:group.modalities,keywords:group.keywords};
431
+ return {text,modalities:group.modalities,keywords:group.keywords,family:group.family};
342
432
  });
343
433
  }
344
434
 
@@ -375,76 +465,259 @@ function oneLine(bot){
375
465
  return line.replace(/\s+/g,' ').slice(0,180);
376
466
  }
377
467
 
378
- function whyLine(modality,keywords){
468
+ function whyLine(modality,keywords,tail='top-ranked in modality'){
379
469
  const hint=(keywords||[]).filter(k=>KEYWORD_RULES.some(r=>r.phrase===k&&r.modalities.includes(modality))).slice(0,4).join('/');
380
- return hint?`matched ${modality}/${hint}; top-ranked in modality`:`matched ${modality}; top-ranked in modality`;
470
+ return hint?`matched ${modality}/${hint}; ${tail}`:`matched ${modality}; ${tail}`;
471
+ }
472
+
473
+ const KIND_CATALOG='catalog integration';
474
+ const KIND_SUGGEST='suggestion: build yourself / platform API';
475
+ const OPTIONAL_COVERAGE=new Set(['share','shares','shared','sharing','post','posts','posting','posted','send','sends','sending','sent','detect','detects','detecting','start','starts','starting','create','creates','use','uses','using','based','optionally','optional','results','data','experience','that','them']);
476
+ const SUGGEST={
477
+ wearable:'Wear OS sensor APIs and Health Connect are not in the kloudy 1.0 catalog. Build the sensing yourself or call the platform API. Unverified non-catalog guidance, not a catalog bot.',
478
+ sync:'Health Connect is not in the kloudy 1.0 catalog. It is a platform API for sending step data to an Android phone. Unverified non-catalog guidance, not a catalog bot.',
479
+ social:'The Facebook Graph API, Instagram API, and X API are not catalog bots. Unverified non-catalog guidance. Build the post yourself or call the platform API.'
480
+ };
481
+ const WEAK_REASON={
482
+ wearable:'Nearest catalog bots match a related fitness wearable (Garmin, Fitbit, or Apple Health), not Galaxy Watch step sensing.',
483
+ timer:'Catalog timers or schedulers share a timer or schedule keyword, but none start a timer from movement.',
484
+ sync:'Catalog Android tools share the Android keyword, but they do not send step data to a phone.',
485
+ nutrition:'Catalog nutrition tools only partly overlap this meal or calorie need.',
486
+ social:'Catalog social tools only partly overlap this post or share need.'
487
+ };
488
+ const CAP_NOISE=new Set(['connect','mcp','llm','language','agents','agent','scheduling','notifications','notification','api','calls','call','data','text','code','web','database','audio','video','image','memory','search','orchestration','skill','vision','browser','filesystem','security','cloud','comms']);
489
+ const SYNC_DENY=/\b(?:figma|frida|linkedin|phonepe|phonehome|phone-a-friend|phone-harness)\b/i;
490
+ const WATCH_DENY=/opencode|watchtower|framewatch|filewatch|firewatch|cloudwatch|swatch|cybersec|rewatch|langwatch|webwatch|vibewatch/i;
491
+
492
+ function contentKeywords(keywords){
493
+ const out=[];
494
+ for(const keyword of keywords||[]){
495
+ const text=String(keyword||'').toLowerCase();
496
+ if(!text||OPTIONAL_COVERAGE.has(text)||out.includes(text))continue;
497
+ out.push(text);
498
+ }
499
+ return out;
500
+ }
501
+
502
+ function botBlob(bot){
503
+ const caps=(bot.caps||[]).map(c=>String(c).replace(/^cap\./,'').replace(/^mcp\./,''));
504
+ return `${bot.name||''} ${bot.id||''} ${caps.join(' ')}`.toLowerCase();
505
+ }
506
+
507
+ function botTokens(bot){
508
+ return new Set(words(botBlob(bot)).filter(token=>!CAP_NOISE.has(token)));
509
+ }
510
+
511
+ function tokenCovers(tokens,raw,keyword){
512
+ const key=String(keyword||'').toLowerCase();
513
+ if(!key)return false;
514
+ if(key==='x')return tokens.has('twitter')||/(^|[^a-z0-9])x([^a-z0-9]|$)/.test(raw);
515
+ if(key.includes(' '))return raw.includes(key);
516
+ if(tokens.has(key))return true;
517
+ if(key.length>=6&&raw.includes(key))return true;
518
+ if(key.length>=4){
519
+ for(const token of tokens)if(token.startsWith(key)&&token.length<=key.length+3)return true;
520
+ }
521
+ return false;
522
+ }
523
+
524
+ function timerToken(token){
525
+ if(token==='timer'||token==='timers')return true;
526
+ if(token.startsWith('schedule')||token.includes('scheduler'))return true;
527
+ if(token==='cron'||(token.startsWith('cron')&&!token.startsWith('cronomet')&&token.length<=10))return true;
528
+ return false;
529
+ }
530
+
531
+ function familyHit(family,tokens,raw,keywords){
532
+ if(family==='wearable'){
533
+ const asked=(keywords||[]).join(' ').toLowerCase();
534
+ if(!/watch|step|fitness|pedometer|accelerometer|wear|health|garmin|fitbit|sensor/.test(asked))return null;
535
+ if(WATCH_DENY.test(raw))return null;
536
+ if([...tokens].some(token=>['garmin','fitbit','pedometer','accelerometer','smartwatch','wearable','fitness'].includes(token)))return 'related';
537
+ if(/apple-health|applehealth|health-connect|healthconnect|wear-os|wearos|galaxy-watch/.test(raw))return 'related';
538
+ return null;
539
+ }
540
+ if(family==='timer'){
541
+ const want=new Set((keywords||[]).map(keyword=>keyword.toLowerCase()));
542
+ if([...tokens].some(timerToken))return [...tokens].some(token=>token==='timer'||token==='timers')?'direct':'related';
543
+ if((want.has('trigger')||want.has('triggers')||want.has('triggered'))&&[...tokens].some(token=>token==='trigger'||token.startsWith('trigger')))return 'direct';
544
+ return null;
545
+ }
546
+ if(family==='sync'){
547
+ if(SYNC_DENY.test(raw))return null;
548
+ if([...tokens].some(token=>token==='android'||token==='adb'))return 'direct';
549
+ const want=new Set((keywords||[]).map(keyword=>keyword.toLowerCase()));
550
+ if(want.has('mobile')&&tokens.has('mobile'))return 'related';
551
+ if((want.has('phone')||want.has('phones'))&&(tokens.has('android')||tokens.has('adb')||tokens.has('sms')||[...tokens].some(token=>token.startsWith('push')||token.startsWith('notif'))))return 'related';
552
+ if((want.has('sync')||want.has('syncs')||want.has('synced')||want.has('syncing'))&&tokens.has('sync'))return 'related';
553
+ return null;
554
+ }
555
+ if(family==='nutrition'){
556
+ const want=new Set((keywords||[]).map(keyword=>keyword.toLowerCase()));
557
+ const allowed=new Set(['calorie','calories','nutrition','meal','meals','mealie','diet','diets','recipe','recipes']);
558
+ if(want.has('food')||want.has('foods')){allowed.add('food');allowed.add('foods');}
559
+ const hit=[...tokens].some(token=>allowed.has(token)||(allowed.has('meal')&&token.startsWith('meal')&&token.length<=7)||token.startsWith('calorie'));
560
+ return hit?'related':null;
561
+ }
562
+ if(family==='social'){
563
+ const want=new Set((keywords||[]).map(keyword=>keyword.toLowerCase()));
564
+ const nets=['facebook','instagram','twitter'].filter(net=>want.has(net)||(net==='twitter'&&want.has('x')));
565
+ const have=[...tokens].filter(token=>token==='facebook'||token==='instagram'||token==='twitter');
566
+ if(!have.length)return null;
567
+ if(nets.length&&!nets.some(net=>have.includes(net)))return null;
568
+ return 'direct';
569
+ }
570
+ return null;
571
+ }
572
+
573
+ function overrideRank(scores,id){
574
+ const action=scores?.bots?.[id]?.action;
575
+ if(action==='boost')return -1;
576
+ if(action==='demote')return 1;
577
+ return 0;
578
+ }
579
+
580
+ function excluded(scores,id){
581
+ return scores?.bots?.[id]?.action==='exclude';
381
582
  }
382
583
 
383
- function rankModality(bots,modality,keywords,limit){
584
+ export function parseLimit(value){
585
+ if(value==null||value==='')return DEFAULT_LIMIT;
586
+ const text=String(value);
587
+ if(!/^\d+$/.test(text))throw new Error('Limit must be an integer from 1 to 20');
588
+ const n=Number(text);
589
+ if(!Number.isInteger(n)||n<MIN_LIMIT||n>MAX_LIMIT)throw new Error('Limit must be an integer from 1 to 20');
590
+ return n;
591
+ }
592
+
593
+ export function stackIdFor(parts,unmatched=[]){
594
+ const lines=[];
595
+ for(const part of parts){
596
+ lines.push('part:'+part.part);
597
+ for(const bot of part.bots)lines.push('bot:'+bot.id);
598
+ }
599
+ for(const item of unmatched)lines.push('unmatched:'+item.part);
600
+ return createHash('sha256').update(lines.join('\n')).digest('hex');
601
+ }
602
+
603
+ export function feedbackLine(stackId){
604
+ return `kloudy feedback --stack ${stackId} --bot <id> --result worked|failed|partial [--reason <short>]`;
605
+ }
606
+
607
+ function selectFamily(bots,piece,limit,scores){
608
+ const want=piece.modalities||[];
609
+ const pool=[];
610
+ for(let i=0;i<bots.length;i++){
611
+ const bot=bots[i];
612
+ if(!installCommand(bot)||excluded(scores,bot.id))continue;
613
+ const raw=botBlob(bot);
614
+ const tokens=botTokens(bot);
615
+ const hit=familyHit(piece.family,tokens,raw,piece.keywords);
616
+ if(!hit)continue;
617
+ const need=contentKeywords(piece.keywords);
618
+ const hits=need.filter(keyword=>tokenCovers(tokens,raw,keyword)).length;
619
+ pool.push({b:bot,i,hits,direct:hit==='direct'||need.some(keyword=>tokenCovers(tokens,raw,keyword))?1:0});
620
+ }
621
+ const tier=cls=>CLASS_TIER[cls]??9;
622
+ pool.sort((x,y)=>y.direct-x.direct||tier(x.b.cls)-tier(y.b.cls)||overrideRank(scores,x.b.id)-overrideRank(scores,y.b.id)||primarySortHint(y.b.modality_primary,want)-primarySortHint(x.b.modality_primary,want)||y.hits-x.hits||(y.b.score||0)-(x.b.score||0)||x.i-y.i||String(x.b.id).localeCompare(String(y.b.id)));
623
+ const chosen=pool.slice(0,limit).map(x=>x.b);
624
+ const need=contentKeywords(piece.keywords);
625
+ const confidence=!chosen.length?'none':!need.length||need.every(keyword=>chosen.some(bot=>tokenCovers(botTokens(bot),botBlob(bot),keyword)))?'match':'weak';
626
+ return {bots:chosen,confidence,candidatesTotal:pool.length};
627
+ }
628
+
629
+ function botItem(bot,modality,keywords,tail){
630
+ return {
631
+ id:bot.id,
632
+ name:bot.name,
633
+ class:bot.cls,
634
+ command:installCommand(bot),
635
+ license:bot.license||'',
636
+ description:oneLine(bot),
637
+ why:whyLine(modality,keywords,tail),
638
+ modalities:[modality]
639
+ };
640
+ }
641
+
642
+ function rankModality(bots,modality,keywords,scores){
384
643
  const terms=[...new Set((keywords||[]).flatMap(words))];
385
644
  const want=[modality];
386
645
  const pool=[];
387
646
  for(let i=0;i<bots.length;i++){
388
647
  const b=bots[i];
389
- if(!(b.modalities||[]).includes(modality))continue;
648
+ if(!(b.modalities||[]).includes(modality)||excluded(scores,b.id))continue;
390
649
  const blob=(b.name+' '+b.id+' '+(b.caps||[]).join(' ')).toLowerCase();
391
650
  pool.push({b,i,hits:terms.filter(t=>blob.includes(t)).length});
392
651
  }
393
652
  const tier=cls=>CLASS_TIER[cls]??9;
394
- pool.sort((x,y)=>tier(x.b.cls)-tier(y.b.cls)||primarySortHint(y.b.modality_primary,want)-primarySortHint(x.b.modality_primary,want)||y.hits-x.hits||(y.b.score||0)-(x.b.score||0)||x.i-y.i||String(x.b.id).localeCompare(String(y.b.id)));
395
- return pool.slice(0,limit).map(x=>x.b);
653
+ pool.sort((x,y)=>tier(x.b.cls)-tier(y.b.cls)||overrideRank(scores,x.b.id)-overrideRank(scores,y.b.id)||primarySortHint(y.b.modality_primary,want)-primarySortHint(x.b.modality_primary,want)||y.hits-x.hits||(y.b.score||0)-(x.b.score||0)||x.i-y.i||String(x.b.id).localeCompare(String(y.b.id)));
654
+ return pool.map(x=>x.b);
655
+ }
656
+
657
+ function recordPiece(piece,bots,confidence,parts,unmatched,gaps,candidatesTotal=bots.length){
658
+ if(bots.length)parts.push({part:piece.text,modalities:piece.modalities,confidence,candidatesTotal,bots});
659
+ else unmatched.push({part:piece.text,reason:'no modality or catalog match'});
660
+ if(confidence==='match')return;
661
+ if(bots.length)gaps.push({part:piece.text,confidence,reason:WEAK_REASON[piece.family]||'Catalog hits only partly overlap this part.',kind:KIND_CATALOG});
662
+ const suggestKind=piece.family==='wearable'||piece.family==='sync'||piece.family==='social';
663
+ if(suggestKind&&SUGGEST[piece.family])gaps.push({part:piece.text,confidence:'none',reason:SUGGEST[piece.family],kind:KIND_SUGGEST});
664
+ else if(!bots.length)gaps.push({part:piece.text,confidence:'none',reason:'no modality or catalog match'});
396
665
  }
397
666
 
398
667
  export function recommendGoal(goal,options={}){
399
668
  const source=options.bots?{bots:options.bots}:loadLocal();
400
669
  if(!source?.bots)throw new Error('No local catalog. Run `kloudy bots sync`.');
401
- const limit=options.perModality??PER_MODALITY;
670
+ const limit=parseLimit(options.limit??options.perModality??PER_MODALITY);
671
+ const scores=normalizeScores(options.scores);
402
672
  const pieces=decomposeGoal(goal);
403
673
  const parts=[];
404
674
  const unmatched=[];
405
- const consider=pieces.length?pieces:[{text:String(goal||'').trim(),modalities:[],keywords:[]}];
675
+ const gaps=[];
676
+ const consider=pieces.length?pieces:[{text:String(goal||'').trim(),modalities:[],keywords:[],family:null}];
406
677
  for(const piece of consider){
407
678
  if(!piece.text)continue;
679
+ if(piece.family){
680
+ const picked=selectFamily(source.bots,piece,limit,scores);
681
+ const modality=piece.modalities[0];
682
+ const bots=picked.bots.map(bot=>botItem(bot,modality,piece.keywords,'catalog name search'));
683
+ for(const bot of bots)bot.modalities=[...piece.modalities];
684
+ recordPiece(piece,bots,picked.confidence,parts,unmatched,gaps,picked.candidatesTotal);
685
+ continue;
686
+ }
408
687
  if(!piece.modalities.length){
409
- unmatched.push({part:piece.text,reason:'no modality or catalog match'});
688
+ recordPiece(piece,[], 'none',parts,unmatched,gaps,0);
410
689
  continue;
411
690
  }
412
691
  const seen=new Map();
413
- const bots=[];
692
+ const ranked=[];
414
693
  for(const modality of piece.modalities){
415
- for(const bot of rankModality(source.bots,modality,piece.keywords,limit)){
694
+ for(const bot of rankModality(source.bots,modality,piece.keywords,scores)){
416
695
  const prev=seen.get(bot.id);
417
696
  if(prev){
418
697
  if(!prev.modalities.includes(modality))prev.modalities.push(modality);
419
698
  continue;
420
699
  }
421
- const item={
422
- id:bot.id,
423
- name:bot.name,
424
- class:bot.cls,
425
- command:installCommand(bot),
426
- license:bot.license||'',
427
- description:oneLine(bot),
428
- why:whyLine(modality,piece.keywords),
429
- modalities:[modality]
430
- };
700
+ const item={bot,modalities:[modality]};
431
701
  seen.set(bot.id,item);
432
- bots.push(item);
702
+ ranked.push(item);
433
703
  }
434
704
  }
435
- if(!bots.length){
436
- unmatched.push({part:piece.text,reason:'no modality or catalog match'});
437
- continue;
438
- }
439
- parts.push({part:piece.text,modalities:piece.modalities,bots});
705
+ const bots=ranked.slice(0,limit).map(({bot,modalities})=>{
706
+ const item=botItem(bot,modalities[0],piece.keywords);
707
+ item.modalities=modalities;
708
+ return item;
709
+ });
710
+ recordPiece(piece,bots,bots.length?'match':'none',parts,unmatched,gaps,ranked.length);
440
711
  }
441
- return {schema:RECOMMEND_SCHEMA,catalog:CATALOG_NAME,version:options.version||packageVersion(),goal:String(goal??''),parts,unmatched};
712
+ const complete=parts.length>0&&parts.every(part=>part.confidence==='match')&&gaps.length===0&&unmatched.length===0;
713
+ const stackId=stackIdFor(parts,unmatched);
714
+ return {schema:RECOMMEND_SCHEMA,catalog:CATALOG_NAME,version:options.version||packageVersion(),goal:String(goal??''),limit,stackId,feedback:feedbackLine(stackId),complete,parts,unmatched,gaps};
442
715
  }
443
716
 
444
717
  export function formatRecommend(result){
445
- const lines=[`${result.catalog} · package ${result.version}`,`Goal: ${result.goal}`];
718
+ const lines=[`${result.catalog} · package ${result.version}`,`Goal: ${result.goal}`,`complete=${result.complete?'true':'false'}`,`limit=${result.limit}`,`stackId: ${result.stackId}`,`feedback: ${result.feedback}`];
446
719
  result.parts.forEach((part,index)=>{
447
- lines.push('',`${index+1}. ${part.part}`,`Modalities: ${part.modalities.join(', ')}`);
720
+ lines.push('',`${index+1}. ${part.part}`,`Modalities: ${part.modalities.join(', ')}`,`Confidence: ${part.confidence}`,`Candidates: ${part.candidatesTotal}`);
448
721
  part.bots.forEach((bot,n)=>{
449
722
  lines.push(` ${n+1}) ${bot.id} [${bot.class}] ${bot.license||'unknown license'}`,` ${bot.command}`,` ${bot.description}`,` why: ${bot.why}`);
450
723
  });
@@ -453,15 +726,20 @@ export function formatRecommend(result){
453
726
  lines.push('','Unmatched');
454
727
  for(const item of result.unmatched)lines.push(`- ${item.part}: ${item.reason}`);
455
728
  }
729
+ lines.push('','Gaps');
730
+ if(!result.gaps.length)lines.push('(none)');
731
+ else for(const gap of result.gaps)lines.push(`- ${gap.part} [${gap.confidence}]${gap.kind?` ${gap.kind}:`:''} ${gap.reason}`);
456
732
  return lines.join('\n');
457
733
  }
458
734
 
459
- export function recommendCommand(args,opts={}){
735
+ export async function recommendCommand(args,opts={}){
460
736
  const out=opts.out||(s=>console.log(s));
461
737
  const o=flags(args);
462
738
  const goal=o._.join(' ').trim();
463
- if(!goal)throw new Error('Usage: kloudy recommend <goal> [--json]');
464
- const result=recommendGoal(goal);
739
+ if(!goal)throw new Error('Usage: kloudy recommend <goal> [--json] [--limit N]');
740
+ const limit=parseLimit(o.limit);
741
+ const scores=opts.scores!==undefined?opts.scores:await loadScoreOverrides({offline:!!o.offline,fetchImpl:opts.fetch,now:opts.now,dir:opts.dataDir,url:opts.scoresUrl});
742
+ const result=recommendGoal(goal,{limit,scores});
465
743
  out(o.json?JSON.stringify(result,null,2):formatRecommend(result));
466
744
  return result;
467
745
  }