kloudy 1.0.0 → 1.0.1

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 ADDED
@@ -0,0 +1,54 @@
1
+ ## For AI agents: how to use Kloudy
2
+
3
+ The catalog is **kloudy 1.0**: 13,440 bots and 44 modality tags, bundled in this package (npm version 1.0.1). `recommend` / `stack` and `bots ... --offline` read that snapshot (or the local cache if one exists). They do not call the network. Kloudy does not run an LLM to choose bots. Ranking is deterministic: the same goal returns the same JSON.
4
+
5
+ Classes:
6
+
7
+ - **A** — live HTTP MCP endpoints.
8
+ - **B** — npx or uvx packages.
9
+ - **O** — open source and Hugging Face listings.
10
+
11
+ List modalities:
12
+
13
+ ```sh
14
+ npx kloudy@latest bots modalities --offline
15
+ ```
16
+
17
+ Commands:
18
+
19
+ ```sh
20
+ npx kloudy@latest recommend "<goal>" --json
21
+ npx kloudy@latest stack "<goal>" --json
22
+ npx kloudy@latest bots search <words> --offline
23
+ npx kloudy@latest bots get <id> --offline
24
+ npx kloudy@latest agents
25
+ npx kloudy@latest --help
26
+ ```
27
+
28
+ `recommend` and `stack` are aliases. `--json` prints only a JSON object on stdout (`schema`: `kloudy.recommend/1`, `catalog`: `kloudy 1.0`, `version`: the package version). Without `--json` you get the same sections as text, including an Unmatched section when a part did not match.
29
+
30
+ Each bot has `id`, `name`, `class` (`A`, `B`, or `O`), `command`, `license`, `description`, `why`, and `modalities`. `command` is what you run or connect to: an `npx ...` command, a `uvx ...` command, or a URL. `recommend` does not install or download anything. Use `bots get <id> --offline` to inspect a pick, then run the command only when you intend to.
31
+
32
+ The goal is decomposed before matching. Clauses split on commas, semicolons, and the words `and` and `then`. Inside a clause, distinct modality keywords become separate parts. Each part gets the top bots for each modality it needs (up to five). Order is class A, then B, then O, then primary-modality hint, keyword hits, catalog score, and catalog index. A part with no modality or no catalog row is returned in `unmatched` with the part text and the reason `no modality or catalog match`. Parts are not dropped to make a partial stack look complete.
33
+
34
+ Single-part example:
35
+
36
+ ```sh
37
+ npx kloudy@latest recommend "voice assistant that transcribes phone calls" --json
38
+ ```
39
+
40
+ One part, modality `audio`. First bot: `httpk:npm:mendelio-voice-mcp`, class B, MIT, command `npx -y mendelio-voice-mcp`.
41
+
42
+ Multi-part example:
43
+
44
+ ```sh
45
+ npx kloudy@latest recommend "voice assistant that transcribes calls, summarizes them, and stores them in a database" --json
46
+ ```
47
+
48
+ Three parts:
49
+
50
+ 1. `voice assistant that transcribes calls` — `audio` — `httpk:npm:mendelio-voice-mcp` — `npx -y mendelio-voice-mcp`
51
+ 2. `summarizes them` — `text` — `httpk:mcp:ai.liquidagent.api:liquid-agent` — `https://api.liquidagent.ai/mcp`
52
+ 3. `stores them in a database` — `database_access` — `httpk:npm:microsoft:postgres-mcp` — `npx -y @microsoft/postgres-mcp`
53
+
54
+ `weave a basket` in a larger goal is unmatched. It is reported, not omitted.
package/README.md CHANGED
@@ -20,7 +20,65 @@ Interactive cards require a host that supports MCP Apps. The proxy relays the su
20
20
 
21
21
  The existing SDK exports remain available under `kloudy/*`. For the pre-0.4 specialized engine CLI commands documented below, use `kloudy legacy <command>` (for example, `kloudy legacy init my-app`). The original CLI is retained in `bin/kloudy.mjs`.
22
22
 
