@kaleidorg/mind 0.9.0 → 0.10.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.
Files changed (85) hide show
  1. package/dist/engine/answer.d.ts +3 -0
  2. package/dist/engine/answer.d.ts.map +1 -1
  3. package/dist/engine/answer.js +19 -2
  4. package/dist/engine/answer.js.map +1 -1
  5. package/dist/engine/rgb-ticker.d.ts +17 -0
  6. package/dist/engine/rgb-ticker.d.ts.map +1 -0
  7. package/dist/engine/rgb-ticker.js +47 -0
  8. package/dist/engine/rgb-ticker.js.map +1 -0
  9. package/dist/engine.d.ts.map +1 -1
  10. package/dist/engine.js +8 -2
  11. package/dist/engine.js.map +1 -1
  12. package/dist/guards.d.ts +12 -0
  13. package/dist/guards.d.ts.map +1 -1
  14. package/dist/guards.js +60 -0
  15. package/dist/guards.js.map +1 -1
  16. package/dist/index.d.ts +2 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +2 -2
  19. package/dist/index.js.map +1 -1
  20. package/dist/kaleidoswap/contract.d.ts +7 -0
  21. package/dist/kaleidoswap/contract.d.ts.map +1 -1
  22. package/dist/kaleidoswap/contract.js +71 -17
  23. package/dist/kaleidoswap/contract.js.map +1 -1
  24. package/dist/kaleidoswap/index.d.ts +1 -1
  25. package/dist/kaleidoswap/index.d.ts.map +1 -1
  26. package/dist/kaleidoswap/index.js +1 -1
  27. package/dist/kaleidoswap/index.js.map +1 -1
  28. package/dist/qvac/parse.d.ts.map +1 -1
  29. package/dist/qvac/parse.js +13 -0
  30. package/dist/qvac/parse.js.map +1 -1
  31. package/dist/qvac/provider.d.ts.map +1 -1
  32. package/dist/qvac/provider.js +127 -95
  33. package/dist/qvac/provider.js.map +1 -1
  34. package/dist/recipe/asset-send.js +1 -1
  35. package/dist/recipe/asset-send.js.map +1 -1
  36. package/dist/testing/mock-wallet.d.ts.map +1 -1
  37. package/dist/testing/mock-wallet.js +6 -0
  38. package/dist/testing/mock-wallet.js.map +1 -1
  39. package/dist/wallet/contract.d.ts +5 -0
  40. package/dist/wallet/contract.d.ts.map +1 -1
  41. package/dist/wallet/contract.js +39 -6
  42. package/dist/wallet/contract.js.map +1 -1
  43. package/package.json +1 -1
  44. package/scripts/snapshot-mcp-tools.mjs +38 -0
  45. package/skills/README.md +98 -64
  46. package/skills/bitrefill/SKILL.md +30 -157
  47. package/skills/channel-manager/SKILL.md +31 -52
  48. package/skills/flashnet-swaps/SKILL.md +24 -150
  49. package/skills/kaleido-node/SKILL.md +25 -55
  50. package/skills/kaleido-trading/SKILL.md +28 -172
  51. package/skills/kaleido-trading/references/assets.md +4 -4
  52. package/skills/kaleido-trading/references/atomic.md +5 -7
  53. package/skills/merchant-finder/SKILL.md +25 -108
  54. package/skills/paid-data/SKILL.md +25 -58
  55. package/skills/portfolio-manager/SKILL.md +26 -60
  56. package/skills/rgb-lightning-node/SKILL.md +37 -255
  57. package/skills/rgb-lightning-node/references/channels.md +34 -0
  58. package/skills/spark-wallet/SKILL.md +26 -228
  59. package/skills/submarine-swaps/SKILL.md +19 -37
  60. package/skills/wallet-assistant/SKILL.md +26 -44
  61. package/src/engine/answer.ts +26 -2
  62. package/src/engine/rgb-ticker.test.ts +29 -0
  63. package/src/engine/rgb-ticker.ts +54 -0
  64. package/src/engine.ts +16 -2
  65. package/src/funnel.mind.test.ts +6 -5
  66. package/src/guards.test.ts +39 -0
  67. package/src/guards.ts +61 -0
  68. package/src/index.ts +4 -0
  69. package/src/kaleidoswap/contract.test.ts +32 -3
  70. package/src/kaleidoswap/contract.ts +65 -17
  71. package/src/kaleidoswap/index.ts +1 -0
  72. package/src/qvac/parse.test.ts +14 -0
  73. package/src/qvac/parse.ts +11 -0
  74. package/src/qvac/provider.test.ts +33 -0
  75. package/src/qvac/provider.ts +37 -4
  76. package/src/recipe/asset-send.ts +1 -1
  77. package/src/recipe/recipe.test.ts +1 -1
  78. package/src/skills/catalog.test.ts +215 -0
  79. package/src/skills/mcp-tools.snapshot.json +1938 -0
  80. package/src/testing/mock-wallet.ts +6 -0
  81. package/src/wallet/contract.test.ts +20 -1
  82. package/src/wallet/contract.ts +36 -6
  83. package/skills/dca/SKILL.md +0 -48
  84. package/skills/kaleido-lsps/SKILL.md +0 -131
  85. package/skills/liquidity-optimizer/SKILL.md +0 -91
