kloudy 1.0.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +34 -5
- package/README.md +35 -6
- package/bin/entry.mjs +2 -1
- package/bin/kloudy.mjs +4 -1
- package/package.json +2 -2
- package/src/feedback.mjs +159 -0
- package/src/recommend.mjs +330 -52
- package/src/scores.mjs +80 -0
package/AGENTS.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
## For AI agents: how to use Kloudy
|
|
2
2
|
|
|
3
|
-
The catalog is **kloudy 1.0**: 13,440 bots and 44 modality tags, bundled in this package (npm version 1.0.
|
|
3
|
+
The catalog is **kloudy 1.0**: 13,440 bots and 44 modality tags, bundled in this package (npm version 1.0.2). `recommend` / `stack` and `bots ... --offline` read that snapshot (or the local cache if one exists). Kloudy never runs an LLM. Ranking is deterministic: the same goal and the same score overrides return the same JSON.
|
|
4
|
+
|
|
5
|
+
When a network is available, `recommend` may GET `https://kloudy.ai/api/feedback/scores` and cache those overrides for about 24 hours in the Kloudy data directory (`~/.kloudy`, or `KLOUDY_DATA_DIR`). Overrides demote, exclude, or boost a bot after the class tier. `--offline`, a failed request, or a missing scores file leaves ranking on the bundled catalog order.
|
|
4
6
|
|
|
5
7
|
Classes:
|
|
6
8
|
|
|
@@ -18,18 +20,43 @@ Commands:
|
|
|
18
20
|
|
|
19
21
|
```sh
|
|
20
22
|
npx kloudy@latest recommend "<goal>" --json
|
|
21
|
-
npx kloudy@latest
|
|
23
|
+
npx kloudy@latest recommend "<goal>" --json --limit 5
|
|
24
|
+
npx kloudy@latest stack "<goal>" --json --limit 5 --offline
|
|
25
|
+
npx kloudy@latest feedback --stack <stackId> --bot <botId> --result worked|failed|partial [--reason <short>] [--client <name>]
|
|
26
|
+
npx kloudy@latest feedback --flush
|
|
22
27
|
npx kloudy@latest bots search <words> --offline
|
|
23
28
|
npx kloudy@latest bots get <id> --offline
|
|
24
29
|
npx kloudy@latest agents
|
|
25
30
|
npx kloudy@latest --help
|
|
26
31
|
```
|
|
27
32
|
|
|
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.
|
|
33
|
+
`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. `--limit <n>` sets the per-part cap. `n` is an integer from 1 to 20. The default is 5. `--offline` skips the score-override request.
|
|
29
34
|
|
|
30
35
|
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
36
|
|
|
32
|
-
|
|
37
|
+
Write the goal as one sentence per need, or as a comma-separated list of needs. A long sentence is still accepted. Clearer parts produce a clearer stack.
|
|
38
|
+
|
|
39
|
+
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, then the list is capped per part. Each part includes `candidatesTotal` (how many eligible candidates existed before the cap) and `bots` (the capped list). Narrow that capped list with the project you have open: language, frameworks, licenses, and tools already in the repo. Kloudy never runs an LLM and does not read the project to pick a bot.
|
|
40
|
+
|
|
41
|
+
Familiar modality words such as transcribe, summarize, and database still return the top bots in that modality. A narrower need, such as a watch, a timer, Android sync, a meal plan, or a social post, keeps only bots that share a real keyword or a clearly related catalog name, id, or cap with that part. Unrelated bots are not added as filler. Order is class A, then B, then O, then score override (boost, then unchanged, then demote), then primary-modality hint, keyword hits, catalog score, and catalog index. Exclude removes a bot before the cap. 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.
|
|
42
|
+
|
|
43
|
+
`phone`, `call`, and `calls` map to `audio` only when an audio cue is in the same part or clause: transcribe, transcription, voice, speech, audio, whisper, microphone, asr, phone call, or call audio. Otherwise phone, Android, and mobile are a device-sync need, not audio.
|
|
44
|
+
|
|
45
|
+
Every part has `confidence`: `match`, `weak`, or `none`. Top-level `complete` is true only when every part is `match` and `gaps` is empty. `complete` false means the stack is partial. `gaps` lists unmatched and weak parts. Each gap has the part text, `confidence`, and a reason. `kind` is `catalog integration` when a catalog bot with an install or run command is a real but weak hit. `kind` is `suggestion: build yourself / platform API` when the catalog does not cover the need, such as Wear OS sensor APIs, Health Connect, or the Facebook Graph API. That suggestion is unverified non-catalog guidance. It is not a catalog bot.
|
|
46
|
+
|
|
47
|
+
Every recommend result includes `limit`, `stackId`, and `feedback`. `stackId` is a sha256 of the goal's parts plus the chosen bot ids. The same parts and the same picks always produce the same `stackId`. `feedback` is the command to run after you try a bot. Put that bot's id in place of `<id>`.
|
|
48
|
+
|
|
49
|
+
After you try a stack, report whether it worked:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
kloudy feedback --stack <stackId> --bot <botId> --result worked|failed|partial [--reason <short>] [--client <name>]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`--result` is `worked`, `failed`, or `partial`. `--reason` is optional and short: it is truncated to 80 characters, and a path, token, or email is dropped. `--client` is an optional tool name such as `cursor`, `claude`, `codex`, or `vscode`. It counts distinct clients. Do not pass a person, email, or account. The report is opt-in and anonymous. The payload may include only `stackId`, `botId`, `result`, an optional short `reason`, the kloudy version, and an optional client name. It never includes the goal text, personal info, file paths, environment, or tokens. The POST goes to `https://kloudy.ai/api/feedback`. If the network is down or the POST fails, the report is queued in the Kloudy data directory and sent on the next `kloudy feedback` or `kloudy feedback --flush`.
|
|
56
|
+
|
|
57
|
+
Score overrides come from those anonymous reports. `scripts/aggregate-feedback-scores.mjs` is meant to run weekly Sunday 3:00 America/Phoenix. It writes `feedback-scores.json`. Bots with fewer than 3 reports are ignored. A bot is demoted after it fails for 3 or more distinct clients with a failure ratio of 60% or more. A bot is excluded after 10 or more reports with a failure ratio of 80% or more. A bot is boosted at 5 or more reports when at least 80% are `worked` and it is not demoted or excluded. `partial` counts as a report and counts as neither worked nor failed. Failure ratio is `failed` divided by all reports. Reports without a client still count toward ratios. They do not add a distinct client.
|
|
58
|
+
|
|
59
|
+
Without `--json`, the text includes Confidence, Candidates, Gaps, `limit`, `stackId`, `feedback`, and `complete=true` or `complete=false`. `--json` still prints only the JSON object (`schema`: `kloudy.recommend/1`, `catalog`: `kloudy 1.0`, `version`: `1.0.2`).
|
|
33
60
|
|
|
34
61
|
Single-part example:
|
|
35
62
|
|
|
@@ -51,4 +78,6 @@ Three parts:
|
|
|
51
78
|
2. `summarizes them` — `text` — `httpk:mcp:ai.liquidagent.api:liquid-agent` — `https://api.liquidagent.ai/mcp`
|
|
52
79
|
3. `stores them in a database` — `database_access` — `httpk:npm:microsoft:postgres-mcp` — `npx -y @microsoft/postgres-mcp`
|
|
53
80
|
|
|
54
|
-
|
|
81
|
+
That control stack is `complete` true: each part is confidence `match`, and `gaps` is empty.
|
|
82
|
+
|
|
83
|
+
`weave a basket` in a larger goal is unmatched. It is reported, not omitted. The goal is then `complete` false.
|
package/README.md
CHANGED
|
@@ -24,7 +24,9 @@ Offline bot catalog: `npx kloudy bots list|search|get|sync|works|broken|modaliti
|
|
|
24
24
|
|
|
25
25
|
## For AI agents: how to use Kloudy
|
|
26
26
|
|
|
27
|
-
The catalog is **kloudy 1.0**: 13,440 bots and 44 modality tags, shipped in this package (npm version 1.0.
|
|
27
|
+
The catalog is **kloudy 1.0**: 13,440 bots and 44 modality tags, shipped in this package (npm version 1.0.2). Recommend and the offline bots commands read that bundled snapshot, then the local cache. Kloudy never runs an LLM. Ranking is deterministic: the same goal and the same score overrides return the same JSON.
|
|
28
|
+
|
|
29
|
+
When a network is available, `recommend` may GET `https://kloudy.ai/api/feedback/scores` and cache those overrides for about 24 hours in the Kloudy data directory (`~/.kloudy`, or `KLOUDY_DATA_DIR`). Overrides demote, exclude, or boost a bot after the class tier. `--offline`, a failed request, or a missing scores file leaves ranking on the bundled catalog order.
|
|
28
30
|
|
|
29
31
|
Classes:
|
|
30
32
|
|
|
@@ -38,18 +40,43 @@ List the modality tags:
|
|
|
38
40
|
npx kloudy@latest bots modalities --offline
|
|
39
41
|
```
|
|
40
42
|
|
|
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`).
|
|
43
|
+
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`). `--limit <n>` caps each part (`n` is an integer from 1 to 20; the default is 5). `--offline` skips the score-override request.
|
|
42
44
|
|
|
43
45
|
```sh
|
|
44
46
|
npx kloudy@latest recommend "<goal>" --json
|
|
45
|
-
npx kloudy@latest
|
|
47
|
+
npx kloudy@latest recommend "<goal>" --json --limit 5
|
|
48
|
+
npx kloudy@latest stack "<goal>" --json --limit 5 --offline
|
|
49
|
+
npx kloudy@latest feedback --stack <stackId> --bot <botId> --result worked|failed|partial [--reason <short>] [--client <name>]
|
|
50
|
+
npx kloudy@latest feedback --flush
|
|
46
51
|
npx kloudy@latest bots search <words> --offline
|
|
47
52
|
npx kloudy@latest bots get <id> --offline
|
|
48
53
|
```
|
|
49
54
|
|
|
50
55
|
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
56
|
|
|
52
|
-
|
|
57
|
+
Write the goal as one sentence per need, or as a comma-separated list of needs. A long sentence is still accepted. Clearer parts produce a clearer stack.
|
|
58
|
+
|
|
59
|
+
`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. Each part includes `candidatesTotal` (how many eligible candidates existed before the cap) and `bots` (the capped list). Narrow that capped list with the project you have open: language, frameworks, licenses, and tools already in the repo. Kloudy never runs an LLM and does not read the project to pick a bot.
|
|
60
|
+
|
|
61
|
+
Order is class A, then B, then O, then score override (boost, then unchanged, then demote), then the same primary-modality, keyword, score, and index ordering as catalog search. Exclude removes a bot before the cap. Familiar modality words such as transcribe, summarize, and database still return the top bots in that modality. A narrower need, such as a watch, a timer, Android sync, a meal plan, or a social post, keeps only bots that share a real keyword or a clearly related catalog name, id, or cap with that part. Unrelated bots are not filler. A part with no modality or no catalog row stays in `unmatched` with the part text and a reason. It is not dropped.
|
|
62
|
+
|
|
63
|
+
`phone`, `call`, and `calls` are `audio` only when an audio cue is in the same part or clause (transcribe, transcription, voice, speech, audio, whisper, microphone, asr, phone call, or call audio). Otherwise phone, Android, and mobile are device sync, not audio.
|
|
64
|
+
|
|
65
|
+
Each part includes `confidence` (`match`, `weak`, or `none`). Top-level `complete` is true only when every part is `match` and `gaps` is empty. `complete` false means the stack is partial. `gaps` lists unmatched and weak parts with the part text, confidence, and a reason. `kind` `catalog integration` means a catalog bot with an install or run command is a real but weak hit. `kind` `suggestion: build yourself / platform API` means the catalog does not cover the need (Wear OS sensor APIs, Health Connect, or the Facebook Graph API, for example). That suggestion is unverified non-catalog guidance, not a catalog bot.
|
|
66
|
+
|
|
67
|
+
Every recommend result includes `limit`, `stackId`, and `feedback`. `stackId` is a sha256 of the goal's parts plus the chosen bot ids. The same parts and the same picks always produce the same `stackId`. `feedback` is the command to run after you try a bot. Put that bot's id in place of `<id>`.
|
|
68
|
+
|
|
69
|
+
After you try a stack, report whether it worked:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
kloudy feedback --stack <stackId> --bot <botId> --result worked|failed|partial [--reason <short>] [--client <name>]
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`--result` is `worked`, `failed`, or `partial`. `--reason` is optional and short: it is truncated to 80 characters, and a path, token, or email is dropped. `--client` is an optional tool name such as `cursor`, `claude`, `codex`, or `vscode`. It counts distinct clients. Do not pass a person, email, or account. The report is opt-in and anonymous. The payload may include only `stackId`, `botId`, `result`, an optional short `reason`, the kloudy version, and an optional client name. It never includes the goal text, personal info, file paths, environment, or tokens. The POST goes to `https://kloudy.ai/api/feedback`. If the network is down or the POST fails, the report is queued in the Kloudy data directory and sent on the next `kloudy feedback` or `kloudy feedback --flush`.
|
|
76
|
+
|
|
77
|
+
Score overrides come from those anonymous reports. `scripts/aggregate-feedback-scores.mjs` is meant to run weekly Sunday 3:00 America/Phoenix. It writes `feedback-scores.json`. Bots with fewer than 3 reports are ignored. A bot is demoted after it fails for 3 or more distinct clients with a failure ratio of 60% or more. A bot is excluded after 10 or more reports with a failure ratio of 80% or more. A bot is boosted at 5 or more reports when at least 80% are `worked` and it is not demoted or excluded. `partial` counts as a report and counts as neither worked nor failed. Failure ratio is `failed` divided by all reports. Reports without a client still count toward ratios. They do not add a distinct client.
|
|
78
|
+
|
|
79
|
+
Text output includes Confidence, Candidates, Gaps, `limit`, `stackId`, `feedback`, and `complete=true` or `complete=false`. `--json` still prints only the JSON object.
|
|
53
80
|
|
|
54
81
|
Single-part example:
|
|
55
82
|
|
|
@@ -57,7 +84,7 @@ Single-part example:
|
|
|
57
84
|
npx kloudy@latest recommend "voice assistant that transcribes phone calls" --json
|
|
58
85
|
```
|
|
59
86
|
|
|
60
|
-
That goal is one audio part. The first
|
|
87
|
+
That goal is one audio part. The first bot in the capped list is `httpk:npm:mendelio-voice-mcp` (class B, MIT, `npx -y mendelio-voice-mcp`).
|
|
61
88
|
|
|
62
89
|
Multi-part example:
|
|
63
90
|
|
|
@@ -71,7 +98,9 @@ That becomes three parts, not one blended search:
|
|
|
71
98
|
2. `summarizes them` — `text` — `httpk:mcp:ai.liquidagent.api:liquid-agent` — `https://api.liquidagent.ai/mcp`
|
|
72
99
|
3. `stores them in a database` — `database_access` — `httpk:npm:microsoft:postgres-mcp` — `npx -y @microsoft/postgres-mcp`
|
|
73
100
|
|
|
74
|
-
|
|
101
|
+
That control stack is complete (`complete` true). Each part is confidence `match`.
|
|
102
|
+
|
|
103
|
+
A leftover clause such as `weave a basket` is listed under Unmatched (`no modality or catalog match`) and under Gaps. The goal is then `complete` false.
|
|
75
104
|
|
|
76
105
|
On first contact, read the agent guide and the command list:
|
|
77
106
|
|
package/bin/entry.mjs
CHANGED
|
@@ -16,9 +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 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.');
|
|
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] [--limit N] [--offline]\nnpx kloudy stack "<goal>" [--json] [--limit N] [--offline]\nnpx kloudy feedback --stack ID --bot ID --result worked|failed|partial [--reason TEXT] [--client NAME] [--flush]\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
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]==='feedback'){const {feedbackCommand}=await import('../src/feedback.mjs');try{await feedbackCommand(args.slice(1));}catch(error){process.stderr.write((error?.message||'feedback failed')+'\n');process.exitCode=1;}}
|
|
22
23
|
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;}}
|
|
23
24
|
else if(args[0]==='legacy'){process.argv.splice(2,1);await import('./kloudy.mjs');}
|
|
24
25
|
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;}
|
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 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.');
|
|
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] [--limit N] [--offline]\nkloudy stack <goal> [--json] [--limit N] [--offline]\nkloudy feedback --stack ID --bot ID --result worked|failed|partial [--reason TEXT] [--client NAME] [--flush]\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);
|
|
@@ -54,6 +54,9 @@ try{
|
|
|
54
54
|
}else if(command==='recommend'||command==='stack'){
|
|
55
55
|
const {recommendCommand}=await import('../src/recommend.mjs');
|
|
56
56
|
try{await recommendCommand(args);}catch(error){process.stderr.write((error?.message||'recommend failed')+'\n');process.exitCode=1;}
|
|
57
|
+
}else if(command==='feedback'){
|
|
58
|
+
const {feedbackCommand}=await import('../src/feedback.mjs');
|
|
59
|
+
try{await feedbackCommand(args);}catch(error){process.stderr.write((error?.message||'feedback failed')+'\n');process.exitCode=1;}
|
|
57
60
|
}else if(command==='agents'||command==='agent'){
|
|
58
61
|
const {agentsCommand}=await import('../src/recommend.mjs');
|
|
59
62
|
try{agentsCommand();}catch(error){process.stderr.write((error?.message||'agents failed')+'\n');process.exitCode=1;}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kloudy",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
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",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
],
|
|
40
40
|
"scripts": {
|
|
41
41
|
"test": "node --test test/*.test.mjs",
|
|
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
|
+
"check": "node --check bin/kloudy.mjs && node --check bin/entry.mjs && node --check src/bots.mjs && node --check src/recommend.mjs && node --check src/scores.mjs && node --check src/feedback.mjs && node --check scripts/aggregate-feedback-scores.mjs && node --check src/client.mjs && npm test",
|
|
43
43
|
"bots:bundle": "node scripts/bundle-bots-catalog.mjs --subset full",
|
|
44
44
|
"start": "node bin/kloudy.mjs",
|
|
45
45
|
"proof": "node scripts/prove.mjs",
|
package/src/feedback.mjs
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Anonymous stack feedback. Opt-in. No goal text, paths, env, or tokens.
|
|
2
|
+
// POST https://kloudy.ai/api/feedback and queue in the Kloudy data directory on failure.
|
|
3
|
+
import {existsSync,mkdirSync,readFileSync,writeFileSync} from 'node:fs';
|
|
4
|
+
import {join} from 'node:path';
|
|
5
|
+
import {dataDir} from './scores.mjs';
|
|
6
|
+
|
|
7
|
+
export const FEEDBACK_URL='https://kloudy.ai/api/feedback';
|
|
8
|
+
export const RESULTS=new Set(['worked','failed','partial']);
|
|
9
|
+
export const REASON_MAX=80;
|
|
10
|
+
const ALLOWED=new Set(['stackId','botId','result','reason','version','client']);
|
|
11
|
+
const DISABLED=new Set(['','off','offline','0','false']);
|
|
12
|
+
|
|
13
|
+
function packageVersion(){
|
|
14
|
+
return JSON.parse(readFileSync(new URL('../package.json',import.meta.url),'utf8')).version;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function feedbackEndpoint(explicit){
|
|
18
|
+
const value=explicit??process.env.KLOUDY_FEEDBACK_URL??FEEDBACK_URL;
|
|
19
|
+
return DISABLED.has(String(value).trim())?null:value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function sanitizeReason(reason){
|
|
23
|
+
if(reason==null||reason==='')return undefined;
|
|
24
|
+
let text=String(reason).replace(/[\u0000-\u001f\u007f]/g,' ').replace(/\s+/g,' ').trim();
|
|
25
|
+
if(!text)return undefined;
|
|
26
|
+
if(/[\\/]|~|\.\.|[A-Za-z]:[\\/]|@[A-Za-z0-9.-]+\.[A-Za-z]{2,}|\b(?:bearer|token|secret|password|api[_-]?key)\b|-----BEGIN/i.test(text))return undefined;
|
|
27
|
+
if(/[A-Fa-f0-9]{24,}/.test(text))return undefined;
|
|
28
|
+
if(/(?:sk|pk|ghp|gho|xox[baprs])-/.test(text))return undefined;
|
|
29
|
+
if(text.length>REASON_MAX)text=text.slice(0,REASON_MAX).trim();
|
|
30
|
+
return text||undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function sanitizeClient(client){
|
|
34
|
+
if(client==null||client==='')return undefined;
|
|
35
|
+
const text=String(client).trim();
|
|
36
|
+
if(!/^[A-Za-z][A-Za-z0-9._-]{0,31}$/.test(text))return undefined;
|
|
37
|
+
return text;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildFeedbackPayload(input={}){
|
|
41
|
+
const stackId=String(input.stackId||'').trim();
|
|
42
|
+
const botId=String(input.botId||'').trim();
|
|
43
|
+
const result=input.result;
|
|
44
|
+
if(!/^[a-f0-9]{64}$/.test(stackId))throw new Error('stack id must be the stackId from recommend --json');
|
|
45
|
+
if(!botId||botId.length>200||/[\s\\]/.test(botId)||botId.includes('..'))throw new Error('bot id is required');
|
|
46
|
+
if(!RESULTS.has(result))throw new Error('result must be worked, failed, or partial');
|
|
47
|
+
const payload={stackId,botId,result,version:input.version||packageVersion()};
|
|
48
|
+
const reason=sanitizeReason(input.reason===input.goal?undefined:input.reason);
|
|
49
|
+
if(reason)payload.reason=reason;
|
|
50
|
+
const client=sanitizeClient(input.client);
|
|
51
|
+
if(client)payload.client=client;
|
|
52
|
+
return payload;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function queuePath(dir=dataDir()){
|
|
56
|
+
return join(dir,'feedback-queue.json');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function safeQueued(item){
|
|
60
|
+
if(!item||typeof item!=='object'||Array.isArray(item))return false;
|
|
61
|
+
const keys=Object.keys(item);
|
|
62
|
+
if(!keys.length||!keys.every(key=>ALLOWED.has(key)))return false;
|
|
63
|
+
if(typeof item.stackId!=='string'||typeof item.botId!=='string'||typeof item.version!=='string')return false;
|
|
64
|
+
if(!RESULTS.has(item.result))return false;
|
|
65
|
+
if(item.reason!=null&&typeof item.reason!=='string')return false;
|
|
66
|
+
if(item.client!=null&&typeof item.client!=='string')return false;
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function readQueue(dir=dataDir()){
|
|
71
|
+
const file=queuePath(dir);
|
|
72
|
+
try{
|
|
73
|
+
if(!existsSync(file))return [];
|
|
74
|
+
const data=JSON.parse(readFileSync(file,'utf8'));
|
|
75
|
+
return Array.isArray(data)?data.filter(safeQueued):[];
|
|
76
|
+
}catch{
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function writeQueue(dir,items){
|
|
82
|
+
mkdirSync(dir,{recursive:true});
|
|
83
|
+
writeFileSync(queuePath(dir),JSON.stringify(items,null,2)+'\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function postOne(payload,fetchImpl,url){
|
|
87
|
+
const response=await fetchImpl(url,{
|
|
88
|
+
method:'POST',
|
|
89
|
+
redirect:'error',
|
|
90
|
+
credentials:'omit',
|
|
91
|
+
headers:{'content-type':'application/json',accept:'application/json'},
|
|
92
|
+
body:JSON.stringify(payload),
|
|
93
|
+
signal:AbortSignal.timeout(8000)
|
|
94
|
+
});
|
|
95
|
+
if(!response?.ok)throw new Error('Feedback was not accepted');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function submitFeedback({payload,dir=dataDir(),fetchImpl=fetch,url=feedbackEndpoint()}={}){
|
|
99
|
+
const queued=readQueue(dir);
|
|
100
|
+
if(payload)queued.push(payload);
|
|
101
|
+
if(!queued.length)return {sent:0,queued:0,status:'empty'};
|
|
102
|
+
if(!url){
|
|
103
|
+
writeQueue(dir,queued);
|
|
104
|
+
return {sent:0,queued:queued.length,status:'queued'};
|
|
105
|
+
}
|
|
106
|
+
const pending=[];
|
|
107
|
+
let sent=0;
|
|
108
|
+
let failed=false;
|
|
109
|
+
for(const item of queued){
|
|
110
|
+
if(failed){pending.push(item);continue;}
|
|
111
|
+
try{
|
|
112
|
+
await postOne(item,fetchImpl,url);
|
|
113
|
+
sent++;
|
|
114
|
+
}catch{
|
|
115
|
+
failed=true;
|
|
116
|
+
pending.push(item);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
writeQueue(dir,pending);
|
|
120
|
+
return {sent,queued:pending.length,status:pending.length?(sent?'partial':'queued'):'sent'};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function parseFeedbackArgs(args){
|
|
124
|
+
const o={};
|
|
125
|
+
for(let i=0;i<args.length;i++){
|
|
126
|
+
const arg=args[i];
|
|
127
|
+
if(arg==='--flush'){o.flush=true;continue;}
|
|
128
|
+
if(arg==='--json'){o.json=true;continue;}
|
|
129
|
+
if(!arg.startsWith('--'))throw new Error('Usage: kloudy feedback --stack ID --bot ID --result worked|failed|partial [--reason TEXT] [--client NAME] [--flush]');
|
|
130
|
+
const key=arg.slice(2);
|
|
131
|
+
const value=args[++i];
|
|
132
|
+
if(value==null||String(value).startsWith('--'))throw new Error('Option value required');
|
|
133
|
+
if(!['stack','bot','result','reason','client'].includes(key))throw new Error('Unknown feedback option');
|
|
134
|
+
o[key]=value;
|
|
135
|
+
}
|
|
136
|
+
return o;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function statusLine(result){
|
|
140
|
+
if(result.status==='empty')return 'feedback queue is empty';
|
|
141
|
+
if(result.status==='sent')return `sent ${result.sent} feedback report${result.sent===1?'':'s'}`;
|
|
142
|
+
if(result.status==='partial')return `sent ${result.sent} feedback report${result.sent===1?'':'s'}; ${result.queued} still queued`;
|
|
143
|
+
return `queued ${result.queued} feedback report${result.queued===1?'':'s'}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function feedbackCommand(args,opts={}){
|
|
147
|
+
const out=opts.out||(s=>console.log(s));
|
|
148
|
+
const o=parseFeedbackArgs(args);
|
|
149
|
+
const hasReport=o.stack!=null||o.bot!=null||o.result!=null||o.reason!=null||o.client!=null;
|
|
150
|
+
if(!hasReport&&!o.flush)throw new Error('Usage: kloudy feedback --stack ID --bot ID --result worked|failed|partial [--reason TEXT] [--client NAME] [--flush]');
|
|
151
|
+
if(hasReport&&(!o.stack||!o.bot||!o.result))throw new Error('Feedback requires --stack, --bot, and --result');
|
|
152
|
+
const payload=hasReport?buildFeedbackPayload({stackId:o.stack,botId:o.bot,result:o.result,reason:o.reason,client:o.client,version:opts.version}):undefined;
|
|
153
|
+
const endpoint=opts.url===undefined?feedbackEndpoint():opts.url;
|
|
154
|
+
const result=await submitFeedback({payload,dir:opts.dataDir,fetchImpl:opts.fetch,url:endpoint||null});
|
|
155
|
+
const line=statusLine(result);
|
|
156
|
+
if(o.client!=null&&payload&&!payload.client)out(line+'\nclient omitted');
|
|
157
|
+
else out(line);
|
|
158
|
+
return {...result,payload};
|
|
159
|
+
}
|
package/src/recommend.mjs
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
|
-
//
|
|
2
|
-
// `kloudy recommend <goal> [--json]
|
|
1
|
+
// Stack recommender. Kloudy never runs an LLM.
|
|
2
|
+
// `kloudy recommend <goal> [--json] [--limit N] [--offline]`
|
|
3
|
+
// `kloudy stack` is the same command.
|
|
4
|
+
// Ranking reads the bundled catalog. recommendCommand may apply cached score overrides.
|
|
5
|
+
import {createHash} from 'node:crypto';
|
|
3
6
|
import {readFileSync} from 'node:fs';
|
|
4
7
|
import {flags,loadLocal,localEndpoints} from './bots.mjs';
|
|
5
8
|
import {primarySortHint} from './modalities.mjs';
|
|
9
|
+
import {loadScoreOverrides,normalizeScores} from './scores.mjs';
|
|
6
10
|
|
|
7
11
|
export const RECOMMEND_SCHEMA='kloudy.recommend/1';
|
|
8
12
|
export const CATALOG_NAME='kloudy 1.0';
|
|
9
|
-
const
|
|
13
|
+
export const DEFAULT_LIMIT=5;
|
|
14
|
+
export const MIN_LIMIT=1;
|
|
15
|
+
export const MAX_LIMIT=20;
|
|
16
|
+
const PER_MODALITY=DEFAULT_LIMIT;
|
|
10
17
|
// Class order for recommendations is A, then B, then O.
|
|
11
|
-
//
|
|
12
|
-
//
|
|
18
|
+
// Score overrides apply after class: boost, then unchanged, then demote.
|
|
19
|
+
// Exclude drops a bot before the cap. Primary-modality hint, keyword hits,
|
|
20
|
+
// catalog score, and index tie-break follow the comparator in localSearch.
|
|
13
21
|
const CLASS_TIER={A:0,B:1,O:2};
|
|
14
22
|
const words=s=>String(s||'').toLowerCase().split(/[^\p{L}\p{N}_]+/u).filter(w=>w.length>1);
|
|
15
23
|
|
|
@@ -70,8 +78,6 @@ const RULE_LIST=[
|
|
|
70
78
|
['audio',['audio']],
|
|
71
79
|
['whisper',['audio']],
|
|
72
80
|
['microphone',['audio']],
|
|
73
|
-
['phone',['audio']],
|
|
74
|
-
['calls',['audio']],
|
|
75
81
|
['listen',['audio']],
|
|
76
82
|
['asr',['audio']],
|
|
77
83
|
['tts',['voice_generation']],
|
|
@@ -125,13 +131,13 @@ const RULE_LIST=[
|
|
|
125
131
|
['graphql',['api_calls']],
|
|
126
132
|
['apis',['api_calls']],
|
|
127
133
|
['api',['api_calls']],
|
|
128
|
-
['scheduling',['scheduling']],
|
|
129
|
-
['schedules',['scheduling']],
|
|
130
|
-
['schedule',['scheduling']],
|
|
134
|
+
['scheduling',['scheduling'],{family:'timer'}],
|
|
135
|
+
['schedules',['scheduling'],{family:'timer'}],
|
|
136
|
+
['schedule',['scheduling'],{family:'timer'}],
|
|
131
137
|
['calendar',['scheduling']],
|
|
132
138
|
['reminders',['scheduling']],
|
|
133
139
|
['reminder',['scheduling']],
|
|
134
|
-
['cron',['scheduling']],
|
|
140
|
+
['cron',['scheduling'],{family:'timer'}],
|
|
135
141
|
['notifications',['notifications']],
|
|
136
142
|
['notification',['notifications']],
|
|
137
143
|
['notify',['notifications']],
|
|
@@ -268,11 +274,72 @@ const RULE_LIST=[
|
|
|
268
274
|
['code',['code']],
|
|
269
275
|
['sandbox',['code_execution']],
|
|
270
276
|
['interpreter',['code_execution']],
|
|
271
|
-
['repl',['code_execution']]
|
|
277
|
+
['repl',['code_execution']],
|
|
278
|
+
// Phone and calls are audio only when an audio cue is in the same clause.
|
|
279
|
+
// Otherwise phone / Android / mobile are device sync, not voice tools.
|
|
280
|
+
['phone call',['audio']],
|
|
281
|
+
['phone calls',['audio']],
|
|
282
|
+
['call audio',['audio']],
|
|
283
|
+
['phone',['audio'],{when:'audio'}],
|
|
284
|
+
['calls',['audio'],{when:'audio'}],
|
|
285
|
+
['call',['audio'],{when:'audio'}],
|
|
286
|
+
['phones',['notifications','comms'],{family:'sync',when:'no-audio'}],
|
|
287
|
+
['phone',['notifications','comms'],{family:'sync',when:'no-audio'}],
|
|
288
|
+
['android',['notifications','comms'],{family:'sync'}],
|
|
289
|
+
['mobile',['notifications','comms'],{family:'sync'}],
|
|
290
|
+
['syncing',['notifications','comms'],{family:'sync'}],
|
|
291
|
+
['synced',['notifications','comms'],{family:'sync'}],
|
|
292
|
+
['syncs',['notifications','comms'],{family:'sync'}],
|
|
293
|
+
['sync',['notifications','comms'],{family:'sync'}],
|
|
294
|
+
['push notification',['notifications','comms'],{family:'sync'}],
|
|
295
|
+
['push notifications',['notifications','comms'],{family:'sync'}],
|
|
296
|
+
['galaxy watch',['data'],{family:'wearable'}],
|
|
297
|
+
['wear os',['data'],{family:'wearable'}],
|
|
298
|
+
['health connect',['data'],{family:'wearable'}],
|
|
299
|
+
['apple health',['data'],{family:'wearable'}],
|
|
300
|
+
['smartwatch',['data'],{family:'wearable'}],
|
|
301
|
+
['smart watch',['data'],{family:'wearable'}],
|
|
302
|
+
['pedometer',['data'],{family:'wearable'}],
|
|
303
|
+
['accelerometer',['data'],{family:'wearable'}],
|
|
304
|
+
['wearable',['data'],{family:'wearable'}],
|
|
305
|
+
['fitness',['data'],{family:'wearable'}],
|
|
306
|
+
['steps',['data'],{family:'wearable',when:'not-device'}],
|
|
307
|
+
['step',['data'],{family:'wearable',when:'not-device'}],
|
|
308
|
+
['watch',['data'],{family:'wearable'}],
|
|
309
|
+
['timers',['scheduling'],{family:'timer'}],
|
|
310
|
+
['timer',['scheduling'],{family:'timer'}],
|
|
311
|
+
['triggers',['scheduling'],{family:'timer',when:'timer'}],
|
|
312
|
+
['trigger',['scheduling'],{family:'timer',when:'timer'}],
|
|
313
|
+
['triggered',['scheduling'],{family:'timer',when:'timer'}],
|
|
314
|
+
['movement',['scheduling'],{family:'timer',when:'movement-timer'}],
|
|
315
|
+
['movement',['data'],{family:'wearable',when:'movement-sensor'}],
|
|
316
|
+
['calories',['text'],{family:'nutrition'}],
|
|
317
|
+
['calorie',['text'],{family:'nutrition'}],
|
|
318
|
+
['nutrition',['text'],{family:'nutrition'}],
|
|
319
|
+
['recipes',['text'],{family:'nutrition'}],
|
|
320
|
+
['recipe',['text'],{family:'nutrition'}],
|
|
321
|
+
['meals',['text'],{family:'nutrition'}],
|
|
322
|
+
['meal',['text'],{family:'nutrition'}],
|
|
323
|
+
['diets',['text'],{family:'nutrition'}],
|
|
324
|
+
['diet',['text'],{family:'nutrition'}],
|
|
325
|
+
['foods',['text'],{family:'nutrition'}],
|
|
326
|
+
['food',['text'],{family:'nutrition'}],
|
|
327
|
+
['facebook',['comms'],{family:'social'}],
|
|
328
|
+
['instagram',['comms'],{family:'social'}],
|
|
329
|
+
['twitter',['comms'],{family:'social'}],
|
|
330
|
+
['shares',['comms'],{family:'social',when:'social'}],
|
|
331
|
+
['shared',['comms'],{family:'social',when:'social'}],
|
|
332
|
+
['sharing',['comms'],{family:'social',when:'social'}],
|
|
333
|
+
['share',['comms'],{family:'social',when:'social'}],
|
|
334
|
+
['posting',['comms'],{family:'social',when:'social'}],
|
|
335
|
+
['posts',['comms'],{family:'social',when:'social'}],
|
|
336
|
+
['posted',['comms'],{family:'social',when:'social'}],
|
|
337
|
+
['post',['comms'],{family:'social',when:'social'}],
|
|
338
|
+
['x',['comms'],{family:'social',when:'social-x'}]
|
|
272
339
|
];
|
|
273
340
|
|
|
274
|
-
export const KEYWORD_RULES=RULE_LIST.map(([phrase,modalities])=>({phrase,modalities:[...modalities]}))
|
|
275
|
-
.sort((a,b)=>b.phrase.length-a.phrase.length||a.phrase.localeCompare(b.phrase));
|
|
341
|
+
export const KEYWORD_RULES=RULE_LIST.map(([phrase,modalities,meta])=>({phrase,modalities:[...modalities],...(meta||{})}))
|
|
342
|
+
.sort((a,b)=>b.phrase.length-a.phrase.length||a.phrase.localeCompare(b.phrase)||(a.when||'').localeCompare(b.when||''));
|
|
276
343
|
|
|
277
344
|
const CLAUSE_SPLIT=/\s*(?:,|;|&)\s*|\s+\b(?:and|then)\b\s+/i;
|
|
278
345
|
const EDGE_STOP=new Set(['a','an','the','that','which','who','to','for','of','in','on','with','and','then','into','from','by','or','also']);
|
|
@@ -283,16 +350,38 @@ function bounded(text,start,end){
|
|
|
283
350
|
return before&&after;
|
|
284
351
|
}
|
|
285
352
|
|
|
353
|
+
const AUDIO_CUE=/\b(?:transcri(?:be|bes|bed|bing|ption|ptions)?|voices?|speech|audio|whisper|microphones?|asr|listen(?:s|ing|ed)?|phone calls?|call audio)\b/i;
|
|
354
|
+
const DEVICE_CUE=/\b(?:android|mobile|phones?|sync(?:ing|ed|s)?)\b/i;
|
|
355
|
+
const TIMER_CUE=/\b(?:timers?|schedul\w*|cron|triggers?|triggered)\b/i;
|
|
356
|
+
const DB_CUE=/\b(?:databases?|sql|postgres(?:ql)?|mysql|sqlite|mongodb)\b/i;
|
|
357
|
+
const SOCIAL_NET=/\b(?:facebook|instagram|twitter)\b/i;
|
|
358
|
+
const SOCIAL_X=/(?:\bx\.com\b|\b(?:post|share|tweet)(?:s|ed|ing)?\b[^.!?\n]{0,40}\bx\b|\bx\b[^.!?\n]{0,40}\b(?:post|share|tweet)(?:s|ed|ing)?\b)/i;
|
|
359
|
+
|
|
360
|
+
function ruleApplies(rule,text){
|
|
361
|
+
switch(rule.when){
|
|
362
|
+
case 'audio':return AUDIO_CUE.test(text);
|
|
363
|
+
case 'no-audio':return !AUDIO_CUE.test(text);
|
|
364
|
+
case 'social':return SOCIAL_NET.test(text)||SOCIAL_X.test(text);
|
|
365
|
+
case 'social-x':return SOCIAL_X.test(text);
|
|
366
|
+
case 'timer':return TIMER_CUE.test(text)&&!DB_CUE.test(text);
|
|
367
|
+
case 'movement-timer':return TIMER_CUE.test(text);
|
|
368
|
+
case 'movement-sensor':return !TIMER_CUE.test(text);
|
|
369
|
+
case 'not-device':return !DEVICE_CUE.test(text);
|
|
370
|
+
default:return true;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
286
374
|
export function findKeywordHits(text){
|
|
287
375
|
const lower=String(text||'').toLowerCase();
|
|
288
376
|
const found=[];
|
|
289
377
|
for(const rule of KEYWORD_RULES){
|
|
378
|
+
if(!ruleApplies(rule,lower))continue;
|
|
290
379
|
let from=0;
|
|
291
380
|
while(from<=lower.length-rule.phrase.length){
|
|
292
381
|
const i=lower.indexOf(rule.phrase,from);
|
|
293
382
|
if(i<0)break;
|
|
294
383
|
const end=i+rule.phrase.length;
|
|
295
|
-
if(bounded(lower,i,end)&&!found.some(h=>i<h.end&&end>h.start))found.push({start:i,end,phrase:rule.phrase,modalities:rule.modalities});
|
|
384
|
+
if(bounded(lower,i,end)&&!found.some(h=>i<h.end&&end>h.start))found.push({start:i,end,phrase:rule.phrase,modalities:rule.modalities,family:rule.family||null});
|
|
296
385
|
from=i+1;
|
|
297
386
|
}
|
|
298
387
|
}
|
|
@@ -319,7 +408,7 @@ function partsFromClause(clause){
|
|
|
319
408
|
const hits=findKeywordHits(clause);
|
|
320
409
|
if(!hits.length){
|
|
321
410
|
const text=clauseLabel(clause);
|
|
322
|
-
return text?[{text,modalities:[],keywords:[]}]:[];
|
|
411
|
+
return text?[{text,modalities:[],keywords:[],family:null}]:[];
|
|
323
412
|
}
|
|
324
413
|
const groups=[];
|
|
325
414
|
for(const hit of hits){
|
|
@@ -328,17 +417,18 @@ function partsFromClause(clause){
|
|
|
328
417
|
last.hits.push(hit);
|
|
329
418
|
for(const m of hit.modalities)if(!last.modalities.includes(m))last.modalities.push(m);
|
|
330
419
|
if(!last.keywords.includes(hit.phrase))last.keywords.push(hit.phrase);
|
|
331
|
-
|
|
420
|
+
if(hit.family&&!last.family)last.family=hit.family;
|
|
421
|
+
}else groups.push({modalities:[...hit.modalities],keywords:[hit.phrase],hits:[hit],family:hit.family||null});
|
|
332
422
|
}
|
|
333
423
|
if(groups.length===1){
|
|
334
424
|
const text=clauseLabel(clause)||groups[0].keywords.join(' ');
|
|
335
|
-
return [{text,modalities:groups[0].modalities,keywords:groups[0].keywords}];
|
|
425
|
+
return [{text,modalities:groups[0].modalities,keywords:groups[0].keywords,family:groups[0].family}];
|
|
336
426
|
}
|
|
337
427
|
return groups.map((group,i)=>{
|
|
338
428
|
const start=i===0?0:splitPoint(clause,groups[i-1].hits.at(-1).end,group.hits[0].start);
|
|
339
429
|
const end=i===groups.length-1?clause.length:splitPoint(clause,group.hits.at(-1).end,groups[i+1].hits[0].start);
|
|
340
430
|
const text=clauseLabel(clause.slice(start,end))||group.keywords.join(' ');
|
|
341
|
-
return {text,modalities:group.modalities,keywords:group.keywords};
|
|
431
|
+
return {text,modalities:group.modalities,keywords:group.keywords,family:group.family};
|
|
342
432
|
});
|
|
343
433
|
}
|
|
344
434
|
|
|
@@ -375,76 +465,259 @@ function oneLine(bot){
|
|
|
375
465
|
return line.replace(/\s+/g,' ').slice(0,180);
|
|
376
466
|
}
|
|
377
467
|
|
|
378
|
-
function whyLine(modality,keywords){
|
|
468
|
+
function whyLine(modality,keywords,tail='top-ranked in modality'){
|
|
379
469
|
const hint=(keywords||[]).filter(k=>KEYWORD_RULES.some(r=>r.phrase===k&&r.modalities.includes(modality))).slice(0,4).join('/');
|
|
380
|
-
return hint?`matched ${modality}/${hint};
|
|
470
|
+
return hint?`matched ${modality}/${hint}; ${tail}`:`matched ${modality}; ${tail}`;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const KIND_CATALOG='catalog integration';
|
|
474
|
+
const KIND_SUGGEST='suggestion: build yourself / platform API';
|
|
475
|
+
const OPTIONAL_COVERAGE=new Set(['share','shares','shared','sharing','post','posts','posting','posted','send','sends','sending','sent','detect','detects','detecting','start','starts','starting','create','creates','use','uses','using','based','optionally','optional','results','data','experience','that','them']);
|
|
476
|
+
const SUGGEST={
|
|
477
|
+
wearable:'Wear OS sensor APIs and Health Connect are not in the kloudy 1.0 catalog. Build the sensing yourself or call the platform API. Unverified non-catalog guidance, not a catalog bot.',
|
|
478
|
+
sync:'Health Connect is not in the kloudy 1.0 catalog. It is a platform API for sending step data to an Android phone. Unverified non-catalog guidance, not a catalog bot.',
|
|
479
|
+
social:'The Facebook Graph API, Instagram API, and X API are not catalog bots. Unverified non-catalog guidance. Build the post yourself or call the platform API.'
|
|
480
|
+
};
|
|
481
|
+
const WEAK_REASON={
|
|
482
|
+
wearable:'Nearest catalog bots match a related fitness wearable (Garmin, Fitbit, or Apple Health), not Galaxy Watch step sensing.',
|
|
483
|
+
timer:'Catalog timers or schedulers share a timer or schedule keyword, but none start a timer from movement.',
|
|
484
|
+
sync:'Catalog Android tools share the Android keyword, but they do not send step data to a phone.',
|
|
485
|
+
nutrition:'Catalog nutrition tools only partly overlap this meal or calorie need.',
|
|
486
|
+
social:'Catalog social tools only partly overlap this post or share need.'
|
|
487
|
+
};
|
|
488
|
+
const CAP_NOISE=new Set(['connect','mcp','llm','language','agents','agent','scheduling','notifications','notification','api','calls','call','data','text','code','web','database','audio','video','image','memory','search','orchestration','skill','vision','browser','filesystem','security','cloud','comms']);
|
|
489
|
+
const SYNC_DENY=/\b(?:figma|frida|linkedin|phonepe|phonehome|phone-a-friend|phone-harness)\b/i;
|
|
490
|
+
const WATCH_DENY=/opencode|watchtower|framewatch|filewatch|firewatch|cloudwatch|swatch|cybersec|rewatch|langwatch|webwatch|vibewatch/i;
|
|
491
|
+
|
|
492
|
+
function contentKeywords(keywords){
|
|
493
|
+
const out=[];
|
|
494
|
+
for(const keyword of keywords||[]){
|
|
495
|
+
const text=String(keyword||'').toLowerCase();
|
|
496
|
+
if(!text||OPTIONAL_COVERAGE.has(text)||out.includes(text))continue;
|
|
497
|
+
out.push(text);
|
|
498
|
+
}
|
|
499
|
+
return out;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function botBlob(bot){
|
|
503
|
+
const caps=(bot.caps||[]).map(c=>String(c).replace(/^cap\./,'').replace(/^mcp\./,''));
|
|
504
|
+
return `${bot.name||''} ${bot.id||''} ${caps.join(' ')}`.toLowerCase();
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function botTokens(bot){
|
|
508
|
+
return new Set(words(botBlob(bot)).filter(token=>!CAP_NOISE.has(token)));
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function tokenCovers(tokens,raw,keyword){
|
|
512
|
+
const key=String(keyword||'').toLowerCase();
|
|
513
|
+
if(!key)return false;
|
|
514
|
+
if(key==='x')return tokens.has('twitter')||/(^|[^a-z0-9])x([^a-z0-9]|$)/.test(raw);
|
|
515
|
+
if(key.includes(' '))return raw.includes(key);
|
|
516
|
+
if(tokens.has(key))return true;
|
|
517
|
+
if(key.length>=6&&raw.includes(key))return true;
|
|
518
|
+
if(key.length>=4){
|
|
519
|
+
for(const token of tokens)if(token.startsWith(key)&&token.length<=key.length+3)return true;
|
|
520
|
+
}
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function timerToken(token){
|
|
525
|
+
if(token==='timer'||token==='timers')return true;
|
|
526
|
+
if(token.startsWith('schedule')||token.includes('scheduler'))return true;
|
|
527
|
+
if(token==='cron'||(token.startsWith('cron')&&!token.startsWith('cronomet')&&token.length<=10))return true;
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function familyHit(family,tokens,raw,keywords){
|
|
532
|
+
if(family==='wearable'){
|
|
533
|
+
const asked=(keywords||[]).join(' ').toLowerCase();
|
|
534
|
+
if(!/watch|step|fitness|pedometer|accelerometer|wear|health|garmin|fitbit|sensor/.test(asked))return null;
|
|
535
|
+
if(WATCH_DENY.test(raw))return null;
|
|
536
|
+
if([...tokens].some(token=>['garmin','fitbit','pedometer','accelerometer','smartwatch','wearable','fitness'].includes(token)))return 'related';
|
|
537
|
+
if(/apple-health|applehealth|health-connect|healthconnect|wear-os|wearos|galaxy-watch/.test(raw))return 'related';
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
if(family==='timer'){
|
|
541
|
+
const want=new Set((keywords||[]).map(keyword=>keyword.toLowerCase()));
|
|
542
|
+
if([...tokens].some(timerToken))return [...tokens].some(token=>token==='timer'||token==='timers')?'direct':'related';
|
|
543
|
+
if((want.has('trigger')||want.has('triggers')||want.has('triggered'))&&[...tokens].some(token=>token==='trigger'||token.startsWith('trigger')))return 'direct';
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
if(family==='sync'){
|
|
547
|
+
if(SYNC_DENY.test(raw))return null;
|
|
548
|
+
if([...tokens].some(token=>token==='android'||token==='adb'))return 'direct';
|
|
549
|
+
const want=new Set((keywords||[]).map(keyword=>keyword.toLowerCase()));
|
|
550
|
+
if(want.has('mobile')&&tokens.has('mobile'))return 'related';
|
|
551
|
+
if((want.has('phone')||want.has('phones'))&&(tokens.has('android')||tokens.has('adb')||tokens.has('sms')||[...tokens].some(token=>token.startsWith('push')||token.startsWith('notif'))))return 'related';
|
|
552
|
+
if((want.has('sync')||want.has('syncs')||want.has('synced')||want.has('syncing'))&&tokens.has('sync'))return 'related';
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
if(family==='nutrition'){
|
|
556
|
+
const want=new Set((keywords||[]).map(keyword=>keyword.toLowerCase()));
|
|
557
|
+
const allowed=new Set(['calorie','calories','nutrition','meal','meals','mealie','diet','diets','recipe','recipes']);
|
|
558
|
+
if(want.has('food')||want.has('foods')){allowed.add('food');allowed.add('foods');}
|
|
559
|
+
const hit=[...tokens].some(token=>allowed.has(token)||(allowed.has('meal')&&token.startsWith('meal')&&token.length<=7)||token.startsWith('calorie'));
|
|
560
|
+
return hit?'related':null;
|
|
561
|
+
}
|
|
562
|
+
if(family==='social'){
|
|
563
|
+
const want=new Set((keywords||[]).map(keyword=>keyword.toLowerCase()));
|
|
564
|
+
const nets=['facebook','instagram','twitter'].filter(net=>want.has(net)||(net==='twitter'&&want.has('x')));
|
|
565
|
+
const have=[...tokens].filter(token=>token==='facebook'||token==='instagram'||token==='twitter');
|
|
566
|
+
if(!have.length)return null;
|
|
567
|
+
if(nets.length&&!nets.some(net=>have.includes(net)))return null;
|
|
568
|
+
return 'direct';
|
|
569
|
+
}
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function overrideRank(scores,id){
|
|
574
|
+
const action=scores?.bots?.[id]?.action;
|
|
575
|
+
if(action==='boost')return -1;
|
|
576
|
+
if(action==='demote')return 1;
|
|
577
|
+
return 0;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function excluded(scores,id){
|
|
581
|
+
return scores?.bots?.[id]?.action==='exclude';
|
|
381
582
|
}
|
|
382
583
|
|
|
383
|
-
function
|
|
584
|
+
export function parseLimit(value){
|
|
585
|
+
if(value==null||value==='')return DEFAULT_LIMIT;
|
|
586
|
+
const text=String(value);
|
|
587
|
+
if(!/^\d+$/.test(text))throw new Error('Limit must be an integer from 1 to 20');
|
|
588
|
+
const n=Number(text);
|
|
589
|
+
if(!Number.isInteger(n)||n<MIN_LIMIT||n>MAX_LIMIT)throw new Error('Limit must be an integer from 1 to 20');
|
|
590
|
+
return n;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export function stackIdFor(parts,unmatched=[]){
|
|
594
|
+
const lines=[];
|
|
595
|
+
for(const part of parts){
|
|
596
|
+
lines.push('part:'+part.part);
|
|
597
|
+
for(const bot of part.bots)lines.push('bot:'+bot.id);
|
|
598
|
+
}
|
|
599
|
+
for(const item of unmatched)lines.push('unmatched:'+item.part);
|
|
600
|
+
return createHash('sha256').update(lines.join('\n')).digest('hex');
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export function feedbackLine(stackId){
|
|
604
|
+
return `kloudy feedback --stack ${stackId} --bot <id> --result worked|failed|partial [--reason <short>]`;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function selectFamily(bots,piece,limit,scores){
|
|
608
|
+
const want=piece.modalities||[];
|
|
609
|
+
const pool=[];
|
|
610
|
+
for(let i=0;i<bots.length;i++){
|
|
611
|
+
const bot=bots[i];
|
|
612
|
+
if(!installCommand(bot)||excluded(scores,bot.id))continue;
|
|
613
|
+
const raw=botBlob(bot);
|
|
614
|
+
const tokens=botTokens(bot);
|
|
615
|
+
const hit=familyHit(piece.family,tokens,raw,piece.keywords);
|
|
616
|
+
if(!hit)continue;
|
|
617
|
+
const need=contentKeywords(piece.keywords);
|
|
618
|
+
const hits=need.filter(keyword=>tokenCovers(tokens,raw,keyword)).length;
|
|
619
|
+
pool.push({b:bot,i,hits,direct:hit==='direct'||need.some(keyword=>tokenCovers(tokens,raw,keyword))?1:0});
|
|
620
|
+
}
|
|
621
|
+
const tier=cls=>CLASS_TIER[cls]??9;
|
|
622
|
+
pool.sort((x,y)=>y.direct-x.direct||tier(x.b.cls)-tier(y.b.cls)||overrideRank(scores,x.b.id)-overrideRank(scores,y.b.id)||primarySortHint(y.b.modality_primary,want)-primarySortHint(x.b.modality_primary,want)||y.hits-x.hits||(y.b.score||0)-(x.b.score||0)||x.i-y.i||String(x.b.id).localeCompare(String(y.b.id)));
|
|
623
|
+
const chosen=pool.slice(0,limit).map(x=>x.b);
|
|
624
|
+
const need=contentKeywords(piece.keywords);
|
|
625
|
+
const confidence=!chosen.length?'none':!need.length||need.every(keyword=>chosen.some(bot=>tokenCovers(botTokens(bot),botBlob(bot),keyword)))?'match':'weak';
|
|
626
|
+
return {bots:chosen,confidence,candidatesTotal:pool.length};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function botItem(bot,modality,keywords,tail){
|
|
630
|
+
return {
|
|
631
|
+
id:bot.id,
|
|
632
|
+
name:bot.name,
|
|
633
|
+
class:bot.cls,
|
|
634
|
+
command:installCommand(bot),
|
|
635
|
+
license:bot.license||'',
|
|
636
|
+
description:oneLine(bot),
|
|
637
|
+
why:whyLine(modality,keywords,tail),
|
|
638
|
+
modalities:[modality]
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function rankModality(bots,modality,keywords,scores){
|
|
384
643
|
const terms=[...new Set((keywords||[]).flatMap(words))];
|
|
385
644
|
const want=[modality];
|
|
386
645
|
const pool=[];
|
|
387
646
|
for(let i=0;i<bots.length;i++){
|
|
388
647
|
const b=bots[i];
|
|
389
|
-
if(!(b.modalities||[]).includes(modality))continue;
|
|
648
|
+
if(!(b.modalities||[]).includes(modality)||excluded(scores,b.id))continue;
|
|
390
649
|
const blob=(b.name+' '+b.id+' '+(b.caps||[]).join(' ')).toLowerCase();
|
|
391
650
|
pool.push({b,i,hits:terms.filter(t=>blob.includes(t)).length});
|
|
392
651
|
}
|
|
393
652
|
const tier=cls=>CLASS_TIER[cls]??9;
|
|
394
|
-
pool.sort((x,y)=>tier(x.b.cls)-tier(y.b.cls)||primarySortHint(y.b.modality_primary,want)-primarySortHint(x.b.modality_primary,want)||y.hits-x.hits||(y.b.score||0)-(x.b.score||0)||x.i-y.i||String(x.b.id).localeCompare(String(y.b.id)));
|
|
395
|
-
return pool.
|
|
653
|
+
pool.sort((x,y)=>tier(x.b.cls)-tier(y.b.cls)||overrideRank(scores,x.b.id)-overrideRank(scores,y.b.id)||primarySortHint(y.b.modality_primary,want)-primarySortHint(x.b.modality_primary,want)||y.hits-x.hits||(y.b.score||0)-(x.b.score||0)||x.i-y.i||String(x.b.id).localeCompare(String(y.b.id)));
|
|
654
|
+
return pool.map(x=>x.b);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function recordPiece(piece,bots,confidence,parts,unmatched,gaps,candidatesTotal=bots.length){
|
|
658
|
+
if(bots.length)parts.push({part:piece.text,modalities:piece.modalities,confidence,candidatesTotal,bots});
|
|
659
|
+
else unmatched.push({part:piece.text,reason:'no modality or catalog match'});
|
|
660
|
+
if(confidence==='match')return;
|
|
661
|
+
if(bots.length)gaps.push({part:piece.text,confidence,reason:WEAK_REASON[piece.family]||'Catalog hits only partly overlap this part.',kind:KIND_CATALOG});
|
|
662
|
+
const suggestKind=piece.family==='wearable'||piece.family==='sync'||piece.family==='social';
|
|
663
|
+
if(suggestKind&&SUGGEST[piece.family])gaps.push({part:piece.text,confidence:'none',reason:SUGGEST[piece.family],kind:KIND_SUGGEST});
|
|
664
|
+
else if(!bots.length)gaps.push({part:piece.text,confidence:'none',reason:'no modality or catalog match'});
|
|
396
665
|
}
|
|
397
666
|
|
|
398
667
|
export function recommendGoal(goal,options={}){
|
|
399
668
|
const source=options.bots?{bots:options.bots}:loadLocal();
|
|
400
669
|
if(!source?.bots)throw new Error('No local catalog. Run `kloudy bots sync`.');
|
|
401
|
-
const limit=options.perModality??PER_MODALITY;
|
|
670
|
+
const limit=parseLimit(options.limit??options.perModality??PER_MODALITY);
|
|
671
|
+
const scores=normalizeScores(options.scores);
|
|
402
672
|
const pieces=decomposeGoal(goal);
|
|
403
673
|
const parts=[];
|
|
404
674
|
const unmatched=[];
|
|
405
|
-
const
|
|
675
|
+
const gaps=[];
|
|
676
|
+
const consider=pieces.length?pieces:[{text:String(goal||'').trim(),modalities:[],keywords:[],family:null}];
|
|
406
677
|
for(const piece of consider){
|
|
407
678
|
if(!piece.text)continue;
|
|
679
|
+
if(piece.family){
|
|
680
|
+
const picked=selectFamily(source.bots,piece,limit,scores);
|
|
681
|
+
const modality=piece.modalities[0];
|
|
682
|
+
const bots=picked.bots.map(bot=>botItem(bot,modality,piece.keywords,'catalog name search'));
|
|
683
|
+
for(const bot of bots)bot.modalities=[...piece.modalities];
|
|
684
|
+
recordPiece(piece,bots,picked.confidence,parts,unmatched,gaps,picked.candidatesTotal);
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
408
687
|
if(!piece.modalities.length){
|
|
409
|
-
|
|
688
|
+
recordPiece(piece,[], 'none',parts,unmatched,gaps,0);
|
|
410
689
|
continue;
|
|
411
690
|
}
|
|
412
691
|
const seen=new Map();
|
|
413
|
-
const
|
|
692
|
+
const ranked=[];
|
|
414
693
|
for(const modality of piece.modalities){
|
|
415
|
-
for(const bot of rankModality(source.bots,modality,piece.keywords,
|
|
694
|
+
for(const bot of rankModality(source.bots,modality,piece.keywords,scores)){
|
|
416
695
|
const prev=seen.get(bot.id);
|
|
417
696
|
if(prev){
|
|
418
697
|
if(!prev.modalities.includes(modality))prev.modalities.push(modality);
|
|
419
698
|
continue;
|
|
420
699
|
}
|
|
421
|
-
const item={
|
|
422
|
-
id:bot.id,
|
|
423
|
-
name:bot.name,
|
|
424
|
-
class:bot.cls,
|
|
425
|
-
command:installCommand(bot),
|
|
426
|
-
license:bot.license||'',
|
|
427
|
-
description:oneLine(bot),
|
|
428
|
-
why:whyLine(modality,piece.keywords),
|
|
429
|
-
modalities:[modality]
|
|
430
|
-
};
|
|
700
|
+
const item={bot,modalities:[modality]};
|
|
431
701
|
seen.set(bot.id,item);
|
|
432
|
-
|
|
702
|
+
ranked.push(item);
|
|
433
703
|
}
|
|
434
704
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
705
|
+
const bots=ranked.slice(0,limit).map(({bot,modalities})=>{
|
|
706
|
+
const item=botItem(bot,modalities[0],piece.keywords);
|
|
707
|
+
item.modalities=modalities;
|
|
708
|
+
return item;
|
|
709
|
+
});
|
|
710
|
+
recordPiece(piece,bots,bots.length?'match':'none',parts,unmatched,gaps,ranked.length);
|
|
440
711
|
}
|
|
441
|
-
|
|
712
|
+
const complete=parts.length>0&&parts.every(part=>part.confidence==='match')&&gaps.length===0&&unmatched.length===0;
|
|
713
|
+
const stackId=stackIdFor(parts,unmatched);
|
|
714
|
+
return {schema:RECOMMEND_SCHEMA,catalog:CATALOG_NAME,version:options.version||packageVersion(),goal:String(goal??''),limit,stackId,feedback:feedbackLine(stackId),complete,parts,unmatched,gaps};
|
|
442
715
|
}
|
|
443
716
|
|
|
444
717
|
export function formatRecommend(result){
|
|
445
|
-
const lines=[`${result.catalog} · package ${result.version}`,`Goal: ${result.goal}`];
|
|
718
|
+
const lines=[`${result.catalog} · package ${result.version}`,`Goal: ${result.goal}`,`complete=${result.complete?'true':'false'}`,`limit=${result.limit}`,`stackId: ${result.stackId}`,`feedback: ${result.feedback}`];
|
|
446
719
|
result.parts.forEach((part,index)=>{
|
|
447
|
-
lines.push('',`${index+1}. ${part.part}`,`Modalities: ${part.modalities.join(', ')}`);
|
|
720
|
+
lines.push('',`${index+1}. ${part.part}`,`Modalities: ${part.modalities.join(', ')}`,`Confidence: ${part.confidence}`,`Candidates: ${part.candidatesTotal}`);
|
|
448
721
|
part.bots.forEach((bot,n)=>{
|
|
449
722
|
lines.push(` ${n+1}) ${bot.id} [${bot.class}] ${bot.license||'unknown license'}`,` ${bot.command}`,` ${bot.description}`,` why: ${bot.why}`);
|
|
450
723
|
});
|
|
@@ -453,15 +726,20 @@ export function formatRecommend(result){
|
|
|
453
726
|
lines.push('','Unmatched');
|
|
454
727
|
for(const item of result.unmatched)lines.push(`- ${item.part}: ${item.reason}`);
|
|
455
728
|
}
|
|
729
|
+
lines.push('','Gaps');
|
|
730
|
+
if(!result.gaps.length)lines.push('(none)');
|
|
731
|
+
else for(const gap of result.gaps)lines.push(`- ${gap.part} [${gap.confidence}]${gap.kind?` ${gap.kind}:`:''} ${gap.reason}`);
|
|
456
732
|
return lines.join('\n');
|
|
457
733
|
}
|
|
458
734
|
|
|
459
|
-
export function recommendCommand(args,opts={}){
|
|
735
|
+
export async function recommendCommand(args,opts={}){
|
|
460
736
|
const out=opts.out||(s=>console.log(s));
|
|
461
737
|
const o=flags(args);
|
|
462
738
|
const goal=o._.join(' ').trim();
|
|
463
|
-
if(!goal)throw new Error('Usage: kloudy recommend <goal> [--json]');
|
|
464
|
-
const
|
|
739
|
+
if(!goal)throw new Error('Usage: kloudy recommend <goal> [--json] [--limit N]');
|
|
740
|
+
const limit=parseLimit(o.limit);
|
|
741
|
+
const scores=opts.scores!==undefined?opts.scores:await loadScoreOverrides({offline:!!o.offline,fetchImpl:opts.fetch,now:opts.now,dir:opts.dataDir,url:opts.scoresUrl});
|
|
742
|
+
const result=recommendGoal(goal,{limit,scores});
|
|
465
743
|
out(o.json?JSON.stringify(result,null,2):formatRecommend(result));
|
|
466
744
|
return result;
|
|
467
745
|
}
|
package/src/scores.mjs
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Optional recommend score overrides. No model call.
|
|
2
|
+
// GET https://kloudy.ai/api/feedback/scores, cache about 24h, else bundled ranking.
|
|
3
|
+
import {existsSync,mkdirSync,readFileSync,writeFileSync} from 'node:fs';
|
|
4
|
+
import {homedir} from 'node:os';
|
|
5
|
+
import {join} from 'node:path';
|
|
6
|
+
|
|
7
|
+
export const SCORES_URL='https://kloudy.ai/api/feedback/scores';
|
|
8
|
+
export const SCORES_SCHEMA='kloudy.feedback-scores/1';
|
|
9
|
+
export const SCORE_TTL_MS=24*60*60*1000;
|
|
10
|
+
const ACTIONS=new Set(['boost','demote','exclude']);
|
|
11
|
+
const DISABLED=new Set(['','off','offline','0','false']);
|
|
12
|
+
|
|
13
|
+
export function dataDir(){
|
|
14
|
+
return process.env.KLOUDY_DATA_DIR||join(homedir(),'.kloudy');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function scoresCachePath(dir=dataDir()){
|
|
18
|
+
return join(dir,'feedback-scores-cache.json');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function botsOf(body){
|
|
22
|
+
if(body.bots&&typeof body.bots==='object'&&!Array.isArray(body.bots))return body.bots;
|
|
23
|
+
const scores=body.scores;
|
|
24
|
+
if(!scores||typeof scores!=='object'||Array.isArray(scores))return null;
|
|
25
|
+
if(scores.bots&&typeof scores.bots==='object'&&!Array.isArray(scores.bots))return scores.bots;
|
|
26
|
+
return scores;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function normalizeScores(body){
|
|
30
|
+
if(!body||typeof body!=='object'||Array.isArray(body))return null;
|
|
31
|
+
const source=botsOf(body);
|
|
32
|
+
if(!source)return null;
|
|
33
|
+
const bots={};
|
|
34
|
+
for(const [id,row] of Object.entries(source)){
|
|
35
|
+
if(typeof id!=='string'||!id)continue;
|
|
36
|
+
const action=typeof row==='string'?row:row&&row.action;
|
|
37
|
+
if(ACTIONS.has(action))bots[id]={action};
|
|
38
|
+
}
|
|
39
|
+
return {schema:typeof body.schema==='string'?body.schema:SCORES_SCHEMA,bots};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function scoresUrl(explicit){
|
|
43
|
+
const value=explicit??process.env.KLOUDY_SCORES_URL??SCORES_URL;
|
|
44
|
+
return DISABLED.has(String(value).trim())?null:value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readCache(file,now){
|
|
48
|
+
try{
|
|
49
|
+
if(!existsSync(file))return null;
|
|
50
|
+
const wrapped=JSON.parse(readFileSync(file,'utf8'));
|
|
51
|
+
const scores=normalizeScores(wrapped.scores??wrapped);
|
|
52
|
+
const fetchedAt=Number(wrapped.fetchedAt);
|
|
53
|
+
if(!scores||!Number.isFinite(fetchedAt))return null;
|
|
54
|
+
return {fetchedAt,scores,fresh:now-fetchedAt>=0&&now-fetchedAt<SCORE_TTL_MS};
|
|
55
|
+
}catch{
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function writeCache(file,fetchedAt,scores){
|
|
61
|
+
mkdirSync(join(file,'..'),{recursive:true});
|
|
62
|
+
writeFileSync(file,JSON.stringify({fetchedAt,scores})+'\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function loadScoreOverrides({fetchImpl=fetch,now=Date.now(),offline=false,dir=dataDir(),url}={}){
|
|
66
|
+
const cached=readCache(scoresCachePath(dir),now);
|
|
67
|
+
if(cached?.fresh)return cached.scores;
|
|
68
|
+
const endpoint=scoresUrl(url);
|
|
69
|
+
if(offline||!endpoint)return null;
|
|
70
|
+
try{
|
|
71
|
+
const response=await fetchImpl(endpoint,{method:'GET',redirect:'error',credentials:'omit',headers:{accept:'application/json'},signal:AbortSignal.timeout(4000)});
|
|
72
|
+
if(!response?.ok)return null;
|
|
73
|
+
const scores=normalizeScores(await response.json());
|
|
74
|
+
if(!scores)return null;
|
|
75
|
+
writeCache(scoresCachePath(dir),now,scores);
|
|
76
|
+
return scores;
|
|
77
|
+
}catch{
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|