23
- Offline bot catalog: `npx kloudy bots list|search|get|sync|works|broken`. Kloudy 1.0 bundles the full passing catalog (13,440 listings: 26 verified class A, 11,528 untested class B, 1,886 OSS/HF). Search uses that bundled snapshot, then the local cache. `get` uses a live hit when the API has one, and falls back to that snapshot when the live call misses, including HTTP 404. `--offline` stays on the snapshot. Default `kloudy bots sync` still fetches `https://kloudy.ai/api/deployable-bots/export`; that production URL will not have this 1.0 catalog until a later deploy, so the published tarball does not depend on sync for class B.
23
+ Offline bot catalog: `npx kloudy bots list|search|get|sync|works|broken|modalities`. Kloudy 1.0 bundles the full passing catalog (13,440 listings: 26 verified class A, 11,528 untested class B, 1,886 OSS/HF). Search uses that bundled snapshot, then the local cache. `get` uses a live hit when the API has one, and falls back to that snapshot when the live call misses, including HTTP 404. `--offline` stays on the snapshot. Default `kloudy bots sync` still fetches `https://kloudy.ai/api/deployable-bots/export`; that production URL will not have this 1.0 catalog until a later deploy, so the published tarball does not depend on sync for class B.
24
+
25
+ ## For AI agents: how to use Kloudy
26
+
27
+ The catalog is **kloudy 1.0**: 13,440 bots and 44 modality tags, shipped in this package (npm version 1.0.1). Recommend and the offline bots commands read that bundled snapshot, then the local cache. They do not call the network, and Kloudy does not run an LLM to pick a stack.
28
+
29
+ Classes:
30
+
31
+ - **A** — live HTTP MCP endpoints.
32
+ - **B** — npx or uvx packages.
33
+ - **O** — open source and Hugging Face listings.
34
+
35
+ List the modality tags:
36
+
37
+ ```sh
38
+ npx kloudy@latest bots modalities --offline
39
+ ```
40
+
41
+ Turn a plain-language goal into a stack. `recommend` and `stack` are the same command. Add `--json` when you need parseable output (`schema` is `kloudy.recommend/1`).
42
+
43
+ ```sh
44
+ npx kloudy@latest recommend "<goal>" --json
45
+ npx kloudy@latest stack "<goal>" --json
46
+ npx kloudy@latest bots search <words> --offline
47
+ npx kloudy@latest bots get <id> --offline
48
+ ```
49
+
50
+ Each recommended bot includes `id`, `class` (A, B, or O), `command`, `license`, a one-line `description`, and `why`. `command` is an `npx` or `uvx` invocation, or a URL for a live endpoint or source repo. Run or open that command when you want to install or connect. `recommend` itself does not install anything. Inspect a pick with `bots get` before running it.
51
+
52
+ `recommend` splits the goal on commas, semicolons, and the words `and` and `then`, and it also splits a clause when keywords hit different modalities. You get one section per part, with the top bots in each needed modality (class A, then B, then O, then the same primary-modality, score, and index ordering as catalog search). A part with no modality or no catalog row stays in `unmatched` with the part text and a reason. It is not dropped.
53
+
54
+ Single-part example:
55
+
56
+ ```sh
57
+ npx kloudy@latest recommend "voice assistant that transcribes phone calls" --json
58
+ ```
59
+
60
+ That goal is one audio part. The first of the five bots is `httpk:npm:mendelio-voice-mcp` (class B, MIT, `npx -y mendelio-voice-mcp`).
61
+
62
+ Multi-part example:
63
+
64
+ ```sh
65
+ npx kloudy@latest recommend "voice assistant that transcribes calls, summarizes them, and stores them in a database" --json
66
+ ```
67
+
68
+ That becomes three parts, not one blended search:
69
+
70
+ 1. `voice assistant that transcribes calls` — `audio` — `httpk:npm:mendelio-voice-mcp` — `npx -y mendelio-voice-mcp`
71
+ 2. `summarizes them` — `text` — `httpk:mcp:ai.liquidagent.api:liquid-agent` — `https://api.liquidagent.ai/mcp`
72
+ 3. `stores them in a database` — `database_access` — `httpk:npm:microsoft:postgres-mcp` — `npx -y @microsoft/postgres-mcp`
73
+
74
+ A leftover clause such as `weave a basket` is listed under Unmatched (`no modality or catalog match`) instead of disappearing.
75
+
76
+ On first contact, read the agent guide and the command list:
77
+
78
+ ```sh
79
+ npx kloudy@latest agents
80
+ npx kloudy@latest --help
81
+ ```
24
82
 
25
83
  One-click Cursor / VS Code setup: https://kloudy.ai/install. Browser deep links ask the editor to install; opening a link is not proof that installation completed. ChatGPT requires its directory program; this package does not modify ChatGPT.
26
84
 
package/bin/entry.mjs CHANGED
@@ -16,8 +16,10 @@ try{
16
16
  if(results.some(r=>r.state==='unchanged_error'))process.exitCode=1;
17
17
  }else if(args[0]==='mcp'&&args.length===1){await serveRemote();}
18
18
  else if(args[0]==='--version'){console.log(version);}
19
- else if(['--help','help'].includes(args[0]))console.log('npx kloudy Add to detected editors (asks before each write)\nnpx kloudy <question> Ask the public Kloudy MCP endpoint\nnpx kloudy mcp Remote MCP over local stdio\nnpx kloudy bots list|search|get|sync|works|broken|modalities\nnpx kloudy legacy <args> Existing SDK/engine CLI commands\n\nFree discovery needs no key. For authorized account actions set KLOUDY_MCP_TOKEN privately; never put a key in command arguments. Node 20.19+ required.');
19
+ else if(['--help','help'].includes(args[0]))console.log('npx kloudy Add to detected editors (asks before each write)\nnpx kloudy <question> Ask the public Kloudy MCP endpoint\nnpx kloudy mcp Remote MCP over local stdio\nnpx kloudy recommend "<goal>" [--json]\nnpx kloudy stack "<goal>" [--json]\nnpx kloudy agents\nnpx kloudy bots list|search|get|sync|works|broken|modalities [--offline]\nnpx kloudy legacy <args> Existing SDK/engine CLI commands\n\nFree discovery needs no key. For authorized account actions set KLOUDY_MCP_TOKEN privately; never put a key in command arguments. Node 20.19+ required.');
20
20
  else if(args[0]==='bots'){const {botsCommand}=await import('../src/bots.mjs');try{await botsCommand(args.slice(1));}catch(error){process.stderr.write((error?.message||'bots failed')+'\n');process.exitCode=1;}}
21
+ else if(args[0]==='recommend'||args[0]==='stack'){const {recommendCommand}=await import('../src/recommend.mjs');try{await recommendCommand(args.slice(1));}catch(error){process.stderr.write((error?.message||'recommend failed')+'\n');process.exitCode=1;}}
22
+ else if(args[0]==='agents'||args[0]==='agent'){const {agentsCommand}=await import('../src/recommend.mjs');try{agentsCommand();}catch(error){process.stderr.write((error?.message||'agents failed')+'\n');process.exitCode=1;}}
21
23
  else if(args[0]==='legacy'){process.argv.splice(2,1);await import('./kloudy.mjs');}
22
24
  else{if(args[0]?.startsWith('--'))throw Error('Unknown option');const result=await askRemote(args.join(' '));for(const c of result.content||[])if(c.type==='text')process.stdout.write((process.env.KLOUDY_MCP_TOKEN?c.text.replaceAll(process.env.KLOUDY_MCP_TOKEN,'[redacted]'):c.text)+'\n');if(result.isError)process.exitCode=1;}