@@ -70,14 +70,16 @@ export const KALEIDOSWAP_TOOLS: KaleidoswapToolDef[] = [
70
70
 
71
71
  t('market',
72
72
  'kaleidoswap_get_quote',
73
- 'Get an executable quote for swapping a specific amount on one pair. Returns a quote id (use it with atomic_init), the expected receive amount, fees, slippage, and how long the quote is valid for. Re-quote rather than reusing a stale id.',
73
+ 'Get an executable quote for one pair. Amounts are DISPLAY units (0.0005 = 0.0005 BTC = 50,000 sats). Give exactly one of from_amount (sell a fixed input) or to_amount (buy a fixed output). Returns rfq_id, from_asset/to_asset with amount_display and amount_raw, price and expires_at (~60s).',
74
74
  {
75
- from_asset: { type: 'string', description: 'Asset to spend, e.g. "BTC" or "USDT".' },
76
- to_asset: { type: 'string', description: 'Asset to receive, e.g. "USDT" or "BTC".' },
77
- amount: { type: 'number', description: 'Amount of from_asset to swap. BTC is in satoshis; RGB assets use their asset-defined precision.' },
78
- side: { type: 'string', enum: ['buy', 'sell'], description: 'Default "sell" (you sell from_asset). Use "buy" only when from_asset is the quote currency you spend to acquire to_asset.' },
75
+ from_asset_id: { type: 'string', description: "Asset to sell: ticker ('BTC', 'USDT') or RGB id ('rgb:…')." },
76
+ to_asset_id: { type: 'string', description: "Asset to buy: ticker ('USDT', 'BTC') or RGB id ('rgb:…')." },
77
+ from_layer: { type: 'string', description: "Optional: 'BTC_LN', 'RGB_LN', 'BTC_SPARK'. Derived from the asset when omitted." },
78
+ to_layer: { type: 'string', description: "Optional: 'RGB_LN', 'BTC_LN', 'BTC_SPARK'. Derived from the asset when omitted." },
79
+ from_amount: { type: 'number', description: 'Amount to SELL in display units (e.g. 0.001 BTC, 10 USDT).' },
80
+ to_amount: { type: 'number', description: 'Amount to BUY in display units (e.g. 10 USDT).' },
79
81
  },
80
- ['from_asset', 'to_asset', 'amount']),
82
+ ['from_asset_id', 'to_asset_id']),
81
83
 
