kloudy 0.4.7 → 1.0.0
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/LICENSE +202 -202
- package/MCP-DEFAULT.md +12 -12
- package/README.md +317 -313
- package/bin/entry.mjs +23 -22
- package/bin/kloudy.mjs +74 -71
- package/package.json +3 -2
- package/schemas/SDF-LICENSE.txt +21 -21
- package/schemas/kloudy.deployable-bot.schema.json +80 -0
- package/schemas/sdf-document-0.2.schema.json +411 -411
- package/schemas/sdf-webpage-v1.schema.json +176 -176
- package/src/activity.mjs +23 -23
- package/src/approval-ui.mjs +2 -2
- package/src/approval.mjs +25 -25
- package/src/autoupdate.mjs +2 -2
- package/src/bots.mjs +144 -0
- package/src/chooser-link.mjs +17 -17
- package/src/client.mjs +96 -96
- package/src/connect-card.mjs +32 -32
- package/src/connect-ui.mjs +3 -3
- package/src/connection.mjs +25 -25
- package/src/conversation.mjs +53 -53
- package/src/deployable-bots.json.gz +0 -0
- package/src/editor-install.mjs +79 -79
- package/src/host-interface.mjs +10 -10
- package/src/hosted.mjs +42 -42
- package/src/httpk.mjs +66 -66
- package/src/install.mjs +35 -35
- package/src/introduce.mjs +33 -33
- package/src/mcp.mjs +74 -74
- package/src/modalities.mjs +27 -0
- package/src/models.mjs +55 -55
- package/src/preferences-card.mjs +21 -21
- package/src/preferences-ui.mjs +2 -2
- package/src/product-actions.mjs +22 -22
- package/src/public.mjs +41 -41
- package/src/query.mjs +28 -28
- package/src/remote.mjs +9 -2
- package/src/replies.mjs +7 -7
- package/src/reply-templates.json +12 -12
- package/src/sample-actions.mjs +31 -31
- package/src/sample-card.mjs +11 -11
- package/src/sample-cards.mjs +47 -47
- package/src/sample-ui.mjs +2 -2
- package/src/sdf-budget.mjs +23 -23
- package/src/sdf.mjs +12 -12
- package/src/starter.mjs +43 -43
- package/src/status-indicator.css +5 -5
- package/src/strings.mjs +51 -51
- package/src/terminal-status.mjs +6 -6
- package/src/website-return.mjs +18 -0
- package/src/welcome.mjs +8 -8
- package/src/wrap.mjs +58 -58
package/src/approval.mjs
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
|
-
import {randomUUID} from 'node:crypto';
|
|
2
|
-
export const approvalResource='ui://kloudy/approval.html';
|
|
3
|
-
export const approvalTool={name:'kloudy_approval',description:'Submit the exact pending decision from the Kloudy approval view.',inputSchema:{type:'object',properties:{ticket:{type:'string'},decision:{type:'string',enum:['approve','cancel']}},required:['ticket','decision'],additionalProperties:false},_meta:{ui:{resourceUri:approvalResource,visibility:['app']}}};
|
|
4
|
-
export class Approvals {
|
|
5
|
-
constructor({now=()=>Date.now()}={}){this.now=now;this.pending=new Map();}
|
|
6
|
-
present(result,selectionId,connection){
|
|
7
|
-
if(result?.state!=='awaiting_approval')return {content:[{type:'text',text:JSON.stringify(result)}],structuredContent:result};
|
|
8
|
-
const a=result.approval,expires=Number(a?.expires_at)*1000,risk=a?.risk_tier??a?.risk;
|
|
9
|
-
// Do not fabricate a token or let incomplete backend state create an approval.
|
|
10
|
-
if(!a||typeof a.token!=='string'||!a.token||a.token.length>512||typeof risk!=='string'||!risk.trim()||risk.length>64||!(typeof a.summary==='string'||a.summary&&typeof a.summary==='object')||!selectionId||!Number.isFinite(expires)||expires<=this.now())return {isError:true,content:[{type:'text',text:'The exact approval is missing or expired. Refresh the engine request.'}]};
|
|
11
|
-
for(const [id,p] of this.pending)if(p.expires<=this.now())this.pending.delete(id);
|
|
12
|
-
if(this.pending.size>=32)throw Error('Too many pending approvals');
|
|
13
|
-
const ticket=randomUUID();this.pending.set(ticket,{selectionId,token:a.token,expires,connection});
|
|
14
|
-
const summary={state:result.state,summary:a.summary,risk_tier:a.risk_tier??a.risk,expires_at:a.expires_at,notice:'Approve or cancel using the inline Kloudy view. Hosts without MCP Apps must provide their own consent UI; no approval was submitted.'};
|
|
15
|
-
return {content:[{type:'text',text:JSON.stringify(summary)}],structuredContent:summary,_meta:{kloudyApproval:{...summary,ticket},ui:{resourceUri:approvalResource}}};
|
|
16
|
-
}
|
|
17
|
-
async decide(input,useClient){
|
|
18
|
-
if(!input||Object.keys(input).some(k=>!['ticket','decision'].includes(k))||!['approve','cancel'].includes(input.decision))throw Error('Invalid approval decision');
|
|
19
|
-
const pending=this.pending.get(input.ticket);if(!pending||pending.expires<=this.now()){this.pending.delete(input.ticket);throw Error('Approval missing or expired');}
|
|
20
|
-
// Consume before dispatch, including an ambiguous network failure. Never retry
|
|
21
|
-
// a mutation silently; the host must inspect the engine receipt afterwards.
|
|
22
|
-
this.pending.delete(input.ticket);
|
|
23
|
-
return (pending.connection||useClient)(client=>input.decision==='approve'?client.confirm({selectionId:pending.selectionId,token:pending.token,decision:'approve',source:'tap'}):client.rpc('kloudy/cancel',{selection_id:pending.selectionId}));
|
|
24
|
-
}
|
|
25
|
-
}
|
|
1
|
+
import {randomUUID} from 'node:crypto';
|
|
2
|
+
export const approvalResource='ui://kloudy/approval.html';
|
|
3
|
+
export const approvalTool={name:'kloudy_approval',description:'Submit the exact pending decision from the Kloudy approval view.',inputSchema:{type:'object',properties:{ticket:{type:'string'},decision:{type:'string',enum:['approve','cancel']}},required:['ticket','decision'],additionalProperties:false},_meta:{ui:{resourceUri:approvalResource,visibility:['app']}}};
|
|
4
|
+
export class Approvals {
|
|
5
|
+
constructor({now=()=>Date.now()}={}){this.now=now;this.pending=new Map();}
|
|
6
|
+
present(result,selectionId,connection){
|
|
7
|
+
if(result?.state!=='awaiting_approval')return {content:[{type:'text',text:JSON.stringify(result)}],structuredContent:result};
|
|
8
|
+
const a=result.approval,expires=Number(a?.expires_at)*1000,risk=a?.risk_tier??a?.risk;
|
|
9
|
+
// Do not fabricate a token or let incomplete backend state create an approval.
|
|
10
|
+
if(!a||typeof a.token!=='string'||!a.token||a.token.length>512||typeof risk!=='string'||!risk.trim()||risk.length>64||!(typeof a.summary==='string'||a.summary&&typeof a.summary==='object')||!selectionId||!Number.isFinite(expires)||expires<=this.now())return {isError:true,content:[{type:'text',text:'The exact approval is missing or expired. Refresh the engine request.'}]};
|
|
11
|
+
for(const [id,p] of this.pending)if(p.expires<=this.now())this.pending.delete(id);
|
|
12
|
+
if(this.pending.size>=32)throw Error('Too many pending approvals');
|
|
13
|
+
const ticket=randomUUID();this.pending.set(ticket,{selectionId,token:a.token,expires,connection});
|
|
14
|
+
const summary={state:result.state,summary:a.summary,risk_tier:a.risk_tier??a.risk,expires_at:a.expires_at,notice:'Approve or cancel using the inline Kloudy view. Hosts without MCP Apps must provide their own consent UI; no approval was submitted.'};
|
|
15
|
+
return {content:[{type:'text',text:JSON.stringify(summary)}],structuredContent:summary,_meta:{kloudyApproval:{...summary,ticket},ui:{resourceUri:approvalResource}}};
|
|
16
|
+
}
|
|
17
|
+
async decide(input,useClient){
|
|
18
|
+
if(!input||Object.keys(input).some(k=>!['ticket','decision'].includes(k))||!['approve','cancel'].includes(input.decision))throw Error('Invalid approval decision');
|
|
19
|
+
const pending=this.pending.get(input.ticket);if(!pending||pending.expires<=this.now()){this.pending.delete(input.ticket);throw Error('Approval missing or expired');}
|
|
20
|
+
// Consume before dispatch, including an ambiguous network failure. Never retry
|
|
21
|
+
// a mutation silently; the host must inspect the engine receipt afterwards.
|
|
22
|
+
this.pending.delete(input.ticket);
|
|
23
|
+
return (pending.connection||useClient)(client=>input.decision==='approve'?client.confirm({selectionId:pending.selectionId,token:pending.token,decision:'approve',source:'tap'}):client.rpc('kloudy/cancel',{selection_id:pending.selectionId}));
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/autoupdate.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
// Invalid and unset rollout modes fail closed. Configuration never enables itself.
|
|
2
|
-
export const autoupdateEnabled=mode=>mode==='meta'||mode==='notify';
|
|
1
|
+
// Invalid and unset rollout modes fail closed. Configuration never enables itself.
|
|
2
|
+
export const autoupdateEnabled=mode=>mode==='meta'||mode==='notify';
|
package/src/bots.mjs
ADDED
|
@@ -0,0 +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
|
+
}
|
package/src/chooser-link.mjs
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
// Public presentation adapter. URLs contain public template locators only, never
|
|
2
|
-
// a connection credential, authorization token, goal text or identity claim.
|
|
3
|
-
export const CHOOSER_URL='https://kloudy.ai/api/customer/visual-chooser/start';
|
|
4
|
-
export const pendingChooserTool={name:'show_pending_chooser_link',description:'Show the pending Kloudy card and its HTTPS website fallback again. The website verifies account sign-in; the link is not authorization. This does not run a tool, model, installation or payment.',inputSchema:{type:'object',properties:{},additionalProperties:false},annotations:{readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false}};
|
|
5
|
-
export function chooserResponse(result,{apps=false,goal}={}){
|
|
6
|
-
if(!result||typeof result!=='object'||Array.isArray(result)||result.isError)return result;
|
|
7
|
-
const data=result.structuredContent;if(!data||typeof data!=='object'||Array.isArray(data))return result;
|
|
8
|
-
const option=typeof data.selectedOptionId==='string'&&/^[a-z0-9-]{1,80}$/.test(data.selectedOptionId)?data.selectedOptionId:null;
|
|
9
|
-
const set=typeof data.choiceSetId==='string'&&/^visual-[a-f0-9]{1,64}$/.test(data.choiceSetId)?data.choiceSetId:null;
|
|
10
|
-
const url=option&&set?CHOOSER_URL+'#choice='+encodeURIComponent(option)+'&set='+encodeURIComponent(set):CHOOSER_URL;
|
|
11
|
-
const chooser={url,linkIsAuthorization:false,identityFromEmail:false,accountBindingVerified:false,returnMethod:'existing-authenticated-website-project-manifest',automaticIdeReturnVerified:false,...(set?{publicChoiceSetId:set}:{}),...(option?{publicOptionId:option}:{}),...(goal?{request:goal}:{})};
|
|
12
|
-
const text='Website chooser: '+url+'\nOpen this HTTPS link if your host does not show the card. Complete account sign-in/2FA on the website before opening private project choices. Email is a lookup hint, not proof. The host controls card rendering, clickable links and browser launch. Account-bound automatic return is not established by this public template locator.';
|
|
13
|
-
return {...result,content:[...(Array.isArray(result.content)?result.content:[]),{type:'text',text}],structuredContent:{...data,chooser},...(apps?{_meta:{...result._meta,ui:{...result._meta?.ui,resourceUri:'ui://widget/kloudy-visual-setup-v1.html'}}}:{})};
|
|
14
|
-
}
|
|
15
|
-
export function pendingChooserResponse(pending){
|
|
16
|
-
return pending?structuredClone(pending):{content:[{type:'text',text:'No pending chooser is retained in this MCP process. Open '+CHOOSER_URL+' and sign in to recover your existing project. This link is not a bearer token or authorization.'}],structuredContent:{pending:false,chooser:{url:CHOOSER_URL,linkIsAuthorization:false,accountBindingVerified:false}}};
|
|
17
|
-
}
|
|
1
|
+
// Public presentation adapter. URLs contain public template locators only, never
|
|
2
|
+
// a connection credential, authorization token, goal text or identity claim.
|
|
3
|
+
export const CHOOSER_URL='https://kloudy.ai/api/customer/visual-chooser/start';
|
|
4
|
+
export const pendingChooserTool={name:'show_pending_chooser_link',description:'Show the pending Kloudy card and its HTTPS website fallback again. The website verifies account sign-in; the link is not authorization. This does not run a tool, model, installation or payment.',inputSchema:{type:'object',properties:{},additionalProperties:false},annotations:{readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false}};
|
|
5
|
+
export function chooserResponse(result,{apps=false,goal}={}){
|
|
6
|
+
if(!result||typeof result!=='object'||Array.isArray(result)||result.isError)return result;
|
|
7
|
+
const data=result.structuredContent;if(!data||typeof data!=='object'||Array.isArray(data))return result;
|
|
8
|
+
const option=typeof data.selectedOptionId==='string'&&/^[a-z0-9-]{1,80}$/.test(data.selectedOptionId)?data.selectedOptionId:null;
|
|
9
|
+
const set=typeof data.choiceSetId==='string'&&/^visual-[a-f0-9]{1,64}$/.test(data.choiceSetId)?data.choiceSetId:null;
|
|
10
|
+
const url=option&&set?CHOOSER_URL+'#choice='+encodeURIComponent(option)+'&set='+encodeURIComponent(set):CHOOSER_URL;
|
|
11
|
+
const chooser={url,linkIsAuthorization:false,identityFromEmail:false,accountBindingVerified:false,returnMethod:'existing-authenticated-website-project-manifest',automaticIdeReturnVerified:false,...(set?{publicChoiceSetId:set}:{}),...(option?{publicOptionId:option}:{}),...(goal?{request:goal}:{})};
|
|
12
|
+
const text='Website chooser: '+url+'\nOpen this HTTPS link if your host does not show the card. Complete account sign-in/2FA on the website before opening private project choices. Email is a lookup hint, not proof. The host controls card rendering, clickable links and browser launch. Account-bound automatic return is not established by this public template locator.';
|
|
13
|
+
return {...result,content:[...(Array.isArray(result.content)?result.content:[]),{type:'text',text}],structuredContent:{...data,chooser},...(apps?{_meta:{...result._meta,ui:{...result._meta?.ui,resourceUri:'ui://widget/kloudy-visual-setup-v1.html'}}}:{})};
|
|
14
|
+
}
|
|
15
|
+
export function pendingChooserResponse(pending){
|
|
16
|
+
return pending?structuredClone(pending):{content:[{type:'text',text:'No pending chooser is retained in this MCP process. Open '+CHOOSER_URL+' and sign in to recover your existing project. This link is not a bearer token or authorization.'}],structuredContent:{pending:false,chooser:{url:CHOOSER_URL,linkIsAuthorization:false,accountBindingVerified:false}}};
|
|
17
|
+
}
|
package/src/client.mjs
CHANGED
|
@@ -1,96 +1,96 @@
|
|
|
1
|
-
import {randomUUID} from 'node:crypto';
|
|
2
|
-
|
|
3
|
-
export class KloudyError extends Error {
|
|
4
|
-
constructor(code,message){super(message);this.code=code;}
|
|
5
|
-
}
|
|
6
|
-
const fail=(code,message)=>{throw new KloudyError(code,message);};
|
|
7
|
-
const object=x=>x&&typeof x==='object'&&!Array.isArray(x);
|
|
8
|
-
const id=x=>{if(typeof x!=='string'||!/^[-\w]{1,128}$/.test(x))fail('invalid_id','Invalid engine identifier.');return encodeURIComponent(x);};
|
|
9
|
-
const terminal=x=>['completed','failed','cancelled'].includes(x);
|
|
10
|
-
export function httpTransport({origin,token,fetcher=fetch}){
|
|
11
|
-
const url=new URL(origin);
|
|
12
|
-
if(url.username||url.password||url.pathname!=='/'||url.search||url.hash||!(url.protocol==='https:'||(url.protocol==='http:'&&['127.0.0.1','localhost','[::1]'].includes(url.hostname))))fail('invalid_origin','Use an HTTPS or loopback engine origin.');
|
|
13
|
-
if(typeof token!=='string'||!token||/[\r\n]/.test(token))fail('registration_required','Ask for Kloudy and register before searching for tools.');
|
|
14
|
-
return async(method,path,body)=>{
|
|
15
|
-
let response;
|
|
16
|
-
try{response=await fetcher(url.origin+'/v1/integrations'+path,{method,redirect:'error',signal:AbortSignal.timeout(15000),headers:{authorization:'Bearer '+token,...(body?{'content-type':'application/json'}:{})},...(body?{body:JSON.stringify(body)}:{})});}
|
|
17
|
-
catch{fail('engine_unavailable','The engine is unavailable. No operation was retried.');}
|
|
18
|
-
if(!response.ok)fail([401,403].includes(response.status)?'registration_required':response.status===409?'stale_state':response.status===429?'rate_limited':'engine_unavailable','The engine did not accept this request. Reconnect, refresh or finish pending work before retrying.');
|
|
19
|
-
let size=0;const chunks=[];
|
|
20
|
-
for await(const chunk of response.body){size+=chunk.length;if(size>524288)fail('invalid_response','The engine response exceeded its limit.');chunks.push(chunk);}
|
|
21
|
-
try{return JSON.parse(Buffer.concat(chunks).toString());}catch{fail('invalid_response','Invalid engine response.');}
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
export function projectToolbox(receipt){
|
|
25
|
-
const n=receipt?.narrowing;
|
|
26
|
-
if(!object(n)||!Array.isArray(n.shortlist)||n.shortlist.length>3)fail('invalid_response','Expected a bounded engine shortlist.');
|
|
27
|
-
const box=n.toolbox,fn=box?.tool?.function;
|
|
28
|
-
let tools=[];
|
|
29
|
-
if(box?.status==='ready'){
|
|
30
|
-
if(!fn||typeof fn.name!=='string'||!/^[-\w.]{1,100}$/.test(fn.name)||typeof fn.description!=='string'||fn.description.length>2000||fn.parameters?.type!=='object'||!object(fn.parameters.properties)||!n.shortlist.some(x=>x.index===box.candidate_index)||n.measurements?.definitions_emitted!==1)fail('invalid_response','Expected exactly one authorized tool definition.');
|
|
31
|
-
const properties={};
|
|
32
|
-
for(const [name,field] of Object.entries(fn.parameters.properties)){
|
|
33
|
-
if(!/^\w{1,80}$/.test(name)||['__proto__','constructor','prototype'].includes(name)||field?.type!=='string')fail('unsupported_schema','This client supports the current engine string-input tools.');
|
|
34
|
-
properties[name]={type:'string'};
|
|
35
|
-
for(const key of ['minLength','maxLength'])if(field[key]!==undefined){if(!Number.isSafeInteger(field[key])||field[key]<0||field[key]>10000)fail('invalid_response','Invalid tool input bound.');properties[name][key]=field[key];}
|
|
36
|
-
}
|
|
37
|
-
if(Object.keys(properties).length>16)fail('invalid_response','Too many tool inputs.');
|
|
38
|
-
const required=fn.parameters.required??[];
|
|
39
|
-
if(!Array.isArray(required)||required.some(x=>!Object.hasOwn(properties,x)))fail('invalid_response','Invalid required inputs.');
|
|
40
|
-
tools=[{name:fn.name,description:fn.description,inputSchema:{type:'object',properties,required,additionalProperties:false}}];
|
|
41
|
-
}
|
|
42
|
-
return {status:tools.length?'ready':'empty',tools,reason:tools.length?'One engine-selected tool.':String(box?.reason||'no_match').slice(0,100),measurements:{candidatesRead:n.measurements?.candidates_read??null,definitionsEmitted:tools.length}};
|
|
43
|
-
}
|
|
44
|
-
export class KloudyClient {
|
|
45
|
-
constructor({transport,state={},save=async()=>{},mode='autonomous'}){if(typeof transport!=='function')fail('registration_required','Registration and a scoped engine connection are required.');if(!['autonomous','confirm'].includes(mode))fail('invalid_mode','Choose autonomous or confirm.');this.mode=mode;this.transport=transport;this.state=state;this.save=save;this.busy=false;}
|
|
46
|
-
async exclusive(work){if(this.busy)fail('busy','Wait for the current operation.');this.busy=true;try{return await work();}finally{this.busy=false;}}
|
|
47
|
-
async request(method,path,body,limit=4096){if(body&&Buffer.byteLength(JSON.stringify(body))>limit)fail('too_large','Request exceeds the engine limit.');return this.transport(method,path,body);}
|
|
48
|
-
async session(){
|
|
49
|
-
if(!this.state.sessionId){const opened=await this.request('POST','/sessions',{idempotency_key:randomUUID(),front_door:'kloudy'});id(opened.session_id);this.state.sessionId=opened.session_id;await this.save(this.state);}
|
|
50
|
-
let current=await this.request('GET','/sessions/'+id(this.state.sessionId));
|
|
51
|
-
if(current.state==='closed')fail('session_closed','This session is closed. Register or reconnect a fresh session.');
|
|
52
|
-
if(!Number.isSafeInteger(current.revision)||current.revision<0)fail('invalid_response','Invalid engine session revision.');
|
|
53
|
-
if(current.interaction_mode!==this.mode){
|
|
54
|
-
await this.request('POST',`/sessions/${id(this.state.sessionId)}/mode`,{idempotency_key:randomUUID(),expected_revision:current.revision,mode:this.mode,source:'typed'});
|
|
55
|
-
current=await this.request('GET','/sessions/'+id(this.state.sessionId));
|
|
56
|
-
if(current.interaction_mode!==this.mode||!Number.isSafeInteger(current.revision)||current.revision<0)fail('mode_not_supported','The engine has not acknowledged this surface’s approval mode. No tool was invoked.');
|
|
57
|
-
}
|
|
58
|
-
return current;
|
|
59
|
-
}
|
|
60
|
-
async ask(goal,{candidates=[],lane='non_medical'}={}){return this.exclusive(async()=>{
|
|
61
|
-
if(typeof goal!=='string'||!goal.trim()||[...goal].length>500||!['medical','non_medical'].includes(lane)||!Array.isArray(candidates)||candidates.length>16)fail('invalid_input','Use a goal of 1–500 characters and at most 16 public metadata candidates.');
|
|
62
|
-
const current=await this.session();
|
|
63
|
-
if(current.latest_request&&!terminal(current.latest_request.state))fail('pending_request','Finish or cancel the current action first.');
|
|
64
|
-
this.state.selection=null;await this.save(this.state);
|
|
65
|
-
if(!candidates.length){
|
|
66
|
-
const receipt=await this.request('POST',`/sessions/${id(this.state.sessionId)}/route`,{idempotency_key:randomUUID(),expected_revision:current.revision,query:goal,lane,compare:false,profile:null,url:null,language:'en'});
|
|
67
|
-
// An empty Toolbox never widens into a whole-server catalog.
|
|
68
|
-
const result={schema:'kloudy.toolbox/1',sessionId:this.state.sessionId,toolbox:{status:'empty',tools:[],reason:'discovery_feed_required'},fallback:{kind:'source_card',available:false}};
|
|
69
|
-
const destinations=receipt.routing?.view?.destinations;
|
|
70
|
-
if(Array.isArray(destinations)&&destinations[0]){const d=destinations[0];try{const url=new URL(d.url);if(url.protocol==='https:'&&!url.username&&!url.password)result.fallback={kind:'source_card',available:true,title:String(d.title||'Source').slice(0,200),url:url.href};}catch{}}
|
|
71
|
-
return result;
|
|
72
|
-
}
|
|
73
|
-
for(const c of candidates){if(!object(c)||Object.keys(c).some(k=>!['source_url','discovered_via','resource'].includes(k))||!['seo','geo'].includes(c.discovered_via)||!object(c.resource))fail('invalid_input','Use public KLDY/FHIR discovery metadata.');const u=new URL(c.source_url);if(u.protocol!=='https:'||u.username||u.password||u.search||u.hash)fail('invalid_input','Metadata sources must be credential-free HTTPS identifiers.');}
|
|
74
|
-
const key=randomUUID();
|
|
75
|
-
const receipt=await this.request('POST',`/sessions/${id(this.state.sessionId)}/narrow`,{idempotency_key:key,expected_revision:current.revision,goal,lane,candidates},65536);
|
|
76
|
-
const toolbox=projectToolbox(receipt);
|
|
77
|
-
this.state.selection=toolbox.tools.length?{key,tool:toolbox.tools[0]}:null;await this.save(this.state);
|
|
78
|
-
return {schema:'kloudy.toolbox/1',sessionId:this.state.sessionId,toolbox};
|
|
79
|
-
});}
|
|
80
|
-
async call(name,inputs){return this.exclusive(async()=>{
|
|
81
|
-
const selected=this.state.selection;if(!selected||selected.tool.name!==name)fail('not_selected','Ask for Kloudy to select this tool first.');
|
|
82
|
-
const schema=selected.tool.inputSchema;if(!object(inputs)||Object.keys(inputs).some(k=>!Object.hasOwn(schema.properties,k))||schema.required.some(k=>!Object.hasOwn(inputs,k)))fail('invalid_input','Use only the selected tool inputs.');
|
|
83
|
-
for(const [k,v] of Object.entries(inputs)){const f=schema.properties[k];if(typeof v!=='string'||[...v].length<(f.minLength||0)||[...v].length>(f.maxLength??10000))fail('invalid_input','Invalid tool input.');}
|
|
84
|
-
const current=await this.session();if(current.latest_request&&!terminal(current.latest_request.state))fail('pending_request','Finish the pending action first.');
|
|
85
|
-
const receipt=await this.request('POST',`/sessions/${id(this.state.sessionId)}/escalate`,{idempotency_key:randomUUID(),expected_revision:current.revision,narrowing_key:selected.key,inputs});
|
|
86
|
-
id(receipt.request_id);this.state.requestId=receipt.request_id;this.state.selection=null;await this.save(this.state);return this.status();
|
|
87
|
-
});}
|
|
88
|
-
async status(){if(!this.state.requestId)return {state:'idle',mode:this.mode,sessionId:this.state.sessionId||null};const receipt=await this.request('GET','/requests/'+id(this.state.requestId));return {...receipt,...(this.mode==='autonomous'&&receipt.state==='awaiting_approval'?{mode:'autonomous',blocked:{code:'engine_autonomy_not_supported',message:'The engine still requires a separate approval for this action. No approval was fabricated. Cancel or check the engine deployment.'}}:{})};}
|
|
89
|
-
async decide(decision,revision){return this.exclusive(async()=>{
|
|
90
|
-
if(!this.state.requestId)fail('no_request','No action is pending in this session.');
|
|
91
|
-
if(decision==='approve'){if(this.mode!=='confirm')fail('autonomous_only','This client does not offer a second approval gate.');if(typeof revision!=='string'||!revision||revision.length>256)fail('approval_required','Read the exact action and supply its approval revision.');await this.request('POST',`/requests/${id(this.state.requestId)}/approve`,{revision,decision:'approve'});}
|
|
92
|
-
else if(decision==='cancel')await this.request('POST',`/requests/${id(this.state.requestId)}/cancel`,{});
|
|
93
|
-
else fail('invalid_input','Choose approve or cancel.');
|
|
94
|
-
return this.status();
|
|
95
|
-
});}
|
|
96
|
-
}
|
|
1
|
+
import {randomUUID} from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export class KloudyError extends Error {
|
|
4
|
+
constructor(code,message){super(message);this.code=code;}
|
|
5
|
+
}
|
|
6
|
+
const fail=(code,message)=>{throw new KloudyError(code,message);};
|
|
7
|
+
const object=x=>x&&typeof x==='object'&&!Array.isArray(x);
|
|
8
|
+
const id=x=>{if(typeof x!=='string'||!/^[-\w]{1,128}$/.test(x))fail('invalid_id','Invalid engine identifier.');return encodeURIComponent(x);};
|
|
9
|
+
const terminal=x=>['completed','failed','cancelled'].includes(x);
|
|
10
|
+
export function httpTransport({origin,token,fetcher=fetch}){
|
|
11
|
+
const url=new URL(origin);
|
|
12
|
+
if(url.username||url.password||url.pathname!=='/'||url.search||url.hash||!(url.protocol==='https:'||(url.protocol==='http:'&&['127.0.0.1','localhost','[::1]'].includes(url.hostname))))fail('invalid_origin','Use an HTTPS or loopback engine origin.');
|
|
13
|
+
if(typeof token!=='string'||!token||/[\r\n]/.test(token))fail('registration_required','Ask for Kloudy and register before searching for tools.');
|
|
14
|
+
return async(method,path,body)=>{
|
|
15
|
+
let response;
|
|
16
|
+
try{response=await fetcher(url.origin+'/v1/integrations'+path,{method,redirect:'error',signal:AbortSignal.timeout(15000),headers:{authorization:'Bearer '+token,...(body?{'content-type':'application/json'}:{})},...(body?{body:JSON.stringify(body)}:{})});}
|
|
17
|
+
catch{fail('engine_unavailable','The engine is unavailable. No operation was retried.');}
|
|
18
|
+
if(!response.ok)fail([401,403].includes(response.status)?'registration_required':response.status===409?'stale_state':response.status===429?'rate_limited':'engine_unavailable','The engine did not accept this request. Reconnect, refresh or finish pending work before retrying.');
|
|
19
|
+
let size=0;const chunks=[];
|
|
20
|
+
for await(const chunk of response.body){size+=chunk.length;if(size>524288)fail('invalid_response','The engine response exceeded its limit.');chunks.push(chunk);}
|
|
21
|
+
try{return JSON.parse(Buffer.concat(chunks).toString());}catch{fail('invalid_response','Invalid engine response.');}
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function projectToolbox(receipt){
|
|
25
|
+
const n=receipt?.narrowing;
|
|
26
|
+
if(!object(n)||!Array.isArray(n.shortlist)||n.shortlist.length>3)fail('invalid_response','Expected a bounded engine shortlist.');
|
|
27
|
+
const box=n.toolbox,fn=box?.tool?.function;
|
|
28
|
+
let tools=[];
|
|
29
|
+
if(box?.status==='ready'){
|
|
30
|
+
if(!fn||typeof fn.name!=='string'||!/^[-\w.]{1,100}$/.test(fn.name)||typeof fn.description!=='string'||fn.description.length>2000||fn.parameters?.type!=='object'||!object(fn.parameters.properties)||!n.shortlist.some(x=>x.index===box.candidate_index)||n.measurements?.definitions_emitted!==1)fail('invalid_response','Expected exactly one authorized tool definition.');
|
|
31
|
+
const properties={};
|
|
32
|
+
for(const [name,field] of Object.entries(fn.parameters.properties)){
|
|
33
|
+
if(!/^\w{1,80}$/.test(name)||['__proto__','constructor','prototype'].includes(name)||field?.type!=='string')fail('unsupported_schema','This client supports the current engine string-input tools.');
|
|
34
|
+
properties[name]={type:'string'};
|
|
35
|
+
for(const key of ['minLength','maxLength'])if(field[key]!==undefined){if(!Number.isSafeInteger(field[key])||field[key]<0||field[key]>10000)fail('invalid_response','Invalid tool input bound.');properties[name][key]=field[key];}
|
|
36
|
+
}
|
|
37
|
+
if(Object.keys(properties).length>16)fail('invalid_response','Too many tool inputs.');
|
|
38
|
+
const required=fn.parameters.required??[];
|
|
39
|
+
if(!Array.isArray(required)||required.some(x=>!Object.hasOwn(properties,x)))fail('invalid_response','Invalid required inputs.');
|
|
40
|
+
tools=[{name:fn.name,description:fn.description,inputSchema:{type:'object',properties,required,additionalProperties:false}}];
|
|
41
|
+
}
|
|
42
|
+
return {status:tools.length?'ready':'empty',tools,reason:tools.length?'One engine-selected tool.':String(box?.reason||'no_match').slice(0,100),measurements:{candidatesRead:n.measurements?.candidates_read??null,definitionsEmitted:tools.length}};
|
|
43
|
+
}
|
|
44
|
+
export class KloudyClient {
|
|
45
|
+
constructor({transport,state={},save=async()=>{},mode='autonomous'}){if(typeof transport!=='function')fail('registration_required','Registration and a scoped engine connection are required.');if(!['autonomous','confirm'].includes(mode))fail('invalid_mode','Choose autonomous or confirm.');this.mode=mode;this.transport=transport;this.state=state;this.save=save;this.busy=false;}
|
|
46
|
+
async exclusive(work){if(this.busy)fail('busy','Wait for the current operation.');this.busy=true;try{return await work();}finally{this.busy=false;}}
|
|
47
|
+
async request(method,path,body,limit=4096){if(body&&Buffer.byteLength(JSON.stringify(body))>limit)fail('too_large','Request exceeds the engine limit.');return this.transport(method,path,body);}
|
|
48
|
+
async session(){
|
|
49
|
+
if(!this.state.sessionId){const opened=await this.request('POST','/sessions',{idempotency_key:randomUUID(),front_door:'kloudy'});id(opened.session_id);this.state.sessionId=opened.session_id;await this.save(this.state);}
|
|
50
|
+
let current=await this.request('GET','/sessions/'+id(this.state.sessionId));
|
|
51
|
+
if(current.state==='closed')fail('session_closed','This session is closed. Register or reconnect a fresh session.');
|
|
52
|
+
if(!Number.isSafeInteger(current.revision)||current.revision<0)fail('invalid_response','Invalid engine session revision.');
|
|
53
|
+
if(current.interaction_mode!==this.mode){
|
|
54
|
+
await this.request('POST',`/sessions/${id(this.state.sessionId)}/mode`,{idempotency_key:randomUUID(),expected_revision:current.revision,mode:this.mode,source:'typed'});
|
|
55
|
+
current=await this.request('GET','/sessions/'+id(this.state.sessionId));
|
|
56
|
+
if(current.interaction_mode!==this.mode||!Number.isSafeInteger(current.revision)||current.revision<0)fail('mode_not_supported','The engine has not acknowledged this surface’s approval mode. No tool was invoked.');
|
|
57
|
+
}
|
|
58
|
+
return current;
|
|
59
|
+
}
|
|
60
|
+
async ask(goal,{candidates=[],lane='non_medical'}={}){return this.exclusive(async()=>{
|
|
61
|
+
if(typeof goal!=='string'||!goal.trim()||[...goal].length>500||!['medical','non_medical'].includes(lane)||!Array.isArray(candidates)||candidates.length>16)fail('invalid_input','Use a goal of 1–500 characters and at most 16 public metadata candidates.');
|
|
62
|
+
const current=await this.session();
|
|
63
|
+
if(current.latest_request&&!terminal(current.latest_request.state))fail('pending_request','Finish or cancel the current action first.');
|
|
64
|
+
this.state.selection=null;await this.save(this.state);
|
|
65
|
+
if(!candidates.length){
|
|
66
|
+
const receipt=await this.request('POST',`/sessions/${id(this.state.sessionId)}/route`,{idempotency_key:randomUUID(),expected_revision:current.revision,query:goal,lane,compare:false,profile:null,url:null,language:'en'});
|
|
67
|
+
// An empty Toolbox never widens into a whole-server catalog.
|
|
68
|
+
const result={schema:'kloudy.toolbox/1',sessionId:this.state.sessionId,toolbox:{status:'empty',tools:[],reason:'discovery_feed_required'},fallback:{kind:'source_card',available:false}};
|
|
69
|
+
const destinations=receipt.routing?.view?.destinations;
|
|
70
|
+
if(Array.isArray(destinations)&&destinations[0]){const d=destinations[0];try{const url=new URL(d.url);if(url.protocol==='https:'&&!url.username&&!url.password)result.fallback={kind:'source_card',available:true,title:String(d.title||'Source').slice(0,200),url:url.href};}catch{}}
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
for(const c of candidates){if(!object(c)||Object.keys(c).some(k=>!['source_url','discovered_via','resource'].includes(k))||!['seo','geo'].includes(c.discovered_via)||!object(c.resource))fail('invalid_input','Use public KLDY/FHIR discovery metadata.');const u=new URL(c.source_url);if(u.protocol!=='https:'||u.username||u.password||u.search||u.hash)fail('invalid_input','Metadata sources must be credential-free HTTPS identifiers.');}
|
|
74
|
+
const key=randomUUID();
|
|
75
|
+
const receipt=await this.request('POST',`/sessions/${id(this.state.sessionId)}/narrow`,{idempotency_key:key,expected_revision:current.revision,goal,lane,candidates},65536);
|
|
76
|
+
const toolbox=projectToolbox(receipt);
|
|
77
|
+
this.state.selection=toolbox.tools.length?{key,tool:toolbox.tools[0]}:null;await this.save(this.state);
|
|
78
|
+
return {schema:'kloudy.toolbox/1',sessionId:this.state.sessionId,toolbox};
|
|
79
|
+
});}
|
|
80
|
+
async call(name,inputs){return this.exclusive(async()=>{
|
|
81
|
+
const selected=this.state.selection;if(!selected||selected.tool.name!==name)fail('not_selected','Ask for Kloudy to select this tool first.');
|
|
82
|
+
const schema=selected.tool.inputSchema;if(!object(inputs)||Object.keys(inputs).some(k=>!Object.hasOwn(schema.properties,k))||schema.required.some(k=>!Object.hasOwn(inputs,k)))fail('invalid_input','Use only the selected tool inputs.');
|
|
83
|
+
for(const [k,v] of Object.entries(inputs)){const f=schema.properties[k];if(typeof v!=='string'||[...v].length<(f.minLength||0)||[...v].length>(f.maxLength??10000))fail('invalid_input','Invalid tool input.');}
|
|
84
|
+
const current=await this.session();if(current.latest_request&&!terminal(current.latest_request.state))fail('pending_request','Finish the pending action first.');
|
|
85
|
+
const receipt=await this.request('POST',`/sessions/${id(this.state.sessionId)}/escalate`,{idempotency_key:randomUUID(),expected_revision:current.revision,narrowing_key:selected.key,inputs});
|
|
86
|
+
id(receipt.request_id);this.state.requestId=receipt.request_id;this.state.selection=null;await this.save(this.state);return this.status();
|
|
87
|
+
});}
|
|
88
|
+
async status(){if(!this.state.requestId)return {state:'idle',mode:this.mode,sessionId:this.state.sessionId||null};const receipt=await this.request('GET','/requests/'+id(this.state.requestId));return {...receipt,...(this.mode==='autonomous'&&receipt.state==='awaiting_approval'?{mode:'autonomous',blocked:{code:'engine_autonomy_not_supported',message:'The engine still requires a separate approval for this action. No approval was fabricated. Cancel or check the engine deployment.'}}:{})};}
|
|
89
|
+
async decide(decision,revision){return this.exclusive(async()=>{
|
|
90
|
+
if(!this.state.requestId)fail('no_request','No action is pending in this session.');
|
|
91
|
+
if(decision==='approve'){if(this.mode!=='confirm')fail('autonomous_only','This client does not offer a second approval gate.');if(typeof revision!=='string'||!revision||revision.length>256)fail('approval_required','Read the exact action and supply its approval revision.');await this.request('POST',`/requests/${id(this.state.requestId)}/approve`,{revision,decision:'approve'});}
|
|
92
|
+
else if(decision==='cancel')await this.request('POST',`/requests/${id(this.state.requestId)}/cancel`,{});
|
|
93
|
+
else fail('invalid_input','Choose approve or cancel.');
|
|
94
|
+
return this.status();
|
|
95
|
+
});}
|
|
96
|
+
}
|
package/src/connect-card.mjs
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
import {connectHTML,publicConnectHTML} from './connect-ui.mjs';
|
|
2
|
-
|
|
3
|
-
export const CONNECT_URI='ui://widget/kloudy-connect-v1.html';
|
|
4
|
-
export const connectResource={uri:CONNECT_URI,name:'Kloudy setup',description:'Show the Kloudy install step or sign-in availability.',mimeType:'text/html;profile=mcp-app'};
|
|
5
|
-
export const connectTool={
|
|
6
|
-
name:'kloudy_connect_card',
|
|
7
|
-
title:'Kloudy sign-in and install card',
|
|
8
|
-
description:'Show sign-in availability or the one-step install card. First call find_mcp_tools with operation ask and a short setup request. If it returns kind account or connect, call this card once with the returned mode. Copying the command does not install anything or authorize private tools. Text-only hosts show the same instruction.',
|
|
9
|
-
annotations:{readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false},
|
|
10
|
-
inputSchema:{type:'object',properties:{mode:{type:'string',enum:['signin','install'],description:'Use the mode returned by find_mcp_tools: signin for account sign-in, or install for editor setup.'}},required:['mode'],additionalProperties:false},
|
|
11
|
-
_meta:{ui:{resourceUri:CONNECT_URI},'openai/outputTemplate':CONNECT_URI,'openai/toolInvocation/invoking':'Opening Kloudy setup…','openai/toolInvocation/invoked':'Kloudy setup'}
|
|
12
|
-
};
|
|
13
|
-
export function connectArguments(value){return !!value&&typeof value==='object'&&!Array.isArray(value)&&Object.keys(value).length===1&&['signin','install'].includes(value.mode);}
|
|
14
|
-
export function connectResult(mode,{publicOnly=false}={}){
|
|
15
|
-
const install=mode==='install';
|
|
16
|
-
const message=install?'Choose Connect, then pick your app.':'GitHub sign-in coming soon. Find public tools today.';
|
|
17
|
-
const actions=[install?{label:'Connect',url:'https://kloudy.ai/install'}:{label:'Find tools',url:'https://kloudy.ai/install#discover'}];
|
|
18
|
-
return {content:[{type:'text',text:message+'\n\n'+actions.map(a=>`[${a.label}](${a.url})`).join('\n')}],structuredContent:{tier:'public',kind:install?'connect':'account',mode,items:[],message,...(install?{copyText:'npx kloudy'}:{}),actions,model_calls:0,executed:false}};
|
|
19
|
-
}
|
|
20
|
-
export function connectContents({publicOnly=false}={}){return {contents:[{uri:CONNECT_URI,mimeType:connectResource.mimeType,text:publicOnly?publicConnectHTML:connectHTML,_meta:{
|
|
21
|
-
ui:{prefersBorder:true,domain:'https://kloudy.ai',csp:{connectDomains:[],resourceDomains:[]}},
|
|
22
|
-
'openai/widgetDescription':'Kloudy sign-in and setup choices. No account is created and no tool is authorized by opening a link.',
|
|
23
|
-
'openai/widgetPrefersBorder':true,'openai/widgetDomain':'https://kloudy.ai',
|
|
24
|
-
'openai/widgetCSP':{connect_domains:[],resource_domains:[],redirect_domains:['https://kloudy.ai','https://cursor.com','https://code.visualstudio.com','https://windsurf.com','https://devin.ai','https://claude.com','https://claude.ai','https://chatgpt.com']}
|
|
25
|
-
}}]};}
|
|
26
|
-
// Public UI metadata still requires credential validation when called with a credential.
|
|
27
|
-
export function connectMessage(method,params={}){
|
|
28
|
-
if(!params||typeof params!=='object'||Array.isArray(params))return false;
|
|
29
|
-
if(['resources/list','resources/templates/list'].includes(method))return Object.keys(params).length===0;
|
|
30
|
-
if(method==='resources/read')return Object.keys(params).length===1&¶ms.uri===CONNECT_URI;
|
|
31
|
-
return method==='tools/call'&&Object.keys(params).length===2&¶ms.name==='kloudy_connect_card'&&connectArguments(params.arguments);
|
|
32
|
-
}
|
|
1
|
+
import {connectHTML,publicConnectHTML} from './connect-ui.mjs';
|
|
2
|
+
|
|
3
|
+
export const CONNECT_URI='ui://widget/kloudy-connect-v1.html';
|
|
4
|
+
export const connectResource={uri:CONNECT_URI,name:'Kloudy setup',description:'Show the Kloudy install step or sign-in availability.',mimeType:'text/html;profile=mcp-app'};
|
|
5
|
+
export const connectTool={
|
|
6
|
+
name:'kloudy_connect_card',
|
|
7
|
+
title:'Kloudy sign-in and install card',
|
|
8
|
+
description:'Show sign-in availability or the one-step install card. First call find_mcp_tools with operation ask and a short setup request. If it returns kind account or connect, call this card once with the returned mode. Copying the command does not install anything or authorize private tools. Text-only hosts show the same instruction.',
|
|
9
|
+
annotations:{readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false},
|
|
10
|
+
inputSchema:{type:'object',properties:{mode:{type:'string',enum:['signin','install'],description:'Use the mode returned by find_mcp_tools: signin for account sign-in, or install for editor setup.'}},required:['mode'],additionalProperties:false},
|
|
11
|
+
_meta:{ui:{resourceUri:CONNECT_URI},'openai/outputTemplate':CONNECT_URI,'openai/toolInvocation/invoking':'Opening Kloudy setup…','openai/toolInvocation/invoked':'Kloudy setup'}
|
|
12
|
+
};
|
|
13
|
+
export function connectArguments(value){return !!value&&typeof value==='object'&&!Array.isArray(value)&&Object.keys(value).length===1&&['signin','install'].includes(value.mode);}
|
|
14
|
+
export function connectResult(mode,{publicOnly=false}={}){
|
|
15
|
+
const install=mode==='install';
|
|
16
|
+
const message=install?'Choose Connect, then pick your app.':'GitHub sign-in coming soon. Find public tools today.';
|
|
17
|
+
const actions=[install?{label:'Connect',url:'https://kloudy.ai/install'}:{label:'Find tools',url:'https://kloudy.ai/install#discover'}];
|
|
18
|
+
return {content:[{type:'text',text:message+'\n\n'+actions.map(a=>`[${a.label}](${a.url})`).join('\n')}],structuredContent:{tier:'public',kind:install?'connect':'account',mode,items:[],message,...(install?{copyText:'npx kloudy'}:{}),actions,model_calls:0,executed:false}};
|
|
19
|
+
}
|
|
20
|
+
export function connectContents({publicOnly=false}={}){return {contents:[{uri:CONNECT_URI,mimeType:connectResource.mimeType,text:publicOnly?publicConnectHTML:connectHTML,_meta:{
|
|
21
|
+
ui:{prefersBorder:true,domain:'https://kloudy.ai',csp:{connectDomains:[],resourceDomains:[]}},
|
|
22
|
+
'openai/widgetDescription':'Kloudy sign-in and setup choices. No account is created and no tool is authorized by opening a link.',
|
|
23
|
+
'openai/widgetPrefersBorder':true,'openai/widgetDomain':'https://kloudy.ai',
|
|
24
|
+
'openai/widgetCSP':{connect_domains:[],resource_domains:[],redirect_domains:['https://kloudy.ai','https://cursor.com','https://code.visualstudio.com','https://windsurf.com','https://devin.ai','https://claude.com','https://claude.ai','https://chatgpt.com']}
|
|
25
|
+
}}]};}
|
|
26
|
+
// Public UI metadata still requires credential validation when called with a credential.
|
|
27
|
+
export function connectMessage(method,params={}){
|
|
28
|
+
if(!params||typeof params!=='object'||Array.isArray(params))return false;
|
|
29
|
+
if(['resources/list','resources/templates/list'].includes(method))return Object.keys(params).length===0;
|
|
30
|
+
if(method==='resources/read')return Object.keys(params).length===1&¶ms.uri===CONNECT_URI;
|
|
31
|
+
return method==='tools/call'&&Object.keys(params).length===2&¶ms.name==='kloudy_connect_card'&&connectArguments(params.arguments);
|
|
32
|
+
}
|