23
25
  }catch{process.stderr.write('Kloudy could not complete the request. Check your connection or authorized key. No action was retried. Run npx kloudy --help.\n');process.exitCode=1;}
package/bin/kloudy.mjs CHANGED
@@ -19,7 +19,7 @@ try{
19
19
  const connection=option('connection')||process.env.KLOUDY_CONNECTION;
20
20
  const project=option('project')||process.cwd(),decision=option('decision');
21
21
  if(command==='help'||command==='--help'){
22
- console.log('Ask for Kloudy. One CLI, one MCP, tools appear when needed.\n\nkloudy models list [--endpoint URL] [--key-env NAME]\nkloudy models run NAME --prompt-file FILE [--max-tokens 128] [--allow-provider-charge]\nkloudy init my-app\nkloudy read https://example.com\nkloudy card /world\nkloudy convert https://example.com\nkloudy query "need" --connection FILE\nkloudy ask "goal" --connection FILE [--candidates FILE]\nkloudy assemble --selections FILE --connection FILE\nkloudy call NAME --inputs FILE [--selection ID] --connection FILE\nkloudy action chain.head --inputs FILE --connection FILE [--idempotency-key KEY]\nkloudy wallet-connect ethereum --connection FILE\nkloudy hosting [--budget-micros 100000000] --connection FILE\nkloudy wallet balance --connection FILE\nkloudy wallet top_up --amount 10.00 --connection FILE\nkloudy conversation read|append|sync --inputs FILE --connection FILE\nkloudy host --connection FILE\nkloudy status --connection FILE\nkloudy cancel --connection FILE\nkloudy mcp --connection FILE\nkloudy introduce [--project DIR] [--decision yes|no]\nkloudy install [--ide cursor|claude|vscode|codex] [--url HTTPS_URL] [--token-env NAME] [--connection FILE]\nkloudy wrap --config FILE [--port 8796]\nkloudy strings save NAME --selections FILE\nkloudy strings use NAME\nkloudy strings call NAME --tool ALIAS --inputs FILE\nkloudy strings list|prune\nkloudy strings keep|remove NAME\nkloudy login\nkloudy bots list|search|get|sync|works|broken\n\nRegister with email + short 2FA through the Core account service. Existing engine connections remain usable. Hosted registration is not supplied by this client package.');
22
+ console.log('Ask for Kloudy. One CLI, one MCP, tools appear when needed.\n\nkloudy models list [--endpoint URL] [--key-env NAME]\nkloudy models run NAME --prompt-file FILE [--max-tokens 128] [--allow-provider-charge]\nkloudy init my-app\nkloudy read https://example.com\nkloudy card /world\nkloudy convert https://example.com\nkloudy query "need" --connection FILE\nkloudy ask "goal" --connection FILE [--candidates FILE]\nkloudy assemble --selections FILE --connection FILE\nkloudy call NAME --inputs FILE [--selection ID] --connection FILE\nkloudy action chain.head --inputs FILE --connection FILE [--idempotency-key KEY]\nkloudy wallet-connect ethereum --connection FILE\nkloudy hosting [--budget-micros 100000000] --connection FILE\nkloudy wallet balance --connection FILE\nkloudy wallet top_up --amount 10.00 --connection FILE\nkloudy conversation read|append|sync --inputs FILE --connection FILE\nkloudy host --connection FILE\nkloudy status --connection FILE\nkloudy cancel --connection FILE\nkloudy mcp --connection FILE\nkloudy introduce [--project DIR] [--decision yes|no]\nkloudy install [--ide cursor|claude|vscode|codex] [--url HTTPS_URL] [--token-env NAME] [--connection FILE]\nkloudy wrap --config FILE [--port 8796]\nkloudy strings save NAME --selections FILE\nkloudy strings use NAME\nkloudy strings call NAME --tool ALIAS --inputs FILE\nkloudy strings list|prune\nkloudy strings keep|remove NAME\nkloudy login\nkloudy recommend <goal> [--json]\nkloudy stack <goal> [--json]\nkloudy agents\nkloudy bots list|search|get|sync|works|broken|modalities [--offline]\n\nRegister with email + short 2FA through the Core account service. Existing engine connections remain usable. Hosted registration is not supplied by this client package.');
23
23
  }else if(command==='models'){
24
24
  const action=args.shift(),endpoint=option('endpoint'),keyEnv=option('key-env'),file=option('prompt-file'),max=option('max-tokens');
25
25
  const allow=args.includes('--allow-provider-charge');if(allow)args.splice(args.indexOf('--allow-provider-charge'),1);
@@ -51,6 +51,12 @@ try{
51
51
  }else if(command==='bots'){
52
52
  const {botsCommand}=await import('../src/bots.mjs');
53
53
  try{await botsCommand(args);}catch(error){process.stderr.write((error?.message||'bots failed')+'\n');process.exitCode=1;}
54
+ }else if(command==='recommend'||command==='stack'){
55
+ const {recommendCommand}=await import('../src/recommend.mjs');
56
+ try{await recommendCommand(args);}catch(error){process.stderr.write((error?.message||'recommend failed')+'\n');process.exitCode=1;}
57
+ }else if(command==='agents'||command==='agent'){
58
+ const {agentsCommand}=await import('../src/recommend.mjs');
59
+ try{agentsCommand();}catch(error){process.stderr.write((error?.message||'agents failed')+'\n');process.exitCode=1;}
54
60
  }else{
55
61
  const walletAmount=option('amount'),budgetMicros=option('budget-micros'),idempotencyKey=option('idempotency-key');
56
62
  const candidatesFile=option('candidates'),inputsFile=option('inputs'),selectionsFile=option('selections'),selectionId=option('selection'),lane=option('lane')||'non_medical';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kloudy",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Ask for Kloudy: CLI, MCP and SDK clients for the same scoped engine session.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -33,12 +33,13 @@
33
33
  "src",
34
34
  "schemas",
35
35
  "README.md",
36
+ "AGENTS.md",
36
37
  "MCP-DEFAULT.md",
37
38
  "LICENSE"
38
39
  ],
39
40
  "scripts": {
40
41
  "test": "node --test test/*.test.mjs",
41
- "check": "node --check bin/kloudy.mjs && node --check bin/entry.mjs && node --check src/bots.mjs && node --check src/client.mjs && npm test",
42
+ "check": "node --check bin/kloudy.mjs && node --check bin/entry.mjs && node --check src/bots.mjs && node --check src/recommend.mjs && node --check src/client.mjs && npm test",
42
43
  "bots:bundle": "node scripts/bundle-bots-catalog.mjs --subset full",
43
44
  "start": "node bin/kloudy.mjs",
44
45
  "proof": "node scripts/prove.mjs",
@@ -1,80 +1,80 @@
1
- {
2
- "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "$id": "https://kloudy.ai/schemas/kloudy.deployable-bot.schema.json",
4
- "title": "kloudy.deployable-bot/1",
5
- "type": "object",
6
- "required": [
7
- "id",
8
- "name",
9
- "kind",
10
- "modalities"
11
- ],
12
- "properties": {
13
- "id": {
14
- "type": "string"
15
- },
16
- "name": {
17
- "type": "string"
18
- },
19
- "kind": {
20
- "type": "string",
21
- "enum": [
22
- "http",
23
- "npx",
24
- "uvx",
25
- "botboozle",
26
- "hf"
27
- ]
28
- },
29
- "tier": {
30
- "type": "string"
31
- },
32
- "status": {
33
- "type": "string"
34
- },
35
- "endpoints": {
36
- "type": "array"
37
- },
38
- "free": {
39
- "type": "boolean"
40
- },
41
- "devices": {
42
- "type": "array",
43
- "items": {
44
- "type": "string"
45
- }
46
- },
47
- "caps": {
48
- "type": "array",
49
- "items": {
50
- "type": "string"
51
- }
52
- },
53
- "license": {
54
- "type": "string"
55
- },
56
- "source_url": {
57
- "type": "string"
58
- },
59
- "score": {
60
- "type": "number"
61
- },
62
- "modalities": {
63
- "type": "array",
64
- "minItems": 1,
65
- "items": {
66
- "type": "string",
67
- "minLength": 1
68
- },
69
- "description": "Every fitting modality tag. Multi-select. Known values include text, code, code_execution, embedding, image, image_generation, audio, voice_generation, video, multimodal, translation, search, database_access, file_system, browser_automation, api_calls, scheduling, notifications, auth_encryption, data_analysis, document_processing, robotics, agent_tools; other tags that appear are allowed."
70
- },
71
- "modality_primary": {
72
- "type": [
73
- "string",
74
- "null"
75
- ],
76
- "description": "Sort hint only; never used as a filter."
77
- }
78
- },
79
- "additionalProperties": true
80
- }
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://kloudy.ai/schemas/kloudy.deployable-bot.schema.json",
4
+ "title": "kloudy.deployable-bot/1",
5
+ "type": "object",
6
+ "required": [
7
+ "id",
8
+ "name",
9
+ "kind",
10
+ "modalities"
11
+ ],
12
+ "properties": {
13
+ "id": {
14
+ "type": "string"
15
+ },
16
+ "name": {
17
+ "type": "string"
18
+ },
19
+ "kind": {
20
+ "type": "string",
21
+ "enum": [
22
+ "http",
23
+ "npx",
24
+ "uvx",
25
+ "botboozle",
26
+ "hf"
27
+ ]
28
+ },
29
+ "tier": {
30
+ "type": "string"
31
+ },
32
+ "status": {
33
+ "type": "string"
34
+ },
35
+ "endpoints": {
36
+ "type": "array"
37
+ },
38
+ "free": {
39
+ "type": "boolean"
40
+ },
41
+ "devices": {
42
+ "type": "array",
43
+ "items": {
44
+ "type": "string"
45
+ }
46
+ },
47
+ "caps": {
48
+ "type": "array",
49
+ "items": {
50
+ "type": "string"
51
+ }
52
+ },
53
+ "license": {
54
+ "type": "string"
55
+ },
56
+ "source_url": {
57
+ "type": "string"
58
+ },
59
+ "score": {
60
+ "type": "number"
61
+ },
62
+ "modalities": {
63
+ "type": "array",
64
+ "minItems": 1,
65
+ "items": {
66
+ "type": "string",
67
+ "minLength": 1
68
+ },
69
+ "description": "Every fitting modality tag. Multi-select. Known values include text, code, code_execution, embedding, image, image_generation, audio, voice_generation, video, multimodal, translation, search, database_access, file_system, browser_automation, api_calls, scheduling, notifications, auth_encryption, data_analysis, document_processing, robotics, agent_tools; other tags that appear are allowed."
70
+ },
71
+ "modality_primary": {
72
+ "type": [
73
+ "string",
74
+ "null"
75
+ ],
76
+ "description": "Sort hint only; never used as a filter."
77
+ }
78
+ },
79
+ "additionalProperties": true
80
+ }
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
+ }
@@ -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
- export const KNOWN_MODALITIES=['text','code','code_execution','embedding','image','image_generation','audio','voice_generation','video','multimodal','translation','search','database_access','file_system','browser_automation','api_calls','scheduling','notifications','auth_encryption','data_analysis','document_processing','robotics','agent_tools','web','data','cloud','finance','productivity','comms','security','maps'];
5
-
6
- export function modalityWant(value){
7
- if(value==null||value==='')return [];
8
- const raw=Array.isArray(value)?value:[value];
9
- const out=[];
10
- for(const v of raw){
11
- for(const part of String(v).split(',')){
12
- const s=part.trim();
13
- if(s&&!out.includes(s))out.push(s);
14
- }
15
- }
16
- return out;
17
- }
18
-
19
- export function rowMatchesModalities(have,want,{all=false}={}){
20
- if(!want.length)return true;
21
- const set=Array.isArray(have)?have:[];
22
- return all?want.every(m=>set.includes(m)):want.some(m=>set.includes(m));
23
- }
24
-
25
- export function primarySortHint(primary,want){
26
- return primary&&want.includes(primary)?1:0;
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
+ }
@@ -0,0 +1,478 @@
1
+ // Offline stack recommender. No network and no model call.
2
+ // `kloudy recommend <goal> [--json]` and `kloudy stack <goal> [--json]`.
3
+ import {readFileSync} from 'node:fs';
4
+ import {flags,loadLocal,localEndpoints} from './bots.mjs';
5
+ import {primarySortHint} from './modalities.mjs';
6
+
7
+ export const RECOMMEND_SCHEMA='kloudy.recommend/1';
8
+ export const CATALOG_NAME='kloudy 1.0';
9
+ const PER_MODALITY=5;
10
+ // Class order for recommendations is A, then B, then O.
11
+ // Primary-modality hint, keyword hits, catalog score, and index tie-break
12
+ // follow the comparator in localSearch.
13
+ const CLASS_TIER={A:0,B:1,O:2};
14
+ const words=s=>String(s||'').toLowerCase().split(/[^\p{L}\p{N}_]+/u).filter(w=>w.length>1);
15
+
16
+ const RULE_LIST=[
17
+ ['text to speech',['voice_generation']],
18
+ ['text-to-speech',['voice_generation']],
19
+ ['speech synthesis',['voice_generation']],
20
+ ['voice generation',['voice_generation']],
21
+ ['voice over',['voice_generation']],
22
+ ['voiceover',['voice_generation']],
23
+ ['text to image',['image_generation']],
24
+ ['text-to-image',['image_generation']],
25
+ ['image generation',['image_generation']],
26
+ ['generate images',['image_generation']],
27
+ ['generate image',['image_generation']],
28
+ ['stable diffusion',['image_generation']],
29
+ ['txt2img',['image_generation']],
30
+ ['code execution',['code_execution']],
31
+ ['execute code',['code_execution']],
32
+ ['run code',['code_execution']],
33
+ ['browser automation',['browser_automation']],
34
+ ['web scraping',['browser_automation']],
35
+ ['file system',['file_system']],
36
+ ['command line',['cli']],
37
+ ['command-line',['cli']],
38
+ ['vector database',['embedding']],
39
+ ['vector store',['embedding']],
40
+ ['rest api',['api_calls']],
41
+ ['api calls',['api_calls']],
42
+ ['api call',['api_calls']],
43
+ ['function calls',['agent_tools']],
44
+ ['function call',['agent_tools']],
45
+ ['function calling',['agent_tools']],
46
+ ['tool calling',['agent_tools']],
47
+ ['agent tools',['agent_tools']],
48
+ ['agent tool',['agent_tools']],
49
+ ['retrieval augmented',['rag']],
50
+ ['microsoft teams',['chat_platforms']],
51
+ ['chat window',['chat_ui']],
52
+ ['chat ui',['chat_ui']],
53
+ ['model server',['inference']],
54
+ ['web search',['search']],
55
+ ['transcribes',['audio']],
56
+ ['transcribe',['audio']],
57
+ ['transcribed',['audio']],
58
+ ['transcribing',['audio']],
59
+ ['transcriptions',['audio']],
60
+ ['transcription',['audio']],
61
+ ['summarizes',['text']],
62
+ ['summarize',['text']],
63
+ ['summarized',['text']],
64
+ ['summarizing',['text']],
65
+ ['summarization',['text']],
66
+ ['summaries',['text']],
67
+ ['summary',['text']],
68
+ ['voice',['audio']],
69
+ ['speech',['audio']],
70
+ ['audio',['audio']],
71
+ ['whisper',['audio']],
72
+ ['microphone',['audio']],
73
+ ['phone',['audio']],
74
+ ['calls',['audio']],
75
+ ['listen',['audio']],
76
+ ['asr',['audio']],
77
+ ['tts',['voice_generation']],
78
+ ['speak',['voice_generation']],
79
+ ['speaking',['voice_generation']],
80
+ ['draw',['image_generation']],
81
+ ['drawing',['image_generation']],
82
+ ['images',['image']],
83
+ ['image',['image']],
84
+ ['pictures',['image']],
85
+ ['picture',['image']],
86
+ ['photos',['image']],
87
+ ['photo',['image']],
88
+ ['vision',['image']],
89
+ ['videos',['video']],
90
+ ['video',['video']],
91
+ ['youtube',['video']],
92
+ ['multimodal',['multimodal']],
93
+ ['translates',['translation']],
94
+ ['translate',['translation']],
95
+ ['translation',['translation']],
96
+ ['translator',['translation']],
97
+ ['databases',['database_access']],
98
+ ['database',['database_access']],
99
+ ['postgresql',['database_access']],
100
+ ['postgres',['database_access']],
101
+ ['mongodb',['database_access']],
102
+ ['sqlite',['database_access']],
103
+ ['mysql',['database_access']],
104
+ ['sql',['database_access']],
105
+ ['storage',['database_access']],
106
+ ['stored',['database_access']],
107
+ ['stores',['database_access']],
108
+ ['storing',['database_access']],
109
+ ['store',['database_access']],
110
+ ['filesystem',['file_system']],
111
+ ['files',['file_system']],
112
+ ['file',['file_system']],
113
+ ['directory',['file_system']],
114
+ ['folders',['file_system']],
115
+ ['folder',['file_system']],
116
+ ['disk',['file_system']],
117
+ ['playwright',['browser_automation']],
118
+ ['puppeteer',['browser_automation']],
119
+ ['selenium',['browser_automation']],
120
+ ['scraping',['browser_automation']],
121
+ ['scrape',['browser_automation']],
122
+ ['browser',['browser_automation']],
123
+ ['webhooks',['api_calls']],
124
+ ['webhook',['api_calls']],
125
+ ['graphql',['api_calls']],
126
+ ['apis',['api_calls']],
127
+ ['api',['api_calls']],
128
+ ['scheduling',['scheduling']],
129
+ ['schedules',['scheduling']],
130
+ ['schedule',['scheduling']],
131
+ ['calendar',['scheduling']],
132
+ ['reminders',['scheduling']],
133
+ ['reminder',['scheduling']],
134
+ ['cron',['scheduling']],
135
+ ['notifications',['notifications']],
136
+ ['notification',['notifications']],
137
+ ['notify',['notifications']],
138
+ ['alerts',['notifications']],
139
+ ['alert',['notifications']],
140
+ ['authentication',['auth_encryption']],
141
+ ['encryption',['auth_encryption']],
142
+ ['encrypt',['auth_encryption']],
143
+ ['passwords',['auth_encryption']],
144
+ ['password',['auth_encryption']],
145
+ ['oauth',['auth_encryption']],
146
+ ['login',['auth_encryption']],
147
+ ['auth',['auth_encryption']],
148
+ ['analytics',['data_analysis']],
149
+ ['analysis',['data_analysis']],
150
+ ['analyze',['data_analysis']],
151
+ ['statistics',['data_analysis']],
152
+ ['statistical',['data_analysis']],
153
+ ['dataframe',['data_analysis']],
154
+ ['documents',['document_processing']],
155
+ ['document',['document_processing']],
156
+ ['docx',['document_processing']],
157
+ ['pdf',['document_processing']],
158
+ ['ocr',['document_processing']],
159
+ ['robotics',['robotics']],
160
+ ['robots',['robotics']],
161
+ ['robot',['robotics']],
162
+ ['ros',['robotics']],
163
+ ['embeddings',['embedding']],
164
+ ['embedding',['embedding']],
165
+ ['vectors',['embedding']],
166
+ ['vector',['embedding']],
167
+ ['searching',['search']],
168
+ ['searches',['search']],
169
+ ['search',['search']],
170
+ ['lookup',['search']],
171
+ ['websites',['web']],
172
+ ['website',['web']],
173
+ ['crawling',['web']],
174
+ ['crawl',['web']],
175
+ ['html',['web']],
176
+ ['url',['web']],
177
+ ['web',['web']],
178
+ ['datasets',['data']],
179
+ ['dataset',['data']],
180
+ ['csv',['data']],
181
+ ['etl',['data']],
182
+ ['kubernetes',['cloud']],
183
+ ['azure',['cloud']],
184
+ ['cloud',['cloud']],
185
+ ['aws',['cloud']],
186
+ ['gcp',['cloud']],
187
+ ['k8s',['cloud']],
188
+ ['payments',['finance']],
189
+ ['payment',['finance']],
190
+ ['invoices',['finance']],
191
+ ['invoice',['finance']],
192
+ ['accounting',['finance']],
193
+ ['trading',['finance']],
194
+ ['stripe',['finance']],
195
+ ['finance',['finance']],
196
+ ['spreadsheets',['productivity']],
197
+ ['spreadsheet',['productivity']],
198
+ ['productivity',['productivity']],
199
+ ['todos',['productivity']],
200
+ ['todo',['productivity']],
201
+ ['notes',['productivity']],
202
+ ['messaging',['comms']],
203
+ ['emails',['comms']],
204
+ ['email',['comms']],
205
+ ['sms',['comms']],
206
+ ['vulnerabilities',['security']],
207
+ ['vulnerability',['security']],
208
+ ['security',['security']],
209
+ ['pentest',['security']],
210
+ ['geospatial',['maps']],
211
+ ['locations',['maps']],
212
+ ['location',['maps']],
213
+ ['geocode',['maps']],
214
+ ['maps',['maps']],
215
+ ['map',['maps']],
216
+ ['memories',['memory']],
217
+ ['memory',['memory']],
218
+ ['remember',['memory']],
219
+ ['orchestration',['orchestration']],
220
+ ['orchestrate',['orchestration']],
221
+ ['workflows',['orchestration']],
222
+ ['workflow',['orchestration']],
223
+ ['pipelines',['orchestration']],
224
+ ['pipeline',['orchestration']],
225
+ ['cuda',['gpu_compute']],
226
+ ['gpu',['gpu_compute']],
227
+ ['mathematics',['math']],
228
+ ['equations',['math']],
229
+ ['equation',['math']],
230
+ ['calculator',['math']],
231
+ ['math',['math']],
232
+ ['rag',['rag']],
233
+ ['telegram',['chat_platforms']],
234
+ ['discord',['chat_platforms']],
235
+ ['slack',['chat_platforms']],
236
+ ['chatbot',['chat_ui']],
237
+ ['terminal',['cli']],
238
+ ['shell',['cli']],
239
+ ['cli',['cli']],
240
+ ['benchmarks',['evaluation']],
241
+ ['benchmark',['evaluation']],
242
+ ['evaluation',['evaluation']],
243
+ ['evaluate',['evaluation']],
244
+ ['observability',['observability']],
245
+ ['monitoring',['observability']],
246
+ ['metrics',['observability']],
247
+ ['tracing',['observability']],
248
+ ['logs',['observability']],
249
+ ['rabbitmq',['queues']],
250
+ ['queues',['queues']],
251
+ ['queue',['queues']],
252
+ ['kafka',['queues']],
253
+ ['inference',['inference']],
254
+ ['vllm',['inference']],
255
+ ['simulations',['simulation']],
256
+ ['simulation',['simulation']],
257
+ ['simulator',['simulation']],
258
+ ['simulate',['simulation']],
259
+ ['writing',['text']],
260
+ ['prompt',['text']],
261
+ ['chat',['text']],
262
+ ['llm',['text']],
263
+ ['text',['text']],
264
+ ['coding',['code']],
265
+ ['programming',['code']],
266
+ ['developer',['code']],
267
+ ['software',['code']],
268
+ ['code',['code']],
269
+ ['sandbox',['code_execution']],
270
+ ['interpreter',['code_execution']],
271
+ ['repl',['code_execution']]
272
+ ];
273
+
274
+ export const KEYWORD_RULES=RULE_LIST.map(([phrase,modalities])=>({phrase,modalities:[...modalities]}))
275
+ .sort((a,b)=>b.phrase.length-a.phrase.length||a.phrase.localeCompare(b.phrase));
276
+
277
+ const CLAUSE_SPLIT=/\s*(?:,|;|&)\s*|\s+\b(?:and|then)\b\s+/i;
278
+ const EDGE_STOP=new Set(['a','an','the','that','which','who','to','for','of','in','on','with','and','then','into','from','by','or','also']);
279
+
280
+ function bounded(text,start,end){
281
+ const before=start===0||!/[\p{L}\p{N}_]/u.test(text[start-1]);
282
+ const after=end===text.length||!/[\p{L}\p{N}_]/u.test(text[end]);
283
+ return before&&after;
284
+ }
285
+
286
+ export function findKeywordHits(text){
287
+ const lower=String(text||'').toLowerCase();
288
+ const found=[];
289
+ for(const rule of KEYWORD_RULES){
290
+ let from=0;
291
+ while(from<=lower.length-rule.phrase.length){
292
+ const i=lower.indexOf(rule.phrase,from);
293
+ if(i<0)break;
294
+ const end=i+rule.phrase.length;
295
+ if(bounded(lower,i,end)&&!found.some(h=>i<h.end&&end>h.start))found.push({start:i,end,phrase:rule.phrase,modalities:rule.modalities});
296
+ from=i+1;
297
+ }
298
+ }
299
+ found.sort((a,b)=>a.start-b.start||a.end-b.end);
300
+ return found;
301
+ }
302
+
303
+ function cleanPart(text){
304
+ const bits=String(text||'').replace(/\s+/g,' ').trim().replace(/^[,;:.]+|[,;:.]+$/g,'').split(' ').filter(Boolean);
305
+ while(bits.length&&EDGE_STOP.has(bits[0].toLowerCase()))bits.shift();
306
+ while(bits.length&&EDGE_STOP.has(bits[bits.length-1].toLowerCase()))bits.pop();
307
+ return bits.join(' ');
308
+ }
309
+
310
+ function overlaps(a,b){
311
+ return a.some(m=>b.includes(m));
312
+ }
313
+
314
+ function clauseLabel(clause){
315
+ return cleanPart(clause)||String(clause||'').replace(/\s+/g,' ').trim();
316
+ }
317
+
318
+ function partsFromClause(clause){
319
+ const hits=findKeywordHits(clause);
320
+ if(!hits.length){
321
+ const text=clauseLabel(clause);
322
+ return text?[{text,modalities:[],keywords:[]}]:[];
323
+ }
324
+ const groups=[];
325
+ for(const hit of hits){
326
+ const last=groups[groups.length-1];
327
+ if(last&&overlaps(last.modalities,hit.modalities)){
328
+ last.hits.push(hit);
329
+ for(const m of hit.modalities)if(!last.modalities.includes(m))last.modalities.push(m);
330
+ if(!last.keywords.includes(hit.phrase))last.keywords.push(hit.phrase);
331
+ }else groups.push({modalities:[...hit.modalities],keywords:[hit.phrase],hits:[hit]});
332
+ }
333
+ if(groups.length===1){
334
+ const text=clauseLabel(clause)||groups[0].keywords.join(' ');
335
+ return [{text,modalities:groups[0].modalities,keywords:groups[0].keywords}];
336
+ }
337
+ return groups.map((group,i)=>{
338
+ const start=i===0?0:splitPoint(clause,groups[i-1].hits.at(-1).end,group.hits[0].start);
339
+ const end=i===groups.length-1?clause.length:splitPoint(clause,group.hits.at(-1).end,groups[i+1].hits[0].start);
340
+ const text=clauseLabel(clause.slice(start,end))||group.keywords.join(' ');
341
+ return {text,modalities:group.modalities,keywords:group.keywords};
342
+ });
343
+ }
344
+
345
+ function splitPoint(clause,leftEnd,rightStart){
346
+ const gap=clause.slice(leftEnd,rightStart);
347
+ const rel=gap.lastIndexOf(' ');
348
+ return rel>=0?leftEnd+rel+1:Math.floor((leftEnd+rightStart)/2);
349
+ }
350
+
351
+ export function decomposeGoal(goal){
352
+ const text=String(goal||'').replace(/\s+/g,' ').trim();
353
+ if(!text)return [];
354
+ const parts=[];
355
+ for(const clause of text.split(CLAUSE_SPLIT).map(s=>s.trim()).filter(Boolean))parts.push(...partsFromClause(clause));
356
+ return parts;
357
+ }
358
+
359
+ export function packageVersion(){
360
+ return JSON.parse(readFileSync(new URL('../package.json',import.meta.url),'utf8')).version;
361
+ }
362
+
363
+ export function installCommand(bot){
364
+ const eps=localEndpoints(bot);
365
+ for(const ep of eps)if(ep.command)return ep.command;
366
+ for(const ep of eps)if(ep.url)return ep.url;
367
+ return bot.endpoint||'';
368
+ }
369
+
370
+ function oneLine(bot){
371
+ const caps=(bot.caps||[]).map(c=>String(c).replace(/^cap\./,'').replace(/^mcp\./,'').replace(/[._]+/g,' ').trim()).filter(c=>c&&c.toLowerCase()!=='connect');
372
+ const kind={http:'live HTTP endpoint',npx:'npx package',uvx:'uvx package',hf:'Hugging Face model',botboozle:'open-source package'}[bot.kind]||bot.kind||'bot';
373
+ const detail=caps.length?caps.slice(0,4).join(', '):(bot.modalities||[]).slice(0,3).join(', ');
374
+ const line=detail?`${bot.name} — ${kind}; ${detail}`:`${bot.name} — ${kind}`;
375
+ return line.replace(/\s+/g,' ').slice(0,180);
376
+ }
377
+
378
+ function whyLine(modality,keywords){
379
+ const hint=(keywords||[]).filter(k=>KEYWORD_RULES.some(r=>r.phrase===k&&r.modalities.includes(modality))).slice(0,4).join('/');
380
+ return hint?`matched ${modality}/${hint}; top-ranked in modality`:`matched ${modality}; top-ranked in modality`;
381
+ }
382
+
383
+ function rankModality(bots,modality,keywords,limit){
384
+ const terms=[...new Set((keywords||[]).flatMap(words))];
385
+ const want=[modality];
386
+ const pool=[];
387
+ for(let i=0;i<bots.length;i++){
388
+ const b=bots[i];
389
+ if(!(b.modalities||[]).includes(modality))continue;
390
+ const blob=(b.name+' '+b.id+' '+(b.caps||[]).join(' ')).toLowerCase();
391
+ pool.push({b,i,hits:terms.filter(t=>blob.includes(t)).length});
392
+ }
393
+ const tier=cls=>CLASS_TIER[cls]??9;
394
+ pool.sort((x,y)=>tier(x.b.cls)-tier(y.b.cls)||primarySortHint(y.b.modality_primary,want)-primarySortHint(x.b.modality_primary,want)||y.hits-x.hits||(y.b.score||0)-(x.b.score||0)||x.i-y.i||String(x.b.id).localeCompare(String(y.b.id)));
395
+ return pool.slice(0,limit).map(x=>x.b);
396
+ }
397
+
398
+ export function recommendGoal(goal,options={}){
399
+ const source=options.bots?{bots:options.bots}:loadLocal();
400
+ if(!source?.bots)throw new Error('No local catalog. Run `kloudy bots sync`.');
401
+ const limit=options.perModality??PER_MODALITY;
402
+ const pieces=decomposeGoal(goal);
403
+ const parts=[];
404
+ const unmatched=[];
405
+ const consider=pieces.length?pieces:[{text:String(goal||'').trim(),modalities:[],keywords:[]}];
406
+ for(const piece of consider){
407
+ if(!piece.text)continue;
408
+ if(!piece.modalities.length){
409
+ unmatched.push({part:piece.text,reason:'no modality or catalog match'});
410
+ continue;
411
+ }
412
+ const seen=new Map();
413
+ const bots=[];
414
+ for(const modality of piece.modalities){
415
+ for(const bot of rankModality(source.bots,modality,piece.keywords,limit)){
416
+ const prev=seen.get(bot.id);
417
+ if(prev){
418
+ if(!prev.modalities.includes(modality))prev.modalities.push(modality);
419
+ continue;
420
+ }
421
+ const item={
422
+ id:bot.id,
423
+ name:bot.name,
424
+ class:bot.cls,
425
+ command:installCommand(bot),
426
+ license:bot.license||'',
427
+ description:oneLine(bot),
428
+ why:whyLine(modality,piece.keywords),
429
+ modalities:[modality]
430
+ };
431
+ seen.set(bot.id,item);
432
+ bots.push(item);
433
+ }
434
+ }
435
+ if(!bots.length){
436
+ unmatched.push({part:piece.text,reason:'no modality or catalog match'});
437
+ continue;
438
+ }
439
+ parts.push({part:piece.text,modalities:piece.modalities,bots});
440
+ }
441
+ return {schema:RECOMMEND_SCHEMA,catalog:CATALOG_NAME,version:options.version||packageVersion(),goal:String(goal??''),parts,unmatched};
442
+ }
443
+
444
+ export function formatRecommend(result){
445
+ const lines=[`${result.catalog} · package ${result.version}`,`Goal: ${result.goal}`];
446
+ result.parts.forEach((part,index)=>{
447
+ lines.push('',`${index+1}. ${part.part}`,`Modalities: ${part.modalities.join(', ')}`);
448
+ part.bots.forEach((bot,n)=>{
449
+ lines.push(` ${n+1}) ${bot.id} [${bot.class}] ${bot.license||'unknown license'}`,` ${bot.command}`,` ${bot.description}`,` why: ${bot.why}`);
450
+ });
451
+ });
452
+ if(result.unmatched.length){
453
+ lines.push('','Unmatched');
454
+ for(const item of result.unmatched)lines.push(`- ${item.part}: ${item.reason}`);
455
+ }
456
+ return lines.join('\n');
457
+ }
458
+
459
+ export function recommendCommand(args,opts={}){
460
+ const out=opts.out||(s=>console.log(s));
461
+ const o=flags(args);
462
+ const goal=o._.join(' ').trim();
463
+ if(!goal)throw new Error('Usage: kloudy recommend <goal> [--json]');
464
+ const result=recommendGoal(goal);
465
+ out(o.json?JSON.stringify(result,null,2):formatRecommend(result));
466
+ return result;
467
+ }
468
+
469
+ export function agentsDocument(){
470
+ return readFileSync(new URL('../AGENTS.md',import.meta.url),'utf8');
471
+ }
472
+
473
+ export function agentsCommand(opts={}){
474
+ const out=opts.out||(s=>console.log(s));
475
+ const text=agentsDocument().replace(/\s*$/,'');
476
+ out(text);
477
+ return text;
478
+ }