82
84
  t('market',
83
85
  'kaleidoswap_get_nodeinfo',
@@ -86,30 +88,36 @@ export const KALEIDOSWAP_TOOLS: KaleidoswapToolDef[] = [
86
88
  // ─── atomic (the trust-minimised swap chain — used by the recipe) ───────
87
89
  t('atomic',
88
90
  'kaleidoswap_atomic_init',
89
- "Initialise an atomic swap from a quote. Requires the receiver's RGB/LN invoice so the maker can lock the outgoing leg. SPEND: confirmation-gated. Returns the maker's invoice for the user to pay and an atomic id to track.",
91
+ 'Start an atomic swap from a fresh quote. SPEND: confirmation-gated. Pass the quote rfq_id, both asset ids and the quote legs\' amount_raw values unchanged. Returns swapstring, payment_hash and access_token (keep it for status).',
90
92
  {
91
- quote_id: { type: 'string', description: 'The quote id from kaleidoswap_get_quote.' },
92
- receive_invoice: { type: 'string', description: "The user's RGB or Lightning invoice for to_asset, created on the user's own node." },
93
+ rfq_id: { type: 'string', description: 'rfq_id from kaleidoswap_get_quote.' },
94
+ from_asset_id: { type: 'string', description: 'from_asset.asset_id from the quote.' },
95
+ from_amount_raw: { type: 'integer', description: 'from_asset.amount_raw from the quote, unchanged.' },
96
+ to_asset_id: { type: 'string', description: 'to_asset.asset_id from the quote.' },
97
+ to_amount_raw: { type: 'integer', description: 'to_asset.amount_raw from the quote, unchanged.' },
93
98
  },
94
- ['quote_id', 'receive_invoice'],
99
+ ['rfq_id', 'from_asset_id', 'from_amount_raw', 'to_asset_id', 'to_amount_raw'],
95
100
  /* spend */ true),
96
101
 
97
102
  t('atomic',
98
103
  'kaleidoswap_atomic_execute',
99
- "Tell the maker to release the receive leg now that the user has paid the maker's invoice. SPEND: confirmation-gated (committing the swap). Returns an updated atomic status.",
104
+ 'Confirm the swap after rln_atomic_taker whitelisted the swapstring. SPEND: confirmation-gated. taker_pubkey is the pubkey from rln_get_node_info.',
100
105
  {
101
- atomic_id: { type: 'string', description: 'The atomic id from kaleidoswap_atomic_init.' },
106
+ swapstring: { type: 'string', description: 'swapstring from kaleidoswap_atomic_init.' },
107
+ taker_pubkey: { type: 'string', description: 'Node pubkey from rln_get_node_info.' },
108
+ payment_hash: { type: 'string', description: 'payment_hash from kaleidoswap_atomic_init.' },
102
109
  },
103
- ['atomic_id'],
110
+ ['swapstring', 'taker_pubkey', 'payment_hash'],
104
111
  /* spend */ true),
105
112
 
106
113
  t('atomic',
107
114
  'kaleidoswap_atomic_status',
108
- 'Poll the status of an atomic swap — pending_payment / paid / settling / completed / failed / expired. Use this in a loop after execute until it terminates.',
115
+ 'Poll an atomic swap by payment_hash: Waiting → Pending → Succeeded | Expired | Failed.',
109
116
  {
110
- atomic_id: { type: 'string', description: 'The atomic id from kaleidoswap_atomic_init.' },
117
+ payment_hash: { type: 'string', description: 'payment_hash from kaleidoswap_atomic_init.' },
118
+ access_token: { type: 'string', description: 'access_token from kaleidoswap_atomic_init, when the host needs it.' },
111
119
  },
112
- ['atomic_id']),
120
+ ['payment_hash']),
113
121
 
114
122
  // ─── liquidity (buy a NEW channel pre-loaded with an asset — onboarding) ──
115
123
  t('liquidity',
@@ -157,6 +165,46 @@ export function kaleidoswapTools(
157
165
  return KALEIDOSWAP_TOOLS.filter((x) => groups.has(x.group));
158
166
  }
159
167
 
168
+ const isBtc = (asset: unknown) => typeof asset === 'string' && /^(btc|sats?)$/i.test(asset.trim());
169
+
170
+ /**
171
+ * Map kaleido-mcp argument names onto the pre-0.9 contract names (and back), so
172
+ * one call shape works on every surface and older host handlers keep working.
173
+ * Canonical names win; legacy names are only filled in when absent. The legacy
174
+ * quote `amount` is sats for BTC and display units otherwise.
175
+ */
176
+ export function normalizeKaleidoswapArgs(name: string, args: Record<string, unknown>): Record<string, unknown> {
177
+ const a: Record<string, unknown> = { ...args };
178
+ const alias = (canonical: string, legacy: string) => {
179
+ if (a[canonical] == null && a[legacy] != null) a[canonical] = a[legacy];
180
+ if (a[legacy] == null && a[canonical] != null) a[legacy] = a[canonical];
181
+ };
182
+ if (name === 'kaleidoswap_get_quote') {
183
+ if (a.from_asset_id == null && a.from_asset != null) a.from_asset_id = a.from_asset;
184
+ if (a.to_asset_id == null && a.to_asset != null) a.to_asset_id = a.to_asset;
185
+ if (a.from_amount == null && a.to_amount == null && a.amount != null) {
186
+ const side = a.amount_side === 'to' || a.side === 'buy' ? 'to' : 'from';
187
+ const asset = side === 'to' ? a.to_asset_id : a.from_asset_id;
188
+ const display = isBtc(asset) ? Number(a.amount) / 1e8 : Number(a.amount);
189
+ a[side === 'to' ? 'to_amount' : 'from_amount'] = display;
190
+ }
191
+ a.from_asset ??= a.from_asset_id;
192
+ a.to_asset ??= a.to_asset_id;
193
+ if (a.amount == null) {
194
+ const toSide = a.from_amount == null && a.to_amount != null;
195
+ const display = Number(toSide ? a.to_amount : a.from_amount);
196
+ const asset = toSide ? a.to_asset_id : a.from_asset_id;
197
+ if (Number.isFinite(display)) a.amount = isBtc(asset) ? Math.round(display * 1e8) : display;
198
+ if (toSide) a.amount_side = 'to';
199
+ }
200
+ } else if (name === 'kaleidoswap_atomic_init') {
201
+ alias('rfq_id', 'quote_id');
202
+ } else if (name === 'kaleidoswap_atomic_execute' || name === 'kaleidoswap_atomic_status') {
203
+ alias('payment_hash', 'atomic_id');
204
+ }
205
+ return a;
206
+ }
207
+
160
208
  /** A handler bound to one contract tool. Args validated by JSON schema upstream. */
161
209
  export type KaleidoswapHandler = (args: Record<string, unknown>) => Promise<unknown>;
162
210
 
@@ -199,7 +247,7 @@ export function bindKaleidoswapTools(
199
247
  description: def.description,
200
248
  parameters: def.parameters,
201
249
  requiresConfirmation: def.requiresConfirmation,
202
- handler,
250
+ handler: (args) => handler(normalizeKaleidoswapArgs(def.name, args)),
203
251
  });
204
252
  }
