kloudy 1.0.0 → 1.0.2
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 +83 -0
- package/README.md +88 -1
- package/bin/entry.mjs +4 -1
- package/bin/kloudy.mjs +10 -1
- package/package.json +3 -2
- package/schemas/kloudy.deployable-bot.schema.json +80 -80
- package/src/bots.mjs +144 -144
- package/src/feedback.mjs +159 -0
- package/src/modalities.mjs +28 -27
- package/src/recommend.mjs +756 -0
- package/src/scores.mjs +80 -0
package/src/bots.mjs
CHANGED
|
@@ -1,144 +1,144 @@
|
|
|
1
|
-
// `kloudy bots` — offline-first deployable-bots catalog.
|
|
2
|
-
// kloudy bots list [--status verified|untested|reported-broken] [--kind http|npx|uvx|botboozle|hf] [--modality m] [--all-modalities] [--device d] [--limit n] [--offset n] [--json]
|
|
3
|
-
// kloudy bots search <words> [same flags]
|
|
4
|
-
// kloudy bots get <id> [--json]
|
|
5
|
-
// kloudy bots sync download the full catalog to the local cache
|
|
6
|
-
// kloudy bots probe <id> HTTPK: MCP initialize + tools/list against a verified HTTP bot
|
|
7
|
-
// --api <base> or KLOUDY_API=<base> use that server (e.g. http://127.0.0.1:4360) live, cache as fallback
|
|
8
|
-
// kloudy bots works|broken <id> tell Kloudy whether a bot worked for you (free, keyless, no personal data)
|
|
9
|
-
// Lookup order: bundled catalog (if this build ships one) -> ~/.cache/kloudy/deployable-bots.json.gz -> live API.
|
|
10
|
-
import {readFileSync,writeFileSync,mkdirSync,existsSync} from 'node:fs';
|
|
11
|
-
import {gunzipSync} from 'node:zlib';
|
|
12
|
-
import {homedir} from 'node:os';
|
|
13
|
-
import {join} from 'node:path';
|
|
14
|
-
import {modalityWant,rowMatchesModalities,primarySortHint} from './modalities.mjs';
|
|
15
|
-
let BASE=process.env.KLOUDY_BOTS_API||(process.env.KLOUDY_API?process.env.KLOUDY_API.replace(/\/$/,'')+'/api/deployable-bots':'https://kloudy.ai/api/deployable-bots');
|
|
16
|
-
let EXPLICIT=!!(process.env.KLOUDY_BOTS_API||process.env.KLOUDY_API);
|
|
17
|
-
const cacheDir=()=>process.env.KLOUDY_CACHE_DIR||join(process.env.XDG_CACHE_HOME||join(homedir(),'.cache'),'kloudy');
|
|
18
|
-
const cacheFile=()=>join(cacheDir(),'deployable-bots.json.gz');
|
|
19
|
-
const bundled=[new URL('./deployable-bots.json.gz',import.meta.url)];
|
|
20
|
-
const words=s=>String(s||'').toLowerCase().split(/[^\p{L}\p{N}_]+/u).filter(w=>w.length>1);
|
|
21
|
-
function decode(buf){const d=JSON.parse(gunzipSync(buf));return d.bots.map(r=>{const [id,name,kind,cls,endpoint,license,url,score,caps,targets,botboozle,source,meta]=r;return {id,name,kind,cls,endpoint,license,url,score,caps,targets,botboozle,source,meta,modalities:meta?.modalities||[],modality_primary:meta?.modalityPrimary||null};}).filter(b=>b.cls!=='D');}
|
|
22
|
-
export function loadLocal(){
|
|
23
|
-
for(const p of [cacheFile(),...bundled])try{if(existsSync(p))return {source:String(p),bots:decode(readFileSync(p))};}catch{}
|
|
24
|
-
return null;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function localEndpoints(b){
|
|
28
|
-
if(Array.isArray(b.meta?.connect)&&b.meta.connect.length){
|
|
29
|
-
return b.meta.connect.map(ep=>{
|
|
30
|
-
const out={type:ep.type};
|
|
31
|
-
if(ep.url)out.url=ep.url;
|
|
32
|
-
if(ep.value)out.command=ep.value;
|
|
33
|
-
if(ep.os)out.os=ep.os;
|
|
34
|
-
if(ep.protocol)out.protocol=ep.protocol;
|
|
35
|
-
return out;
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
if(b.kind==='http')return [{type:'mcp-http',url:b.endpoint}];
|
|
39
|
-
return [{type:b.kind==='hf'?b.kind:'local-'+b.kind,command:b.endpoint}];
|
|
40
|
-
}
|
|
41
|
-
function formatHfGet(bot){
|
|
42
|
-
const h=bot.meta?.hf||{};
|
|
43
|
-
const lines=[`${bot.id} [hf]${bot.license?' '+bot.license:''}`];
|
|
44
|
-
if(h.repo)lines.push('Repo: '+h.repo);
|
|
45
|
-
if(h.revision)lines.push('Revision: '+h.revision);
|
|
46
|
-
if(h.total_size)lines.push('Size: '+h.total_size);
|
|
47
|
-
if(h.runner)lines.push('Runner: '+h.runner);
|
|
48
|
-
if(bot.license)lines.push('License: '+bot.license);
|
|
49
|
-
lines.push('');
|
|
50
|
-
lines.push('Connect (files are not downloaded automatically; say yes before any fetch):');
|
|
51
|
-
const steps=(bot.meta?.connect||[]).filter(ep=>ep.type!=='source');
|
|
52
|
-
if(!steps.length&&bot.endpoint)lines.push(' '+bot.endpoint);
|
|
53
|
-
for(const ep of steps){
|
|
54
|
-
const val=ep.value||ep.url||'';
|
|
55
|
-
const os=ep.os?` (${ep.os})`:'';
|
|
56
|
-
if(val)lines.push(` [${ep.type}${os}] ${val}`);
|
|
57
|
-
}
|
|
58
|
-
if(bot.url)lines.push('Source: '+bot.url);
|
|
59
|
-
lines.push('');
|
|
60
|
-
lines.push('Nothing is installed or downloaded until you say yes.');
|
|
61
|
-
return lines.join('\n');
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const TIER={A:0,C:1,O:2,B:3};
|
|
65
|
-
export function localSearch(bots,{q='',status,kind,device,modality,modalities,allModalities=false,limit=25,offset=0}={}){
|
|
66
|
-
const terms=words(q);let pool=bots.map((b,i)=>({b,i,hits:terms.filter(t=>(b.name+' '+b.id+' '+b.caps.join(' ')).toLowerCase().includes(t)).length}));
|
|
67
|
-
if(terms.length)pool=pool.filter(x=>x.hits);
|
|
68
|
-
pool=pool.map(x=>({...x,status:x.b.cls==='A'?'verified':'untested'}));
|
|
69
|
-
if(status)pool=pool.filter(x=>x.status===status);if(kind)pool=pool.filter(x=>x.b.kind===kind);if(device)pool=pool.filter(x=>x.b.targets.includes(device));
|
|
70
|
-
const want=modalityWant(modality||modalities);
|
|
71
|
-
if(want.length)pool=pool.filter(x=>rowMatchesModalities(x.b.modalities,want,{all:!!allModalities}));
|
|
72
|
-
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-x.b.score||x.i-y.i);
|
|
73
|
-
return {matched:pool.length,offset,items:pool.slice(offset,offset+limit).map(({b,status})=>({id:b.id,name:b.name,kind:b.kind,status,endpoints:localEndpoints(b),free:true,needs_botboozle:!!b.botboozle,works_count:null,broken_count:null,license:b.license,source_url:b.url,devices:b.targets,modalities:b.modalities||[],modality_primary:b.modality_primary||null,meta:b.meta||undefined}))};
|
|
74
|
-
}
|
|
75
|
-
async function remote(path,init){const r=await fetch(BASE+path,{...init,signal:AbortSignal.timeout(8000)});const body=await r.json().catch(()=>({}));if(!r.ok)throw Object.assign(new Error(body.error||'HTTP '+r.status),{status:r.status});return body;}
|
|
76
|
-
// HTTPK fetch: MCP streamable-http initialize + tools/list (read-only; no tools are called).
|
|
77
|
-
export async function mcpProbe(url){
|
|
78
|
-
const h={'content-type':'application/json',accept:'application/json, text/event-stream'};
|
|
79
|
-
const rpc=async(body,extra={})=>{const r=await fetch(url,{method:'POST',headers:{...h,...extra},body:JSON.stringify(body),signal:AbortSignal.timeout(15000)});
|
|
80
|
-
const t=await r.text();if(!r.ok&&r.status!==202)throw new Error('MCP HTTP '+r.status);
|
|
81
|
-
const msg=r.headers.get('content-type')?.includes('event-stream')?t.split('\n').filter(l=>l.startsWith('data:')).map(l=>{try{return JSON.parse(l.slice(5))}catch{return null}}).find(m=>m&&m.id===body.id):t?JSON.parse(t):null;
|
|
82
|
-
return {msg,session:r.headers.get('mcp-session-id')};};
|
|
83
|
-
const init=await rpc({jsonrpc:'2.0',id:1,method:'initialize',params:{protocolVersion:'2025-06-18',capabilities:{},clientInfo:{name:'kloudy-bots',version:'0.1'}}});
|
|
84
|
-
const s=init.session?{'mcp-session-id':init.session}:{};
|
|
85
|
-
await fetch(url,{method:'POST',headers:{...h,...s},body:JSON.stringify({jsonrpc:'2.0',method:'notifications/initialized'}),signal:AbortSignal.timeout(15000)}).catch(()=>{});
|
|
86
|
-
const list=await rpc({jsonrpc:'2.0',id:2,method:'tools/list',params:{}},s);
|
|
87
|
-
if(init.msg?.error)throw new Error('initialize: '+init.msg.error.message);
|
|
88
|
-
return {protocol:init.msg?.result?.protocolVersion,server:init.msg?.result?.serverInfo,tools:(list.msg?.result?.tools||[]).map(t=>({name:t.name,description:(t.description||'').slice(0,160)}))};
|
|
89
|
-
}
|
|
90
|
-
export function flags(args){const o={_:[]};for(let i=0;i<args.length;i++){const a=args[i];if(a.startsWith('--')){const k=a.slice(2);if(k==='json'||k==='offline'||k==='live'||k==='all-modalities'){o[k==='all-modalities'?'allModalities':k]=true;}else if(k==='modality'||k==='modalities'){const v=args[++i];o.modality=o.modality||[];if(v!=null)o.modality.push(...modalityWant(v));}else o[k]=args[++i];}else o._.push(a);}return o;}
|
|
91
|
-
const line=b=>`${b.status==='verified'?'✔':b.status==='reported-broken'?'✖':'·'} ${b.id} [${b.kind}${b.works_count!=null?` +${b.works_count}/-${b.broken_count}`:''}] ${b.endpoints[0].url||b.endpoints[0].command}`;
|
|
92
|
-
export async function botsCommand(args,opts={}){
|
|
93
|
-
const out=opts.out||(s=>console.log(s));
|
|
94
|
-
const [sub='list',...rest]=args,o=flags(rest);
|
|
95
|
-
if(o.api){BASE=o.api.replace(/\/$/,'')+(/\/api\/deployable-bots$/.test(o.api)?'':'/api/deployable-bots');EXPLICIT=true;}
|
|
96
|
-
const limit=Number(o.limit||25),offset=Number(o.offset||0);
|
|
97
|
-
const print=(r,src)=>{if(o.json)return out(JSON.stringify({...r,source:src},null,2));out(`${r.matched} bots (${src})`);for(const b of r.items)out(line(b));if(r.matched>offset+r.items.length)out(`more: --offset ${offset+r.items.length}`);};
|
|
98
|
-
if(sub==='sync'){const r=await fetch(BASE+'/export',{signal:AbortSignal.timeout(60000)});if(!r.ok)throw new Error('Sync failed: HTTP '+r.status);
|
|
99
|
-
const {gzipSync}=await import('node:zlib');const text=await r.text();mkdirSync(cacheDir(),{recursive:true});writeFileSync(cacheFile(),gzipSync(text));out(`cached ${decode(gzipSync(text)).length} bots at ${cacheFile()}`);return;}
|
|
100
|
-
if(sub==='works'||sub==='broken'){const id=o._[0];if(!id)throw new Error(`Usage: kloudy bots ${sub} <id>`);
|
|
101
|
-
const r=await remote('/feedback',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id,works:sub==='works'})});out(`thanks — recorded "${sub}" for ${r.id}`);return;}
|
|
102
|
-
if(sub==='probe'){const id=o._[0];if(!id)throw new Error('Usage: kloudy bots probe <id>');
|
|
103
|
-
let bot;try{bot=(await remote('/'+encodeURIComponent(id))).bot;}catch{const l=loadLocal();bot=l&&localSearch(l.bots.filter(b=>b.id===id)).items[0];}
|
|
104
|
-
if(!bot)throw new Error('Unknown bot id.');const ep=bot.endpoints[0];if(!ep.url)throw new Error('probe supports HTTP bots; local bots run with: '+ep.command);
|
|
105
|
-
const r=await mcpProbe(ep.url);if(o.json)return out(JSON.stringify(r,null,2));
|
|
106
|
-
out(`${bot.id} -> ${ep.url}\nserver: ${r.server?.name||'?'} ${r.server?.version||''} (protocol ${r.protocol||'?'})\ntools (${r.tools.length}): ${r.tools.map(t=>t.name).join(', ')}`);return;}
|
|
107
|
-
if(sub==='get'){const id=o._[0];if(!id)throw new Error('Usage: kloudy bots get <id>');
|
|
108
|
-
let bot=null, local=loadLocal();
|
|
109
|
-
if(!o.offline)try{bot=(await remote('/'+encodeURIComponent(id))).bot;}catch{}
|
|
110
|
-
if(!bot){const hit=local&&localSearch(local.bots.filter(b=>b.id===id)).items[0];if(!hit)throw new Error('Unknown bot id (offline). Run `kloudy bots sync`.');bot={...hit,meta:hit.meta||local.bots.find(b=>b.id===id)?.meta};}
|
|
111
|
-
const raw=local&&local.bots.find(b=>b.id===id);
|
|
112
|
-
if(raw){bot.meta=bot.meta||raw.meta;bot.targets=bot.targets||raw.targets;}
|
|
113
|
-
if(bot.kind==='hf'||raw?.kind==='hf'){
|
|
114
|
-
const full=raw?{...raw,...bot,meta:bot.meta||raw.meta,url:bot.source_url||bot.url||raw.url}:bot;
|
|
115
|
-
if(o.json)return out(JSON.stringify(bot,null,2));
|
|
116
|
-
return out(formatHfGet(full));
|
|
117
|
-
}
|
|
118
|
-
return out(JSON.stringify(bot,null,2));}
|
|
119
|
-
if(sub==='modalities'){
|
|
120
|
-
const l=loadLocal();
|
|
121
|
-
if(!l)throw new Error('No local catalog. Run `kloudy bots sync`.');
|
|
122
|
-
const counts=new Map();
|
|
123
|
-
let tagged=0,untagged=0;
|
|
124
|
-
for(const b of l.bots){
|
|
125
|
-
const mods=b.modalities||[];
|
|
126
|
-
if(!mods.length){untagged++;continue;}
|
|
127
|
-
tagged++;
|
|
128
|
-
for(const m of mods)counts.set(m,(counts.get(m)||0)+1);
|
|
129
|
-
}
|
|
130
|
-
const items=[...counts.entries()].sort((a,c)=>c[1]-a[1]||a[0].localeCompare(c[0])).map(([modality,count])=>({modality,count}));
|
|
131
|
-
if(o.json)return out(JSON.stringify({total:l.bots.length,tagged,untagged,items},null,2));
|
|
132
|
-
out(`${items.length} modalities across ${tagged} tagged bots`+(untagged?` (${untagged} untagged)`:''));
|
|
133
|
-
for(const it of items)out(`${it.count}\t${it.modality}`);
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
if(sub!=='list'&&sub!=='search')throw new Error('Usage: kloudy bots list|search|get|sync|works|broken|modalities');
|
|
137
|
-
const q=sub==='search'?o._.join(' '):'';const query={q,status:o.status,kind:o.kind,device:o.device,modality:o.modality||o.modalities,allModalities:!!o.allModalities,limit,offset};
|
|
138
|
-
const l=loadLocal();
|
|
139
|
-
// Offline first: answer from bundled/cached data; the live API adds user-reported status when reachable and the cache is missing.
|
|
140
|
-
if(!o.offline&&(EXPLICIT||o.live))try{const p=new URLSearchParams(Object.entries({q,status:o.status,kind:o.kind,device:o.device,modality:Array.isArray(o.modality)?o.modality.join(','):o.modality||o.modalities,all_modalities:o.allModalities?'true':undefined,intent:o.intent,tier:o.tier,limit,offset}).filter(([,v])=>v!=null&&v!==''));return print(await remote('?'+p),'live: '+BASE);}catch(e){if(!l)throw e;}
|
|
141
|
-
if(l||o.offline){if(!l)throw new Error('No local catalog. Run `kloudy bots sync`.');return print(localSearch(l.bots,query),'local: '+l.source);}
|
|
142
|
-
const p=new URLSearchParams(Object.entries({q,status:o.status,kind:o.kind,device:o.device,modality:Array.isArray(o.modality)?o.modality.join(','):o.modality||o.modalities,all_modalities:o.allModalities?'true':undefined,intent:o.intent,tier:o.tier,limit,offset}).filter(([,v])=>v!=null&&v!==''));
|
|
143
|
-
return print(await remote('?'+p),'live');
|
|
144
|
-
}
|
|
1
|
+
// `kloudy bots` — offline-first deployable-bots catalog.
|
|
2
|
+
// kloudy bots list [--status verified|untested|reported-broken] [--kind http|npx|uvx|botboozle|hf] [--modality m] [--all-modalities] [--device d] [--limit n] [--offset n] [--json]
|
|
3
|
+
// kloudy bots search <words> [same flags]
|
|
4
|
+
// kloudy bots get <id> [--json]
|
|
5
|
+
// kloudy bots sync download the full catalog to the local cache
|
|
6
|
+
// kloudy bots probe <id> HTTPK: MCP initialize + tools/list against a verified HTTP bot
|
|
7
|
+
// --api <base> or KLOUDY_API=<base> use that server (e.g. http://127.0.0.1:4360) live, cache as fallback
|
|
8
|
+
// kloudy bots works|broken <id> tell Kloudy whether a bot worked for you (free, keyless, no personal data)
|
|
9
|
+
// Lookup order: bundled catalog (if this build ships one) -> ~/.cache/kloudy/deployable-bots.json.gz -> live API.
|
|
10
|
+
import {readFileSync,writeFileSync,mkdirSync,existsSync} from 'node:fs';
|
|
11
|
+
import {gunzipSync} from 'node:zlib';
|
|
12
|
+
import {homedir} from 'node:os';
|
|
13
|
+
import {join} from 'node:path';
|
|
14
|
+
import {modalityWant,rowMatchesModalities,primarySortHint} from './modalities.mjs';
|
|
15
|
+
let BASE=process.env.KLOUDY_BOTS_API||(process.env.KLOUDY_API?process.env.KLOUDY_API.replace(/\/$/,'')+'/api/deployable-bots':'https://kloudy.ai/api/deployable-bots');
|
|
16
|
+
let EXPLICIT=!!(process.env.KLOUDY_BOTS_API||process.env.KLOUDY_API);
|
|
17
|
+
const cacheDir=()=>process.env.KLOUDY_CACHE_DIR||join(process.env.XDG_CACHE_HOME||join(homedir(),'.cache'),'kloudy');
|
|
18
|
+
const cacheFile=()=>join(cacheDir(),'deployable-bots.json.gz');
|
|
19
|
+
const bundled=[new URL('./deployable-bots.json.gz',import.meta.url)];
|
|
20
|
+
const words=s=>String(s||'').toLowerCase().split(/[^\p{L}\p{N}_]+/u).filter(w=>w.length>1);
|
|
21
|
+
function decode(buf){const d=JSON.parse(gunzipSync(buf));return d.bots.map(r=>{const [id,name,kind,cls,endpoint,license,url,score,caps,targets,botboozle,source,meta]=r;return {id,name,kind,cls,endpoint,license,url,score,caps,targets,botboozle,source,meta,modalities:meta?.modalities||[],modality_primary:meta?.modalityPrimary||null};}).filter(b=>b.cls!=='D');}
|
|
22
|
+
export function loadLocal(){
|
|
23
|
+
for(const p of [cacheFile(),...bundled])try{if(existsSync(p))return {source:String(p),bots:decode(readFileSync(p))};}catch{}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function localEndpoints(b){
|
|
28
|
+
if(Array.isArray(b.meta?.connect)&&b.meta.connect.length){
|
|
29
|
+
return b.meta.connect.map(ep=>{
|
|
30
|
+
const out={type:ep.type};
|
|
31
|
+
if(ep.url)out.url=ep.url;
|
|
32
|
+
if(ep.value)out.command=ep.value;
|
|
33
|
+
if(ep.os)out.os=ep.os;
|
|
34
|
+
if(ep.protocol)out.protocol=ep.protocol;
|
|
35
|
+
return out;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
if(b.kind==='http')return [{type:'mcp-http',url:b.endpoint}];
|
|
39
|
+
return [{type:b.kind==='hf'?b.kind:'local-'+b.kind,command:b.endpoint}];
|
|
40
|
+
}
|
|
41
|
+
function formatHfGet(bot){
|
|
42
|
+
const h=bot.meta?.hf||{};
|
|
43
|
+
const lines=[`${bot.id} [hf]${bot.license?' '+bot.license:''}`];
|
|
44
|
+
if(h.repo)lines.push('Repo: '+h.repo);
|
|
45
|
+
if(h.revision)lines.push('Revision: '+h.revision);
|
|
46
|
+
if(h.total_size)lines.push('Size: '+h.total_size);
|
|
47
|
+
if(h.runner)lines.push('Runner: '+h.runner);
|
|
48
|
+
if(bot.license)lines.push('License: '+bot.license);
|
|
49
|
+
lines.push('');
|
|
50
|
+
lines.push('Connect (files are not downloaded automatically; say yes before any fetch):');
|
|
51
|
+
const steps=(bot.meta?.connect||[]).filter(ep=>ep.type!=='source');
|
|
52
|
+
if(!steps.length&&bot.endpoint)lines.push(' '+bot.endpoint);
|
|
53
|
+
for(const ep of steps){
|
|
54
|
+
const val=ep.value||ep.url||'';
|
|
55
|
+
const os=ep.os?` (${ep.os})`:'';
|
|
56
|
+
if(val)lines.push(` [${ep.type}${os}] ${val}`);
|
|
57
|
+
}
|
|
58
|
+
if(bot.url)lines.push('Source: '+bot.url);
|
|
59
|
+
lines.push('');
|
|
60
|
+
lines.push('Nothing is installed or downloaded until you say yes.');
|
|
61
|
+
return lines.join('\n');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const TIER={A:0,C:1,O:2,B:3};
|
|
65
|
+
export function localSearch(bots,{q='',status,kind,device,modality,modalities,allModalities=false,limit=25,offset=0}={}){
|
|
66
|
+
const terms=words(q);let pool=bots.map((b,i)=>({b,i,hits:terms.filter(t=>(b.name+' '+b.id+' '+b.caps.join(' ')).toLowerCase().includes(t)).length}));
|
|
67
|
+
if(terms.length)pool=pool.filter(x=>x.hits);
|
|
68
|
+
pool=pool.map(x=>({...x,status:x.b.cls==='A'?'verified':'untested'}));
|
|
69
|
+
if(status)pool=pool.filter(x=>x.status===status);if(kind)pool=pool.filter(x=>x.b.kind===kind);if(device)pool=pool.filter(x=>x.b.targets.includes(device));
|
|
70
|
+
const want=modalityWant(modality||modalities);
|
|
71
|
+
if(want.length)pool=pool.filter(x=>rowMatchesModalities(x.b.modalities,want,{all:!!allModalities}));
|
|
72
|
+
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-x.b.score||x.i-y.i);
|
|
73
|
+
return {matched:pool.length,offset,items:pool.slice(offset,offset+limit).map(({b,status})=>({id:b.id,name:b.name,kind:b.kind,status,endpoints:localEndpoints(b),free:true,needs_botboozle:!!b.botboozle,works_count:null,broken_count:null,license:b.license,source_url:b.url,devices:b.targets,modalities:b.modalities||[],modality_primary:b.modality_primary||null,meta:b.meta||undefined}))};
|
|
74
|
+
}
|
|
75
|
+
async function remote(path,init){const r=await fetch(BASE+path,{...init,signal:AbortSignal.timeout(8000)});const body=await r.json().catch(()=>({}));if(!r.ok)throw Object.assign(new Error(body.error||'HTTP '+r.status),{status:r.status});return body;}
|
|
76
|
+
// HTTPK fetch: MCP streamable-http initialize + tools/list (read-only; no tools are called).
|
|
77
|
+
export async function mcpProbe(url){
|
|
78
|
+
const h={'content-type':'application/json',accept:'application/json, text/event-stream'};
|
|
79
|
+
const rpc=async(body,extra={})=>{const r=await fetch(url,{method:'POST',headers:{...h,...extra},body:JSON.stringify(body),signal:AbortSignal.timeout(15000)});
|
|
80
|
+
const t=await r.text();if(!r.ok&&r.status!==202)throw new Error('MCP HTTP '+r.status);
|
|
81
|
+
const msg=r.headers.get('content-type')?.includes('event-stream')?t.split('\n').filter(l=>l.startsWith('data:')).map(l=>{try{return JSON.parse(l.slice(5))}catch{return null}}).find(m=>m&&m.id===body.id):t?JSON.parse(t):null;
|
|
82
|
+
return {msg,session:r.headers.get('mcp-session-id')};};
|
|
83
|
+
const init=await rpc({jsonrpc:'2.0',id:1,method:'initialize',params:{protocolVersion:'2025-06-18',capabilities:{},clientInfo:{name:'kloudy-bots',version:'0.1'}}});
|
|
84
|
+
const s=init.session?{'mcp-session-id':init.session}:{};
|
|
85
|
+
await fetch(url,{method:'POST',headers:{...h,...s},body:JSON.stringify({jsonrpc:'2.0',method:'notifications/initialized'}),signal:AbortSignal.timeout(15000)}).catch(()=>{});
|
|
86
|
+
const list=await rpc({jsonrpc:'2.0',id:2,method:'tools/list',params:{}},s);
|
|
87
|
+
if(init.msg?.error)throw new Error('initialize: '+init.msg.error.message);
|
|
88
|
+
return {protocol:init.msg?.result?.protocolVersion,server:init.msg?.result?.serverInfo,tools:(list.msg?.result?.tools||[]).map(t=>({name:t.name,description:(t.description||'').slice(0,160)}))};
|
|
89
|
+
}
|
|
90
|
+
export function flags(args){const o={_:[]};for(let i=0;i<args.length;i++){const a=args[i];if(a.startsWith('--')){const k=a.slice(2);if(k==='json'||k==='offline'||k==='live'||k==='all-modalities'){o[k==='all-modalities'?'allModalities':k]=true;}else if(k==='modality'||k==='modalities'){const v=args[++i];o.modality=o.modality||[];if(v!=null)o.modality.push(...modalityWant(v));}else o[k]=args[++i];}else o._.push(a);}return o;}
|
|
91
|
+
const line=b=>`${b.status==='verified'?'✔':b.status==='reported-broken'?'✖':'·'} ${b.id} [${b.kind}${b.works_count!=null?` +${b.works_count}/-${b.broken_count}`:''}] ${b.endpoints[0].url||b.endpoints[0].command}`;
|
|
92
|
+
export async function botsCommand(args,opts={}){
|
|
93
|
+
const out=opts.out||(s=>console.log(s));
|
|
94
|
+
const [sub='list',...rest]=args,o=flags(rest);
|
|
95
|
+
if(o.api){BASE=o.api.replace(/\/$/,'')+(/\/api\/deployable-bots$/.test(o.api)?'':'/api/deployable-bots');EXPLICIT=true;}
|
|
96
|
+
const limit=Number(o.limit||25),offset=Number(o.offset||0);
|
|
97
|
+
const print=(r,src)=>{if(o.json)return out(JSON.stringify({...r,source:src},null,2));out(`${r.matched} bots (${src})`);for(const b of r.items)out(line(b));if(r.matched>offset+r.items.length)out(`more: --offset ${offset+r.items.length}`);};
|
|
98
|
+
if(sub==='sync'){const r=await fetch(BASE+'/export',{signal:AbortSignal.timeout(60000)});if(!r.ok)throw new Error('Sync failed: HTTP '+r.status);
|
|
99
|
+
const {gzipSync}=await import('node:zlib');const text=await r.text();mkdirSync(cacheDir(),{recursive:true});writeFileSync(cacheFile(),gzipSync(text));out(`cached ${decode(gzipSync(text)).length} bots at ${cacheFile()}`);return;}
|
|
100
|
+
if(sub==='works'||sub==='broken'){const id=o._[0];if(!id)throw new Error(`Usage: kloudy bots ${sub} <id>`);
|
|
101
|
+
const r=await remote('/feedback',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id,works:sub==='works'})});out(`thanks — recorded "${sub}" for ${r.id}`);return;}
|
|
102
|
+
if(sub==='probe'){const id=o._[0];if(!id)throw new Error('Usage: kloudy bots probe <id>');
|
|
103
|
+
let bot;try{bot=(await remote('/'+encodeURIComponent(id))).bot;}catch{const l=loadLocal();bot=l&&localSearch(l.bots.filter(b=>b.id===id)).items[0];}
|
|
104
|
+
if(!bot)throw new Error('Unknown bot id.');const ep=bot.endpoints[0];if(!ep.url)throw new Error('probe supports HTTP bots; local bots run with: '+ep.command);
|
|
105
|
+
const r=await mcpProbe(ep.url);if(o.json)return out(JSON.stringify(r,null,2));
|
|
106
|
+
out(`${bot.id} -> ${ep.url}\nserver: ${r.server?.name||'?'} ${r.server?.version||''} (protocol ${r.protocol||'?'})\ntools (${r.tools.length}): ${r.tools.map(t=>t.name).join(', ')}`);return;}
|
|
107
|
+
if(sub==='get'){const id=o._[0];if(!id)throw new Error('Usage: kloudy bots get <id>');
|
|
108
|
+
let bot=null, local=loadLocal();
|
|
109
|
+
if(!o.offline)try{bot=(await remote('/'+encodeURIComponent(id))).bot;}catch{}
|
|
110
|
+
if(!bot){const hit=local&&localSearch(local.bots.filter(b=>b.id===id)).items[0];if(!hit)throw new Error('Unknown bot id (offline). Run `kloudy bots sync`.');bot={...hit,meta:hit.meta||local.bots.find(b=>b.id===id)?.meta};}
|
|
111
|
+
const raw=local&&local.bots.find(b=>b.id===id);
|
|
112
|
+
if(raw){bot.meta=bot.meta||raw.meta;bot.targets=bot.targets||raw.targets;}
|
|
113
|
+
if(bot.kind==='hf'||raw?.kind==='hf'){
|
|
114
|
+
const full=raw?{...raw,...bot,meta:bot.meta||raw.meta,url:bot.source_url||bot.url||raw.url}:bot;
|
|
115
|
+
if(o.json)return out(JSON.stringify(bot,null,2));
|
|
116
|
+
return out(formatHfGet(full));
|
|
117
|
+
}
|
|
118
|
+
return out(JSON.stringify(bot,null,2));}
|
|
119
|
+
if(sub==='modalities'){
|
|
120
|
+
const l=loadLocal();
|
|
121
|
+
if(!l)throw new Error('No local catalog. Run `kloudy bots sync`.');
|
|
122
|
+
const counts=new Map();
|
|
123
|
+
let tagged=0,untagged=0;
|
|
124
|
+
for(const b of l.bots){
|
|
125
|
+
const mods=b.modalities||[];
|
|
126
|
+
if(!mods.length){untagged++;continue;}
|
|
127
|
+
tagged++;
|
|
128
|
+
for(const m of mods)counts.set(m,(counts.get(m)||0)+1);
|
|
129
|
+
}
|
|
130
|
+
const items=[...counts.entries()].sort((a,c)=>c[1]-a[1]||a[0].localeCompare(c[0])).map(([modality,count])=>({modality,count}));
|
|
131
|
+
if(o.json)return out(JSON.stringify({total:l.bots.length,tagged,untagged,items},null,2));
|
|
132
|
+
out(`${items.length} modalities across ${tagged} tagged bots`+(untagged?` (${untagged} untagged)`:''));
|
|
133
|
+
for(const it of items)out(`${it.count}\t${it.modality}`);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if(sub!=='list'&&sub!=='search')throw new Error('Usage: kloudy bots list|search|get|sync|works|broken|modalities');
|
|
137
|
+
const q=sub==='search'?o._.join(' '):'';const query={q,status:o.status,kind:o.kind,device:o.device,modality:o.modality||o.modalities,allModalities:!!o.allModalities,limit,offset};
|
|
138
|
+
const l=loadLocal();
|
|
139
|
+
// Offline first: answer from bundled/cached data; the live API adds user-reported status when reachable and the cache is missing.
|
|
140
|
+
if(!o.offline&&(EXPLICIT||o.live))try{const p=new URLSearchParams(Object.entries({q,status:o.status,kind:o.kind,device:o.device,modality:Array.isArray(o.modality)?o.modality.join(','):o.modality||o.modalities,all_modalities:o.allModalities?'true':undefined,intent:o.intent,tier:o.tier,limit,offset}).filter(([,v])=>v!=null&&v!==''));return print(await remote('?'+p),'live: '+BASE);}catch(e){if(!l)throw e;}
|
|
141
|
+
if(l||o.offline){if(!l)throw new Error('No local catalog. Run `kloudy bots sync`.');return print(localSearch(l.bots,query),'local: '+l.source);}
|
|
142
|
+
const p=new URLSearchParams(Object.entries({q,status:o.status,kind:o.kind,device:o.device,modality:Array.isArray(o.modality)?o.modality.join(','):o.modality||o.modalities,all_modalities:o.allModalities?'true':undefined,intent:o.intent,tier:o.tier,limit,offset}).filter(([,v])=>v!=null&&v!==''));
|
|
143
|
+
return print(await remote('?'+p),'live');
|
|
144
|
+
}
|
package/src/feedback.mjs
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Anonymous stack feedback. Opt-in. No goal text, paths, env, or tokens.
|
|
2
|
+
// POST https://kloudy.ai/api/feedback and queue in the Kloudy data directory on failure.
|
|
3
|
+
import {existsSync,mkdirSync,readFileSync,writeFileSync} from 'node:fs';
|
|
4
|
+
import {join} from 'node:path';
|
|
5
|
+
import {dataDir} from './scores.mjs';
|
|
6
|
+
|
|
7
|
+
export const FEEDBACK_URL='https://kloudy.ai/api/feedback';
|
|
8
|
+
export const RESULTS=new Set(['worked','failed','partial']);
|
|
9
|
+
export const REASON_MAX=80;
|
|
10
|
+
const ALLOWED=new Set(['stackId','botId','result','reason','version','client']);
|
|
11
|
+
const DISABLED=new Set(['','off','offline','0','false']);
|
|
12
|
+
|
|
13
|
+
function packageVersion(){
|
|
14
|
+
return JSON.parse(readFileSync(new URL('../package.json',import.meta.url),'utf8')).version;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function feedbackEndpoint(explicit){
|
|
18
|
+
const value=explicit??process.env.KLOUDY_FEEDBACK_URL??FEEDBACK_URL;
|
|
19
|
+
return DISABLED.has(String(value).trim())?null:value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function sanitizeReason(reason){
|
|
23
|
+
if(reason==null||reason==='')return undefined;
|
|
24
|
+
let text=String(reason).replace(/[\u0000-\u001f\u007f]/g,' ').replace(/\s+/g,' ').trim();
|
|
25
|
+
if(!text)return undefined;
|
|
26
|
+
if(/[\\/]|~|\.\.|[A-Za-z]:[\\/]|@[A-Za-z0-9.-]+\.[A-Za-z]{2,}|\b(?:bearer|token|secret|password|api[_-]?key)\b|-----BEGIN/i.test(text))return undefined;
|
|
27
|
+
if(/[A-Fa-f0-9]{24,}/.test(text))return undefined;
|
|
28
|
+
if(/(?:sk|pk|ghp|gho|xox[baprs])-/.test(text))return undefined;
|
|
29
|
+
if(text.length>REASON_MAX)text=text.slice(0,REASON_MAX).trim();
|
|
30
|
+
return text||undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function sanitizeClient(client){
|
|
34
|
+
if(client==null||client==='')return undefined;
|
|
35
|
+
const text=String(client).trim();
|
|
36
|
+
if(!/^[A-Za-z][A-Za-z0-9._-]{0,31}$/.test(text))return undefined;
|
|
37
|
+
return text;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildFeedbackPayload(input={}){
|
|
41
|
+
const stackId=String(input.stackId||'').trim();
|
|
42
|
+
const botId=String(input.botId||'').trim();
|
|
43
|
+
const result=input.result;
|
|
44
|
+
if(!/^[a-f0-9]{64}$/.test(stackId))throw new Error('stack id must be the stackId from recommend --json');
|
|
45
|
+
if(!botId||botId.length>200||/[\s\\]/.test(botId)||botId.includes('..'))throw new Error('bot id is required');
|
|
46
|
+
if(!RESULTS.has(result))throw new Error('result must be worked, failed, or partial');
|
|
47
|
+
const payload={stackId,botId,result,version:input.version||packageVersion()};
|
|
48
|
+
const reason=sanitizeReason(input.reason===input.goal?undefined:input.reason);
|
|
49
|
+
if(reason)payload.reason=reason;
|
|
50
|
+
const client=sanitizeClient(input.client);
|
|
51
|
+
if(client)payload.client=client;
|
|
52
|
+
return payload;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function queuePath(dir=dataDir()){
|
|
56
|
+
return join(dir,'feedback-queue.json');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function safeQueued(item){
|
|
60
|
+
if(!item||typeof item!=='object'||Array.isArray(item))return false;
|
|
61
|
+
const keys=Object.keys(item);
|
|
62
|
+
if(!keys.length||!keys.every(key=>ALLOWED.has(key)))return false;
|
|
63
|
+
if(typeof item.stackId!=='string'||typeof item.botId!=='string'||typeof item.version!=='string')return false;
|
|
64
|
+
if(!RESULTS.has(item.result))return false;
|
|
65
|
+
if(item.reason!=null&&typeof item.reason!=='string')return false;
|
|
66
|
+
if(item.client!=null&&typeof item.client!=='string')return false;
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function readQueue(dir=dataDir()){
|
|
71
|
+
const file=queuePath(dir);
|
|
72
|
+
try{
|
|
73
|
+
if(!existsSync(file))return [];
|
|
74
|
+
const data=JSON.parse(readFileSync(file,'utf8'));
|
|
75
|
+
return Array.isArray(data)?data.filter(safeQueued):[];
|
|
76
|
+
}catch{
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function writeQueue(dir,items){
|
|
82
|
+
mkdirSync(dir,{recursive:true});
|
|
83
|
+
writeFileSync(queuePath(dir),JSON.stringify(items,null,2)+'\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function postOne(payload,fetchImpl,url){
|
|
87
|
+
const response=await fetchImpl(url,{
|
|
88
|
+
method:'POST',
|
|
89
|
+
redirect:'error',
|
|
90
|
+
credentials:'omit',
|
|
91
|
+
headers:{'content-type':'application/json',accept:'application/json'},
|
|
92
|
+
body:JSON.stringify(payload),
|
|
93
|
+
signal:AbortSignal.timeout(8000)
|
|
94
|
+
});
|
|
95
|
+
if(!response?.ok)throw new Error('Feedback was not accepted');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function submitFeedback({payload,dir=dataDir(),fetchImpl=fetch,url=feedbackEndpoint()}={}){
|
|
99
|
+
const queued=readQueue(dir);
|
|
100
|
+
if(payload)queued.push(payload);
|
|
101
|
+
if(!queued.length)return {sent:0,queued:0,status:'empty'};
|
|
102
|
+
if(!url){
|
|
103
|
+
writeQueue(dir,queued);
|
|
104
|
+
return {sent:0,queued:queued.length,status:'queued'};
|
|
105
|
+
}
|
|
106
|
+
const pending=[];
|
|
107
|
+
let sent=0;
|
|
108
|
+
let failed=false;
|
|
109
|
+
for(const item of queued){
|
|
110
|
+
if(failed){pending.push(item);continue;}
|
|
111
|
+
try{
|
|
112
|
+
await postOne(item,fetchImpl,url);
|
|
113
|
+
sent++;
|
|
114
|
+
}catch{
|
|
115
|
+
failed=true;
|
|
116
|
+
pending.push(item);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
writeQueue(dir,pending);
|
|
120
|
+
return {sent,queued:pending.length,status:pending.length?(sent?'partial':'queued'):'sent'};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function parseFeedbackArgs(args){
|
|
124
|
+
const o={};
|
|
125
|
+
for(let i=0;i<args.length;i++){
|
|
126
|
+
const arg=args[i];
|
|
127
|
+
if(arg==='--flush'){o.flush=true;continue;}
|
|
128
|
+
if(arg==='--json'){o.json=true;continue;}
|
|
129
|
+
if(!arg.startsWith('--'))throw new Error('Usage: kloudy feedback --stack ID --bot ID --result worked|failed|partial [--reason TEXT] [--client NAME] [--flush]');
|
|
130
|
+
const key=arg.slice(2);
|
|
131
|
+
const value=args[++i];
|
|
132
|
+
if(value==null||String(value).startsWith('--'))throw new Error('Option value required');
|
|
133
|
+
if(!['stack','bot','result','reason','client'].includes(key))throw new Error('Unknown feedback option');
|
|
134
|
+
o[key]=value;
|
|
135
|
+
}
|
|
136
|
+
return o;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function statusLine(result){
|
|
140
|
+
if(result.status==='empty')return 'feedback queue is empty';
|
|
141
|
+
if(result.status==='sent')return `sent ${result.sent} feedback report${result.sent===1?'':'s'}`;
|
|
142
|
+
if(result.status==='partial')return `sent ${result.sent} feedback report${result.sent===1?'':'s'}; ${result.queued} still queued`;
|
|
143
|
+
return `queued ${result.queued} feedback report${result.queued===1?'':'s'}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function feedbackCommand(args,opts={}){
|
|
147
|
+
const out=opts.out||(s=>console.log(s));
|
|
148
|
+
const o=parseFeedbackArgs(args);
|
|
149
|
+
const hasReport=o.stack!=null||o.bot!=null||o.result!=null||o.reason!=null||o.client!=null;
|
|
150
|
+
if(!hasReport&&!o.flush)throw new Error('Usage: kloudy feedback --stack ID --bot ID --result worked|failed|partial [--reason TEXT] [--client NAME] [--flush]');
|
|
151
|
+
if(hasReport&&(!o.stack||!o.bot||!o.result))throw new Error('Feedback requires --stack, --bot, and --result');
|
|
152
|
+
const payload=hasReport?buildFeedbackPayload({stackId:o.stack,botId:o.bot,result:o.result,reason:o.reason,client:o.client,version:opts.version}):undefined;
|
|
153
|
+
const endpoint=opts.url===undefined?feedbackEndpoint():opts.url;
|
|
154
|
+
const result=await submitFeedback({payload,dir:opts.dataDir,fetchImpl:opts.fetch,url:endpoint||null});
|
|
155
|
+
const line=statusLine(result);
|
|
156
|
+
if(o.client!=null&&payload&&!payload.client)out(line+'\nclient omitted');
|
|
157
|
+
else out(line);
|
|
158
|
+
return {...result,payload};
|
|
159
|
+
}
|
package/src/modalities.mjs
CHANGED
|
@@ -1,27 +1,28 @@
|
|
|
1
|
-
// Multi-select modality matching for `kloudy bots`.
|
|
2
|
-
// `--modality <m>` may be repeated. Default match is ANY (OR). `--all-modalities` switches to AND.
|
|
3
|
-
// `primary` / modality_primary is a sort hint only; it never filters.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
1
|
+
// Multi-select modality matching for `kloudy bots`.
|
|
2
|
+
// `--modality <m>` may be repeated. Default match is ANY (OR). `--all-modalities` switches to AND.
|
|
3
|
+
// `primary` / modality_primary is a sort hint only; it never filters.
|
|
4
|
+
// These 44 tags are the kloudy 1.0 bundled catalog modalities.
|
|
5
|
+
export const KNOWN_MODALITIES=['agent_tools','api_calls','audio','auth_encryption','browser_automation','chat_platforms','chat_ui','cli','cloud','code','code_execution','comms','data','data_analysis','database_access','document_processing','embedding','evaluation','file_system','finance','gpu_compute','image','image_generation','inference','maps','math','memory','multimodal','notifications','observability','orchestration','productivity','queues','rag','robotics','scheduling','search','security','simulation','text','translation','video','voice_generation','web'];
|
|
6
|
+
|
|
7
|
+
export function modalityWant(value){
|
|
8
|
+
if(value==null||value==='')return [];
|
|
9
|
+
const raw=Array.isArray(value)?value:[value];
|
|
10
|
+
const out=[];
|
|
11
|
+
for(const v of raw){
|
|
12
|
+
for(const part of String(v).split(',')){
|
|
13
|
+
const s=part.trim();
|
|
14
|
+
if(s&&!out.includes(s))out.push(s);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function rowMatchesModalities(have,want,{all=false}={}){
|
|
21
|
+
if(!want.length)return true;
|
|
22
|
+
const set=Array.isArray(have)?have:[];
|
|
23
|
+
return all?want.every(m=>set.includes(m)):want.some(m=>set.includes(m));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function primarySortHint(primary,want){
|
|
27
|
+
return primary&&want.includes(primary)?1:0;
|
|
28
|
+
}
|