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/AGENTS.md +34 -5
- package/README.md +60 -21
- package/bin/entry.mjs +32 -25
- package/bin/kloudy.mjs +4 -1
- package/package.json +2 -2
- package/src/chooser-link.mjs +17 -17
- package/src/conversation.mjs +42 -1
- package/src/feedback.mjs +159 -0
- package/src/hosted.mjs +17 -5
- package/src/httpk.mjs +11 -4
- package/src/mcp.mjs +76 -74
- package/src/recommend.mjs +330 -52
- package/src/remote.mjs +117 -89
- package/src/rooms.mjs +86 -0
- package/src/scores.mjs +80 -0
- package/src/website-return.mjs +18 -18
package/src/recommend.mjs
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
|
-
//
|
|
2
|
-
// `kloudy recommend <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
|
|
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
|
-
//
|
|
12
|
-
//
|
|
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
|
-
|
|
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};
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
688
|
+
recordPiece(piece,[], 'none',parts,unmatched,gaps,0);
|
|
410
689
|
continue;
|
|
411
690
|
}
|
|
412
691
|
const seen=new Map();
|
|
413
|
-
const
|
|
692
|
+
const ranked=[];
|
|
414
693
|
for(const modality of piece.modalities){
|
|
415
|
-
for(const bot of rankModality(source.bots,modality,piece.keywords,
|
|
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
|
-
|
|
702
|
+
ranked.push(item);
|
|
433
703
|
}
|
|
434
704
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|