205
253
  return new InProcessToolSource(opts.id ?? 'kaleidoswap', bound);
@@ -3,6 +3,7 @@ export {
3
3
  KALEIDOSWAP_TOOLS,
4
4
  KALEIDOSWAP_SPEND_TOOLS,
5
5
  isKaleidoswapSpendTool,
6
+ normalizeKaleidoswapArgs,
6
7
  getKaleidoswapTool,
7
8
  kaleidoswapTools,
8
9
  bindKaleidoswapTools,
@@ -126,3 +126,17 @@ describe('finalToTurn', () => {
126
126
  });
127
127
  });
128
128
  });
129
+
130
+ describe('extractTextToolCalls: skill example notation', () => {
131
+ it('recovers a reply that is only `tool_name {json}`', () => {
132
+ expect(extractTextToolCalls('`rln_create_ln_invoice {"amount_sats":5000}`')).toEqual([
133
+ { name: 'rln_create_ln_invoice', arguments: { amount_sats: 5000 } },
134
+ ]);
135
+ expect(extractTextToolCalls('rln_list_assets {}')).toEqual([{ name: 'rln_list_assets', arguments: {} }]);
136
+ });
137
+
138
+ it('ignores prose that mentions a call', () => {
139
+ expect(extractTextToolCalls('I would call rln_list_assets {} next.')).toEqual([]);
140
+ expect(extractTextToolCalls('listassets {"a":1}')).toEqual([]);
141
+ });
142
+ });
package/src/qvac/parse.ts CHANGED
@@ -136,6 +136,17 @@ export function extractTextToolCalls(
136
136
  if (c) calls.push(c);
137
137
  }
138
138
  if (calls.length) return calls;
139
+ // The whole reply is one call in the skills' example notation,
140
+ // `tool_name {"arg": 1}` (models without native tool calling copy it).
141
+ const bare = text.trim().match(/^`?([a-z][a-z0-9]*(?:_[a-z0-9]+)+)\s*(\{[\s\S]*\})`?$/);
142
+ if (bare) {
143
+ try {
144
+ const args = JSON.parse(bare[2]!);
145
+ if (args && typeof args === 'object' && !Array.isArray(args)) return [{ name: bare[1]!, arguments: args }];
146
+ } catch {
147
+ /* not a call */
148
+ }
149
+ }
139
150
  // No tags — accept a bare tool-call object only at the very start of the
140
151
  // text (so we don't misread JSON the model is merely talking about).
141
152
  if (/^\s*\{?\s*"name"\s*:/i.test(text)) {
@@ -123,6 +123,39 @@ describe('createQvacProvider.runTurn', () => {
123
123
  expect(deleted).toEqual([{ kvCacheKey: 'run-1' }]);
124
124
  });
125
125
 
126
+ it('retries a turn without reasoning when the tool grammar rejects the inserted </think>', async () => {
127
+ const calls: any[] = [];
128
+ const fn = (params: any) => {
129
+ calls.push(params);
130
+ if (calls.length === 1) throw new Error('Unexpected empty grammar stack after accepting piece: </think> (248069)');
131
+ return { requestId: 'req-2', events: (async function* () {})(), final: Promise.resolve({ contentText: 'ok', toolCalls: [], raw: { fullText: 'ok' } }) };
132
+ };
133
+ const p = createQvacProvider({ completion: fn as any, cancel: noopCancel, getModelId: () => 'm1', maxThinkingTokens: 128 });
134
+ const out = await p.runTurn({ messages: [{ role: 'user', content: 'x' }], tools: [] });
135
+ expect(out.text).toBe('ok');
136
+ expect(calls.map((c) => c.generationParams?.reasoning_budget)).toEqual([128, 0]);
137
+ await expect(
138
+ createQvacProvider({ completion: (() => { throw new Error('boom'); }) as any, cancel: noopCancel, getModelId: () => 'm1' })
139
+ .runTurn({ messages: [{ role: 'user', content: 'x' }], tools: [] }),
140
+ ).rejects.toThrow('boom');
141
+ });
142
+
143
+ it("retries without tool_choice when the chat template can't force a tool call", async () => {
144
+ const calls: any[] = [];
145
+ const fn = (params: any) => {
146
+ calls.push(params);
147
+ if (params.generationParams?.tool_choice) {
148
+ throw new Error('[TextLlm] generationParams.tool_choice demanded a tool call, but the chat template did not render the tool definitions');
149
+ }
150
+ return { requestId: 'r', events: (async function* () {})(), final: Promise.resolve({ contentText: 'ok', toolCalls: [], raw: { fullText: 'ok' } }) };
151
+ };
152
+ const tool = { name: 'get_balance', description: 'b', parameters: { type: 'object', properties: {} } };
153
+ const p = createQvacProvider({ completion: fn as any, cancel: noopCancel, getModelId: () => 'm1' });
154
+ const out = await p.runTurn({ messages: [{ role: 'user', content: 'x' }], tools: [tool as any], toolChoice: 'required' });
155
+ expect(out.text).toBe('ok');
156
+ expect(calls.map((c) => c.generationParams?.tool_choice)).toEqual(['required', undefined]);
157
+ });
158
+
126
159
  it('keeps the reasoning budget below the output cap', async () => {
127
160
  const { fn, calls } = fakeCompletion({ contentText: 'ok', toolCalls: [], raw: { fullText: 'ok' } });
128
161
  const p = createQvacProvider({ completion: fn as any, cancel: noopCancel, getModelId: () => 'm1', defaultMaxTokens: 512, maxThinkingTokens: 512 });
@@ -76,11 +76,26 @@ export interface QvacTurnInput extends TurnInput {
76
76
  onStats?: (stats: QvacTurnStats) => void;
77
77
  }
78
78
 
79
- export function createQvacProvider(options: QvacProviderOptions): LLMProvider {
80
- return {
81
- name: 'qvac',
79
+ /**
80
+ * llama.cpp's tool-call grammar can reject the `</think>` it inserts when a
81
+ * reasoning budget runs out ("Unexpected empty grammar stack after accepting
82
+ * piece: </think>"), which aborts the turn.
83
+ */
84
+ function isReasoningGrammarError(err: unknown): boolean {
85
+ return /empty grammar stack/i.test(err instanceof Error ? err.message : String(err));
86
+ }
82
87
 
83
- async runTurn(input: QvacTurnInput): Promise<TurnOutput> {
88
+ /**
89
+ * Some chat templates (e.g. Llama 3.2 tool-calling 1B) don't render the tool
90
+ * definitions the way a forced `tool_choice` needs ("tool_choice demanded a
91
+ * tool call, but the chat template did not render the tool definitions").
92
+ */
93
+ function isToolChoiceTemplateError(err: unknown): boolean {
94
+ return /did not render the tool definitions/i.test(err instanceof Error ? err.message : String(err));
95
+ }
96
+
97
+ export function createQvacProvider(options: QvacProviderOptions): LLMProvider {
98
+ const runTurnOnce = async (input: QvacTurnInput): Promise<TurnOutput> => {
84
99
  const modelId = options.getModelId();
85
100
  if (!modelId) throw new Error('QVAC model not loaded');
86
101
 
@@ -192,6 +207,24 @@ export function createQvacProvider(options: QvacProviderOptions): LLMProvider {
192
207
  inference,
193
208
  ...(incomplete ? { incomplete: true } : {}),
194
209
  };
210
+ };
211
+
212
+ return {
213
+ name: 'qvac',
214
+
215
+ async runTurn(input: QvacTurnInput): Promise<TurnOutput> {
216
+ try {
217
+ return await runTurnOnce(input);
218
+ } catch (err) {
219
+ // Retry once without reasoning: no budget, so nothing to insert.
220
+ if (input.thinking !== 'off' && isReasoningGrammarError(err)) return runTurnOnce({ ...input, thinking: 'off' });
221
+ // Retry once without forcing a tool call; the model may still call one.
222
+ if (input.toolChoice && isToolChoiceTemplateError(err)) {
223
+ const { toolChoice: _forced, ...rest } = input;
224
+ return runTurnOnce(rest);
225
+ }
226
+ throw err;
227
+ }
195
228
  },
196
229
 
197
230
  async endSession(sessionKey: string): Promise<void> {
@@ -69,7 +69,7 @@ export const assetSendRecipe: Recipe = {
69
69
  tool: 'rln_send_asset',
70
70
  args: (ctx: RecipeContext) => {
71
71
  const contact = ctx.results.contact as { ln_address?: string } | undefined;
72
- return { asset: ctx.slots.asset, amount: ctx.slots.amount, to: contact?.ln_address ?? ctx.slots.recipient };
72
+ return { asset_id: ctx.slots.asset, amount: ctx.slots.amount, recipient_id: contact?.ln_address ?? ctx.slots.recipient };
73
73
  },
74
74
  },
75
75
  summary: (ctx) => {
@@ -257,7 +257,7 @@ describe('runRecipe — asset send', () => {
257
257
  const res = await runRecipe(assetSendRecipe, 'send 10 usdt to bob', { provider: approve, tools, onConfirm });
258
258
  expect(res.status).toBe('done');
259
259
  expect(onConfirm).toHaveBeenCalledOnce();
260
- expect(sent[0]).toEqual({ asset: 'USDT', amount: 10, to: 'bob@x.com' });
260
+ expect(sent[0]).toEqual({ asset_id: 'USDT', amount: 10, recipient_id: 'bob@x.com' });
261
261
  });
262
262
  });
263
263
 
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Skill catalog checks: every packaged skill must name real tools and every
3
+ * worked example must be a valid call, both against the in-app contracts and
4
+ * against kaleido-mcp (src/skills/mcp-tools.snapshot.json, refreshed with
5
+ * scripts/snapshot-mcp-tools.mjs).
6
+ */
7
+
8
+ import { describe, it, expect } from 'vitest';
9
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import type { ToolDef } from '../types.js';
12
+ import { loadSkillsDir, packagedSkillsDir } from './loader.js';
13
+ import { SkillRegistry } from './registry.js';
14
+ import { selectAvailableSkill } from './select.js';
15
+ import { createSkillReferenceToolSource } from './reference-source.js';
16
+ import { WALLET_TOOLS } from '../wallet/contract.js';
17
+ import { KALEIDOSWAP_TOOLS } from '../kaleidoswap/contract.js';
18
+ import { LSPS1_TOOLS } from '../lsps1/contract.js';
19
+ import { SUBMARINE_TOOLS } from '../submarine/contract.js';
20
+ import { FLASHNET_TOOLS } from '../flashnet/contract.js';
21
+ import { BITREFILL_TOOLS } from '../bitrefill/contract.js';
22
+ import { createBtcMapToolSource } from '../knowledge/btc-map.js';
23
+ import { createL402ToolSource } from '../tools/l402.js';
24
+ import { createRagToolSource } from '../rag/tool.js';
25
+ import type { Retriever } from '../rag/retriever.js';
26
+ import { validateToolArgs } from '../guards.js';
27
+ import { estimateTokens } from '../context/budget.js';
28
+
29
+ interface SnapshotTool {
30
+ required: string[];
31
+ properties: Record<string, { type: string; description?: string }>;
32
+ }
33
+
34
+ const SKILLS_DIR = packagedSkillsDir();
35
+ const skills = loadSkillsDir(SKILLS_DIR);
36
+ const registry = new SkillRegistry(skills);
37
+
38
+ const mcp: Record<string, SnapshotTool> = JSON.parse(
39
+ readFileSync(new URL('./mcp-tools.snapshot.json', import.meta.url), 'utf8'),
40
+ ).tools;
41
+
42
+ const core = new Map<string, ToolDef>();
43
+ for (const def of [
44
+ ...WALLET_TOOLS,
45
+ ...KALEIDOSWAP_TOOLS,
46
+ ...LSPS1_TOOLS,
47
+ ...SUBMARINE_TOOLS,
48
+ ...FLASHNET_TOOLS,
49
+ ...BITREFILL_TOOLS,
50
+ ...createBtcMapToolSource({}).listTools(),
51
+ ...createL402ToolSource({ payInvoice: async () => ({ preimage: '' }) }).listTools(),
52
+ ...createRagToolSource({} as Retriever).listTools(),
53
+ ...createSkillReferenceToolSource(registry).listTools(),
54
+ ] as ToolDef[]) core.set(def.name, def);
55
+
56
+ /**
57
+ * Same name, different tool. kaleido-mcp keeps `spark_pay_invoice` as a legacy
58
+ * alias of `spark_pay_spark_invoice` (Spark invoices); its BOLT11 payer is
59
+ * `spark_pay_lightning_invoice`. The in-app `spark_pay_invoice` pays BOLT11, so
60
+ * skills that use it must require an in-app-only tool.
61
+ */
62
+ const NAME_COLLISIONS = new Set(['spark_pay_invoice']);
63
+
64
+ const mcpDef = (name: string): ToolDef => ({
65
+ name,
66
+ description: '',
67
+ parameters: { type: 'object', properties: mcp[name]!.properties, required: mcp[name]!.required },
68
+ });
69
+
70
+ function surfaces(name: string): Array<{ surface: string; def: ToolDef }> {
71
+ const out: Array<{ surface: string; def: ToolDef }> = [];
72
+ if (core.has(name)) out.push({ surface: 'core', def: core.get(name)! });
73
+ if (mcp[name] && !NAME_COLLISIONS.has(name)) out.push({ surface: 'kaleido-mcp', def: mcpDef(name) });
74
+ return out;
75
+ }
76
+
77
+ function props(def: ToolDef): string[] {
78
+ return Object.keys((def.parameters as { properties?: object }).properties ?? {});
79
+ }
80
+
81
+ function required(def: ToolDef): string[] {
82
+ return ((def.parameters as { required?: string[] }).required ?? []);
83
+ }
84
+
85
+ function frontmatterList(name: string, key: string): string[] {
86
+ const skill = skills.find((s) => s.name === name)!;
87
+ if (key === 'tools') return skill.tools ?? [];
88
+ return (skill.metadata?.[key] ?? '').split(',').map((t) => t.trim()).filter(Boolean);
89
+ }
90
+
91
+ /** Every `tool_name {json}` call written in a skill or one of its references. */
92
+ function exampleCalls(dir: string): Array<{ file: string; tool: string; json: string }> {
93
+ const files = [join(dir, 'SKILL.md')];
94
+ const refs = join(dir, 'references');
95
+ if (existsSync(refs)) files.push(...readdirSync(refs).filter((f) => f.endsWith('.md')).map((f) => join(refs, f)));
96
+ const out: Array<{ file: string; tool: string; json: string }> = [];
97
+ for (const file of files) {
98
+ for (const m of readFileSync(file, 'utf8').matchAll(/`([a-z][a-z0-9_]*) (\{[^`]*\})`/g)) {
99
+ if (core.has(m[1]!) || mcp[m[1]!]) out.push({ file, tool: m[1]!, json: m[2]! });
100
+ }
101
+ }
102
+ return out;
103
+ }
104
+
105
+ const isPlaceholder = (v: unknown) => typeof v === 'string' && /^<.*>$|…/.test(v);
106
+
107
+ describe('skill catalog', () => {
108
+ it('ships skills', () => {
109
+ expect(skills.length).toBeGreaterThan(5);
110
+ });
111
+
112
+ it.each(skills.map((s) => [s.name]))('%s: every tool exists on a surface', (name) => {
113
+ const missing = [...frontmatterList(name, 'tools'), ...frontmatterList(name, 'requires-tools')]
114
+ .filter((t) => !core.has(t) && !mcp[t]);
115
+ expect(missing).toEqual([]);
116
+ });
117
+
118
+ it.each(skills.map((s) => [s.name]))('%s: requires-tools are in tools', (name) => {
119
+ const tools = new Set(frontmatterList(name, 'tools'));
120
+ expect(frontmatterList(name, 'requires-tools').filter((t) => !tools.has(t))).toEqual([]);
121
+ });
122
+
123
+ it.each(skills.map((s) => [s.name]))('%s: a colliding tool name is scoped to the app', (name) => {
124
+ const uses = frontmatterList(name, 'tools').some((t) => NAME_COLLISIONS.has(t));
125
+ if (!uses) return;
126
+ const req = frontmatterList(name, 'requires-tools');
127
+ expect(req.length > 0 && req.some((t) => core.has(t) && !mcp[t])).toBe(true);
128
+ });
129
+
130
+ it.each(skills.map((s) => [s.name, s.dir!]))('%s: examples are valid calls', (name, dir) => {
131
+ const scoped = new Set(frontmatterList(name, 'tools'));
132
+ const errors: string[] = [];
133
+ for (const call of exampleCalls(dir)) {
134
+ const where = `${call.file.slice(SKILLS_DIR.length)}: ${call.tool} ${call.json}`;
135
+ let args: Record<string, unknown>;
136
+ try {
137
+ args = JSON.parse(call.json);
138
+ } catch {
139
+ errors.push(`${where} — not JSON`);
140
+ continue;
141
+ }
142
+ if (call.file.endsWith('SKILL.md') && scoped.size && !scoped.has(call.tool)) {
143
+ errors.push(`${where} — tool not in the skill's tools`);
144
+ }
145
+ for (const { surface, def } of surfaces(call.tool)) {
146
+ const known = props(def);
147
+ for (const k of Object.keys(args)) if (!known.includes(k)) errors.push(`${where} — "${k}" unknown on ${surface}`);
148
+ for (const k of required(def)) if (args[k] == null) errors.push(`${where} — "${k}" required on ${surface}`);
149
+ const concrete = Object.fromEntries(Object.entries(args).filter(([, v]) => !isPlaceholder(v)));
150
+ const check = validateToolArgs(def, concrete);
151
+ for (const e of check.errors) if (!/is required/.test(e)) errors.push(`${where} — ${surface}: ${e}`);
152
+ }
153
+ }
154
+ expect(errors).toEqual([]);
155
+ });
156
+
157
+ it.each(skills.map((s) => [s.name]))('%s: tool names in the prose exist', (name) => {
158
+ const body = skills.find((s) => s.name === name)!.instructions;
159
+ const prefixed = /^(rln|wdk|kaleidoswap|kaleido_node|spark|flashnet|bitrefill|mpp|l402|liquid|arkade)_[a-z0-9_]+$/;
160
+ const unknown = [...body.matchAll(/`([a-z][a-z0-9_]*)[` ]/g)]
161
+ .map((m) => m[1]!)
162
+ .filter((w) => prefixed.test(w) && !core.has(w) && !mcp[w]);
163
+ expect([...new Set(unknown)]).toEqual([]);
164
+ });
165
+
166
+ it.each(skills.map((s) => [s.name]))('%s: body stays short', (name) => {
167
+ const skill = skills.find((s) => s.name === name)!;
168
+ expect(estimateTokens(skill.instructions)).toBeLessThanOrEqual(600);
169
+ expect(skill.description.length).toBeLessThanOrEqual(320);
170
+ });
171
+ });
172
+
173
+ describe('core contracts match kaleido-mcp', () => {
174
+ it('shared tool names take the same arguments', () => {
175
+ const errors: string[] = [];
176
+ for (const [name, def] of core) {
177
+ const m = mcp[name];
178
+ if (!m || NAME_COLLISIONS.has(name)) continue;
179
+ const mcpProps = Object.keys(m.properties);
180
+ const coreProps = props(def);
181
+ for (const p of coreProps) if (!mcpProps.includes(p)) errors.push(`${name}: "${p}" is not a kaleido-mcp argument`);
182
+ for (const r of m.required) if (!coreProps.includes(r)) errors.push(`${name}: kaleido-mcp requires "${r}"`);
183
+ }
184
+ expect(errors).toEqual([]);
185
+ });
186
+ });
187
+
188
+ describe('skill selection by surface', () => {
189
+ const mcpTools = Object.keys(mcp);
190
+ const inApp = [...core.keys()];
191
+
192
+ it.each([
193
+ ['Which RGB assets do I hold, and what are the balances?', 'rgb-lightning-node'],
194
+ ['Get a quote to swap 0.0005 BTC into USDT on KaleidoSwap.', 'kaleido-trading'],
195
+ ['Issue a token named Skill Test, ticker SKT, supply 1000, precision 0.', 'rgb-lightning-node'],
196
+ ['Send 1 QTWO to this RGB invoice: rgb:~/~/~/sig/any/1/utxob:test-recipient', 'rgb-lightning-node'],
197
+ ['Create a Lightning invoice for 5000 sats.', 'rgb-lightning-node'],
198
+ ["what's my balance?", 'rgb-lightning-node'],
199
+ ['buy 500k sats of inbound liquidity', 'channel-manager'],
200
+ ['unlock my node', 'kaleido-node'],
201
+ ['pay lntbs10u1pexample with L-USDT', 'submarine-swaps'],
202
+ ])('kaleido-mcp: %s → %s', (query, expected) => {
203
+ expect(selectAvailableSkill(registry, query, mcpTools)?.name).toBe(expected);
204
+ });
205
+
206
+ it.each([
207
+ ["what's my balance?", 'wallet-assistant'],
208
+ ['what is my spark balance', 'spark-wallet'],
209
+ ['swap 100k sats to USDB on flashnet', 'flashnet-swaps'],
210
+ ['buy a $25 amazon gift card', 'bitrefill'],
211
+ ['where can I spend sats in Turin', 'merchant-finder'],
212
+ ])('in-app: %s → %s', (query, expected) => {
213
+ expect(selectAvailableSkill(registry, query, inApp)?.name).toBe(expected);
214
+ });
215
+ });