@kaleidorg/mind 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/funnel.d.ts +19 -0
  2. package/dist/funnel.d.ts.map +1 -1
  3. package/dist/funnel.js +48 -10
  4. package/dist/funnel.js.map +1 -1
  5. package/dist/index.d.ts +5 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +10 -3
  8. package/dist/index.js.map +1 -1
  9. package/dist/kaleidoswap/contract.d.ts +3 -3
  10. package/dist/kaleidoswap/contract.d.ts.map +1 -1
  11. package/dist/kaleidoswap/contract.js +16 -4
  12. package/dist/kaleidoswap/contract.js.map +1 -1
  13. package/dist/knowledge/bitcoin-copilot.d.ts.map +1 -1
  14. package/dist/knowledge/bitcoin-copilot.js +102 -0
  15. package/dist/knowledge/bitcoin-copilot.js.map +1 -1
  16. package/dist/knowledge/btc-map.d.ts +14 -17
  17. package/dist/knowledge/btc-map.d.ts.map +1 -1
  18. package/dist/knowledge/btc-map.js +66 -266
  19. package/dist/knowledge/btc-map.js.map +1 -1
  20. package/dist/lsps1/contract.d.ts.map +1 -1
  21. package/dist/lsps1/contract.js +28 -10
  22. package/dist/lsps1/contract.js.map +1 -1
  23. package/dist/recipe/buy-asset-channel.d.ts +26 -0
  24. package/dist/recipe/buy-asset-channel.d.ts.map +1 -0
  25. package/dist/recipe/buy-asset-channel.js +112 -0
  26. package/dist/recipe/buy-asset-channel.js.map +1 -0
  27. package/dist/recipe/kaleidoswap-atomic.d.ts +26 -18
  28. package/dist/recipe/kaleidoswap-atomic.d.ts.map +1 -1
  29. package/dist/recipe/kaleidoswap-atomic.js +101 -63
  30. package/dist/recipe/kaleidoswap-atomic.js.map +1 -1
  31. package/dist/recipe/kaleidoswap-channel-order.d.ts +35 -0
  32. package/dist/recipe/kaleidoswap-channel-order.d.ts.map +1 -0
  33. package/dist/recipe/kaleidoswap-channel-order.js +493 -0
  34. package/dist/recipe/kaleidoswap-channel-order.js.map +1 -0
  35. package/dist/recipe/kaleidoswap-price.d.ts +21 -0
  36. package/dist/recipe/kaleidoswap-price.d.ts.map +1 -0
  37. package/dist/recipe/kaleidoswap-price.js +57 -0
  38. package/dist/recipe/kaleidoswap-price.js.map +1 -0
  39. package/dist/recipe/runner.d.ts +7 -1
  40. package/dist/recipe/runner.d.ts.map +1 -1
  41. package/dist/recipe/runner.js +115 -29
  42. package/dist/recipe/runner.js.map +1 -1
  43. package/dist/recipe/swap.d.ts +26 -1
  44. package/dist/recipe/swap.d.ts.map +1 -1
  45. package/dist/recipe/swap.js +108 -13
  46. package/dist/recipe/swap.js.map +1 -1
  47. package/dist/recipe/types.d.ts +25 -1
  48. package/dist/recipe/types.d.ts.map +1 -1
  49. package/dist/skills/registry.d.ts +33 -1
  50. package/dist/skills/registry.d.ts.map +1 -1
  51. package/dist/skills/registry.js +45 -1
  52. package/dist/skills/registry.js.map +1 -1
  53. package/package.json +1 -1
  54. package/skills/README.md +3 -0
  55. package/skills/kaleido-lsps/SKILL.md +101 -43
  56. package/skills/kaleido-trading/SKILL.md +81 -31
  57. package/skills/merchant-finder/SKILL.md +96 -66
  58. package/skills/rgb-lightning-node/SKILL.md +108 -0
  59. package/skills/wallet-assistant/SKILL.md +32 -21
  60. package/src/funnel.ts +66 -11
  61. package/src/index.ts +14 -2
  62. package/src/kaleidoswap/contract.test.ts +7 -2
  63. package/src/kaleidoswap/contract.ts +27 -5
  64. package/src/knowledge/bitcoin-copilot.ts +111 -0
  65. package/src/knowledge/btc-map.test.ts +53 -96
  66. package/src/knowledge/btc-map.ts +72 -287
  67. package/src/lsps1/contract.ts +32 -14
  68. package/src/recipe/buy-asset-channel.test.ts +148 -0
  69. package/src/recipe/buy-asset-channel.ts +118 -0
  70. package/src/recipe/kaleidoswap-atomic.test.ts +134 -61
  71. package/src/recipe/kaleidoswap-atomic.ts +112 -66
  72. package/src/recipe/kaleidoswap-channel-order.test.ts +333 -0
  73. package/src/recipe/kaleidoswap-channel-order.ts +548 -0
  74. package/src/recipe/kaleidoswap-price.ts +68 -0
  75. package/src/recipe/recipe.test.ts +61 -5
  76. package/src/recipe/runner.ts +128 -31
  77. package/src/recipe/swap.ts +109 -13
  78. package/src/recipe/types.ts +25 -1
  79. package/src/skills/registry.ts +52 -1
@@ -50,31 +50,49 @@ export const LSPS1_TOOLS: Lsps1ToolDef[] = [
50
50
  "Get the LSP's Lightning network info: pubkey, host, port, connect URI. Useful to display the counterparty or pre-connect a peer. No args."),
51
51
 
52
52
  t('lsp_estimate_fees',
53
- "Estimate the fee for a channel order BEFORE committing. Returns the total cost in sats plus any LSP routing fee. Re-estimate rather than reusing a stale value.",
53
+ "Estimate the channel-order fee BEFORE committing. Returns setup_fee, capacity_fee, duration_fee, total_fee (all in sats). Re-estimate rather than reusing a stale value.",
54
54
  {
55
- lsp_balance_sat: { type: 'number', description: "Sats the LSP commits on their side (inbound capacity for the user)." },
56
- client_balance_sat: { type: 'number', description: "Sats the user pre-funds into the channel (push amount). Often 0." },
57
- channel_expiry_blocks: { type: 'number', description: 'Optional minimum lease in blocks. Defaults to the LSP minimum.' },
55
+ lsp_balance_sat: { type: 'number', description: "Sats the LSP commits on their side (inbound capacity for the user)." },
56
+ client_balance_sat: { type: 'number', description: "Sats the user pre-funds into the channel (push amount). Default 0." },
57
+ channel_expiry_blocks: { type: 'number', description: 'Lease duration in blocks. Default 4320 (~30 days). Maker maximum is typically 20160 (~140 days).' },
58
+ // RGB asset channels (optional):
59
+ asset_id: { type: 'string', description: 'For RGB asset channels — the asset to provision liquidity for.' },
60
+ lsp_asset_amount: { type: 'number', description: 'Asset units the LSP commits (RGB channels only).' },
61
+ client_asset_amount: { type: 'number', description: 'Asset units the user pre-funds (RGB channels only). Requires rfq_id.' },
62
+ rfq_id: { type: 'string', description: 'Quote id from kaleidoswap_get_quote — required when client_asset_amount > 0.' },
63
+ token: { type: 'string', description: 'Optional discount/affiliate token.' },
58
64
  },
59
- ['lsp_balance_sat']),
65
+ ['lsp_balance_sat', 'client_balance_sat', 'channel_expiry_blocks']),
60
66
 
61
67
  t('lsp_create_order',
62
- "Create a channel order. SPEND: confirmation-gated. Returns an order id + a Lightning invoice the user pays to lock the order. The channel opens only after payment.",
68
+ "Create a channel order. SPEND: confirmation-gated. Returns order_id + access_token + a payment.bolt11.invoice the user pays to lock the order. The channel opens only after payment.",
63
69
  {
64
- lsp_balance_sat: { type: 'number', description: "Sats the LSP commits on their side (inbound capacity for the user)." },
65
- client_balance_sat: { type: 'number', description: 'Sats the user pre-funds. Often 0.' },
66
- channel_expiry_blocks: { type: 'number', description: 'Minimum lease in blocks. Defaults to LSP minimum from lsp_get_info.' },
67
- refund_onchain_address: { type: 'string', description: 'Optional on-chain refund address if the LSP cannot open the channel.' },
70
+ client_pubkey: { type: 'string', description: "User's Lightning node pubkey — the LSP opens the channel TO this node. Get it from rln_get_node_info." },
71
+ lsp_balance_sat: { type: 'number', description: "Sats the LSP commits on their side (inbound capacity for the user)." },
72
+ client_balance_sat: { type: 'number', description: "Sats the user pre-funds. Default 0." },
73
+ required_channel_confirmations: { type: 'number', description: 'Number of confs the user wants before considering the channel usable. Default 1.' },
74
+ funding_confirms_within_blocks: { type: 'number', description: 'Max blocks within which funding must confirm. Default 6.' },
75
+ channel_expiry_blocks: { type: 'number', description: 'Lease duration in blocks. Default 4320 (~30 days).' },
76
+ announce_channel: { type: 'boolean', description: 'Public (true) or unannounced (false). Default true.' },
77
+ refund_onchain_address: { type: 'string', description: 'Optional on-chain refund address if the LSP cannot open the channel.' },
78
+ // RGB asset channels (optional):
79
+ asset_id: { type: 'string', description: 'For RGB asset channels.' },
80
+ lsp_asset_amount: { type: 'number', description: 'Asset units the LSP commits.' },
81
+ client_asset_amount: { type: 'number', description: 'Asset units the user pre-funds.' },
82
+ rfq_id: { type: 'string', description: 'Required when client_asset_amount > 0.' },
83
+ token: { type: 'string', description: 'Optional discount/affiliate token.' },
84
+ email: { type: 'string', description: 'Optional contact for order updates.' },
68
85
  },
69
- ['lsp_balance_sat'],
86
+ ['client_pubkey', 'lsp_balance_sat'],
70
87
  /* spend */ true),
71
88
 
72
89
  t('lsp_get_order',
73
- 'Check the status of an LSPS1 order pending / paid / opening / completed / failed. Poll after creating an order until the channel opens.',
90
+ 'Check the status of an LSPS1 order. order_state progresses CREATED CHANNEL_OPENING COMPLETED (or FAILED). ALWAYS pass BOTH the order_id and the access_token from lsp_create_order (the access_token is required for order status).',
74
91
  {
75
- order_id: { type: 'string', description: 'The order id from lsp_create_order.' },
92
+ order_id: { type: 'string', description: 'The order id from lsp_create_order.' },
93
+ access_token: { type: 'string', description: 'The per-order access token returned by lsp_create_order. Required for non-admin reads.' },
76
94
  },
77
- ['order_id']),
95
+ ['order_id', 'access_token']),
78
96
  ];
79
97
 
80
98
  /** All LSPS1 tool names that move funds (confirmation-gated). */
@@ -0,0 +1,148 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { ToolRegistry } from '../tools/registry.js';
3
+ import { InProcessToolSource } from '../tools/in-process.js';
4
+ import type { LLMProvider } from '../providers/types.js';
5
+ import { runRecipe, RecipeRegistry } from './runner.js';
6
+ import { buyAssetChannelRecipe, extractBuyAsset } from './buy-asset-channel.js';
7
+ import { swapRecipe } from './swap.js';
8
+ import { assetSendRecipe } from './asset-send.js';
9
+
10
+ const approve: LLMProvider = { name: 'x', runTurn: async () => ({ text: '', rawContent: '', toolCalls: [] }) };
11
+
12
+ /** Stub the two asset-channel tools the recipe drives. */
13
+ function stubTools(spy?: { create?: (a: any) => void }) {
14
+ return new ToolRegistry([
15
+ new InProcessToolSource('ks', [
16
+ {
17
+ name: 'kaleidoswap_lsp_quote_asset_channel',
18
+ description: '',
19
+ parameters: { type: 'object', properties: {} },
20
+ handler: async (a) => ({
21
+ rfq_id: 'rfq1',
22
+ asset_amount: a.asset_amount,
23
+ btc_amount_sat: 13807,
24
+ channel_fee_sat: 16139,
25
+ total_sat: 29946,
26
+ expires_at: 1234567890,
27
+ }),
28
+ },
29
+ {
30
+ name: 'kaleidoswap_lsp_create_asset_channel',
31
+ description: '',
32
+ parameters: { type: 'object', properties: {} },
33
+ requiresConfirmation: true,
34
+ handler: async (a) => {
35
+ spy?.create?.(a);
36
+ return { order_id: 'ord1', total_sat: 29946, payment: { onchain_address: 'bcrt1qexample' } };
37
+ },
38
+ },
39
+ ]),
40
+ ]);
41
+ }
42
+
43
+ describe('extractBuyAsset (deterministic Tier-0)', () => {
44
+ it('parses "buy 100 usdt"', () => {
45
+ expect(extractBuyAsset('buy 100 usdt')).toEqual({ asset: 'USDT', asset_amount: 100 });
46
+ });
47
+ it('parses "get me 50 xaut"', () => {
48
+ expect(extractBuyAsset('get me 50 xaut')).toEqual({ asset: 'XAUT', asset_amount: 50 });
49
+ });
50
+ it('parses "i want 200 usdt" and "purchase 10 xaut"', () => {
51
+ expect(extractBuyAsset('i want 200 usdt')).toEqual({ asset: 'USDT', asset_amount: 200 });
52
+ expect(extractBuyAsset('purchase 10 xaut')).toEqual({ asset: 'XAUT', asset_amount: 10 });
53
+ });
54
+ it('handles comma grouping in the amount', () => {
55
+ expect(extractBuyAsset('buy 1,000 usdt')).toEqual({ asset: 'USDT', asset_amount: 1000 });
56
+ });
57
+ it('null for a swap (a named source asset ⇒ swap owns it)', () => {
58
+ expect(extractBuyAsset('buy 0.001 btc with usdt')).toBeNull();
59
+ expect(extractBuyAsset('swap 10 usdt for btc')).toBeNull();
60
+ expect(extractBuyAsset('buy 100 usdt with my bitcoin')).toBeNull();
61
+ });
62
+ it('null for a send (asset-send owns it)', () => {
63
+ expect(extractBuyAsset('send 10 usdt to bob')).toBeNull();
64
+ });
65
+ it('null for BTC (BTC is not bought via an asset channel)', () => {
66
+ expect(extractBuyAsset('buy 100000 sats')).toBeNull();
67
+ expect(extractBuyAsset('get 0.01 btc')).toBeNull();
68
+ });
69
+ });
70
+
71
+ describe('runRecipe — buy asset channel', () => {
72
+ it('quote → confirm → create order, deterministic (0 inferences)', async () => {
73
+ const created: any[] = [];
74
+ const tools = stubTools({ create: (a) => created.push(a) });
75
+ const onConfirm = vi.fn(async () => ({ approved: true }));
76
+ const res = await runRecipe(buyAssetChannelRecipe, 'buy 100 usdt', { provider: approve, tools, onConfirm });
77
+
78
+ expect(res.status).toBe('done');
79
+ expect(res.inferences).toBe(0);
80
+ expect(onConfirm).toHaveBeenCalledOnce();
81
+ expect(res.results.quote).toMatchObject({ rfq_id: 'rfq1' });
82
+ expect(created[0]).toMatchObject({ asset: 'USDT', asset_amount: 100, rfq_id: 'rfq1' });
83
+ // The quote's cost rides along for the confirm card.
84
+ expect(created[0]).toMatchObject({ total_sat: 29946, btc_amount_sat: 13807, channel_fee_sat: 16139 });
85
+ expect(res.text).toContain('100 USDT');
86
+ expect(res.text).toContain('29,946');
87
+ });
88
+
89
+ it('denied confirmation → cancelled, no order placed', async () => {
90
+ const created: any[] = [];
91
+ const tools = stubTools({ create: (a) => created.push(a) });
92
+ const res = await runRecipe(buyAssetChannelRecipe, 'buy 100 usdt', {
93
+ provider: approve,
94
+ tools,
95
+ onConfirm: async () => ({ approved: false }),
96
+ });
97
+ expect(res.status).toBe('cancelled');
98
+ expect(created).toHaveLength(0);
99
+ });
100
+
101
+ it('fails closed when no confirm handler is wired (spend never runs)', async () => {
102
+ const created: any[] = [];
103
+ const tools = stubTools({ create: (a) => created.push(a) });
104
+ const res = await runRecipe(buyAssetChannelRecipe, 'buy 100 usdt', { provider: approve, tools });
105
+ expect(res.status).toBe('cancelled');
106
+ expect(created).toHaveLength(0);
107
+ });
108
+
109
+ it('falls back to ONE LLM extraction when the regex misses', async () => {
110
+ const created: any[] = [];
111
+ const tools = stubTools({ create: (a) => created.push(a) });
112
+ const llmOnly = { ...buyAssetChannelRecipe, extract: undefined };
113
+ const provider: LLMProvider = {
114
+ name: 'mock',
115
+ runTurn: vi.fn(async () => ({
116
+ text: '',
117
+ rawContent: '',
118
+ toolCalls: [{ id: '1', name: 'extract_request', arguments: { asset: 'USDT', asset_amount: 100 } }],
119
+ })),
120
+ };
121
+ const res = await runRecipe(llmOnly, 'could you set me up with a hundred tether', {
122
+ provider,
123
+ tools,
124
+ onConfirm: async () => ({ approved: true }),
125
+ });
126
+ expect(res.inferences).toBe(1);
127
+ expect(provider.runTurn).toHaveBeenCalledOnce();
128
+ expect(created[0]).toMatchObject({ asset: 'USDT', asset_amount: 100, rfq_id: 'rfq1' });
129
+ });
130
+ });
131
+
132
+ describe('recipe selection / precedence', () => {
133
+ it('selects buy-asset-channel before swap for "buy 100 usdt"', () => {
134
+ const reg = new RecipeRegistry([buyAssetChannelRecipe, swapRecipe]);
135
+ expect(reg.select('buy 100 usdt')?.name).toBe('buy-asset-channel');
136
+ expect(reg.select('get me 50 xaut')?.name).toBe('buy-asset-channel');
137
+ });
138
+ it('does not hijack a swap or an asset send', () => {
139
+ const reg = new RecipeRegistry([buyAssetChannelRecipe, swapRecipe, assetSendRecipe]);
140
+ expect(reg.select('swap 10 usdt for btc')?.name).not.toBe('buy-asset-channel');
141
+ expect(reg.select('send 10 usdt to bob')?.name).not.toBe('buy-asset-channel');
142
+ });
143
+ it('confident only with both asset and a positive amount', () => {
144
+ expect(buyAssetChannelRecipe.confident!({ asset: 'USDT', asset_amount: 100 })).toBe(true);
145
+ expect(buyAssetChannelRecipe.confident!({ asset: 'USDT' })).toBe(false);
146
+ expect(buyAssetChannelRecipe.confident!({ asset: 'USDT', asset_amount: 0 })).toBe(false);
147
+ });
148
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Built-in "buy an asset channel" recipe — the onboarding buy.
3
+ *
4
+ * The user has on-chain BTC but no Lightning channel yet, and wants to HOLD an
5
+ * RGB asset (USDT, XAUT). They can't swap (no channel to swap inside), so they
6
+ * buy a NEW channel from the maker LSP pre-loaded with the asset. One quote,
7
+ * one spend:
8
+ *
9
+ * "buy 100 usdt" / "get me 50 xaut" / "i want 200 usdt"
10
+ * ↓ 1 model inference (slot extraction; 0 when the regex hits)
11
+ * kaleidoswap_lsp_quote_asset_channel ← maker prices the asset + channel
12
+ * kaleidoswap_lsp_create_asset_channel 🔒 ← (final) order it; pay to open
13
+ *
14
+ * Distinct from `swapRecipe`: a swap names a source asset ("swap 10 usdt FOR
15
+ * btc", "buy btc WITH usdt") and needs an existing channel. This is the
16
+ * no-source, no-channel onboarding path — "buy <amount> <asset>" with nothing
17
+ * to spend it from — so it must be SELECTED BEFORE swap for that phrasing.
18
+ *
19
+ * Opt-in: register via `Funnel.recipes` (like `kaleidoswapAtomicRecipe`). The
20
+ * host binds `kaleidoswap_lsp_*` to its transport (maker REST / MCP / WDK).
21
+ */
22
+
23
+ import type { Recipe } from './types.js';
24
+
25
+ /** RGB assets the maker sells as an asset channel. BTC is never "bought" this way. */
26
+ const RGB_ASSET = /\b(usdt|tether|xaut|gold)\b/i;
27
+ /** A named funding source ⇒ this is a swap, not an onboarding buy. */
28
+ const HAS_SOURCE = /\b(?:with|using|from)\b|\bfor\s+(?:btc|bitcoin|sats?|usdt|xaut|tether|gold)\b/i;
29
+ /** Verbs other intents own (swap / sell / send) — never an onboarding buy. */
30
+ const NOT_BUY = /\b(swap|exchange|convert|trade|sell|send)\b/i;
31
+ /** Acquire verbs that DO mean an onboarding buy. */
32
+ const BUY_VERB = /\b(buy|get|acquire|want|purchase|onboard|need)\b/i;
33
+
34
+ function normAsset(a?: string): string | undefined {
35
+ if (!a) return undefined;
36
+ const x = a.toLowerCase();
37
+ if (/usdt|tether/.test(x)) return 'USDT';
38
+ if (/xaut|gold/.test(x)) return 'XAUT';
39
+ return undefined;
40
+ }
41
+
42
+ const num = (s?: string): number | undefined => {
43
+ if (!s) return undefined;
44
+ const n = Number(s.replace(/,/g, ''));
45
+ return Number.isFinite(n) ? n : undefined;
46
+ };
47
+
48
+ /** Thousands separators, locale-independent (deterministic for tests). */
49
+ const commas = (n: number): string => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
50
+
51
+ /** "buy 100 usdt" / "get me 50 xaut" / "i want 200 usdt" / "purchase 10 xaut". */
52
+ export function extractBuyAsset(text: string): Record<string, unknown> | null {
53
+ const t = text.trim();
54
+ if (NOT_BUY.test(t) || HAS_SOURCE.test(t)) return null;
55
+ if (!RGB_ASSET.test(t)) return null;
56
+ // buy/get/want/acquire/purchase [me] <amount> <asset>
57
+ const m = t.match(/\b(?:buy|get|acquire|want|purchase|onboard|need)\b(?:\s+me)?\s+([\d.,]+)\s*([a-z]+)/i);
58
+ if (!m) return null;
59
+ const asset = normAsset(m[2]);
60
+ const amount = num(m[1]);
61
+ if (!asset || amount === undefined) return null;
62
+ return { asset, asset_amount: amount };
63
+ }
64
+
65
+ export const buyAssetChannelRecipe: Recipe = {
66
+ name: 'buy-asset-channel',
67
+ description:
68
+ 'Onboarding buy: purchase a new Lightning channel pre-loaded with an RGB asset (USDT, XAUT) from the maker LSP — for a user with on-chain BTC but no channel yet. Quote, then order (with confirmation).',
69
+ // "buy/get/want N <rgb-asset>" with NO named source asset and NO swap/send verb.
70
+ match: (t) => !NOT_BUY.test(t) && !HAS_SOURCE.test(t) && RGB_ASSET.test(t) && BUY_VERB.test(t),
71
+ triggers: ['buy', 'get', 'purchase', 'acquire'],
72
+ slots: [
73
+ { name: 'asset', type: 'string', description: 'RGB asset to acquire (USDT or XAUT)', required: true },
74
+ { name: 'asset_amount', type: 'number', description: 'Amount of the asset to load into the channel (display units, e.g. 100)', required: true },
75
+ ],
76
+ extract: extractBuyAsset,
77
+ confident: (s) => !!s.asset && s.asset_amount !== undefined && Number(s.asset_amount) > 0,
78
+ steps: [
79
+ // 1. Maker prices the asset + the channel.
80
+ // Returns { rfq_id, btc_amount_sat, channel_fee_sat, total_sat, expires_at }.
81
+ {
82
+ tool: 'kaleidoswap_lsp_quote_asset_channel',
83
+ as: 'quote',
84
+ args: (ctx) => ({ asset: ctx.slots.asset, asset_amount: ctx.slots.asset_amount }),
85
+ },
86
+ ],
87
+ // 2. Order the channel with the fresh rfq_id. Spend → confirmation-gated.
88
+ // The quote's cost fields ride along so the host's confirm card can show
89
+ // the price before approval; the create tool treats them as display-only.
90
+ final: {
91
+ tool: 'kaleidoswap_lsp_create_asset_channel',
92
+ args: (ctx) => {
93
+ const q = (ctx.results.quote ?? {}) as {
94
+ rfq_id?: string;
95
+ total_sat?: number;
96
+ btc_amount_sat?: number;
97
+ channel_fee_sat?: number;
98
+ expires_at?: number;
99
+ };
100
+ return {
101
+ asset: ctx.slots.asset,
102
+ asset_amount: ctx.slots.asset_amount,
103
+ rfq_id: q.rfq_id,
104
+ total_sat: q.total_sat,
105
+ btc_amount_sat: q.btc_amount_sat,
106
+ channel_fee_sat: q.channel_fee_sat,
107
+ expires_at: q.expires_at,
108
+ };
109
+ },
110
+ },
111
+ summary: (ctx, finalResult) => {
112
+ const q = ctx.results.quote as { total_sat?: number } | undefined;
113
+ const o = finalResult as { order_id?: string } | undefined;
114
+ const cost = typeof q?.total_sat === 'number' ? ` for ${commas(q.total_sat)} sats` : '';
115
+ const id = o?.order_id ? ` (order ${o.order_id})` : '';
116
+ return `Ordered a Lightning channel with ${ctx.slots.asset_amount} ${ctx.slots.asset}${cost}${id}. Pay the returned invoice/address to open it.`;
117
+ },
118
+ };
@@ -5,15 +5,19 @@ import type { LLMProvider } from '../providers/types.js';
5
5
  import { runRecipe } from './runner.js';
6
6
  import { kaleidoswapAtomicRecipe } from './kaleidoswap-atomic.js';
7
7
 
8
- // LLM provider that should never be called when slots are extracted deterministically.
8
+ // LLM provider that should never be called when slots are pre-supplied to runRecipe
9
+ // (or when a recipe is not using forceModelExtract).
9
10
  const refusingProvider: LLMProvider = {
10
11
  name: 'refusing',
11
12
  runTurn: async () => {
12
- throw new Error('provider should NOT be called when extractSwap succeeds');
13
+ throw new Error('provider should NOT be called (slots pre-supplied or det path)');
13
14
  },
14
15
  };
15
16
 
16
- // Stub tools that record every call so we can assert the chain ran end-to-end.
17
+ /**
18
+ * Stub the maker + node tools. The quote echoes full asset specs (asset_id +
19
+ * maker-unit amount) the way the real maker does, so init can source them.
20
+ */
17
21
  function buildStubs(captured: { name: string; args: any }[]) {
18
22
  const tool = (name: string, response: any, spend = false) => ({
19
23
  name,
@@ -27,112 +31,181 @@ function buildStubs(captured: { name: string; args: any }[]) {
27
31
  });
28
32
  return new ToolRegistry([
29
33
  new InProcessToolSource('kaleidoswap', [
30
- tool('kaleidoswap_get_quote', { quote_id: 'q-1', receive_amount: 100, fees: 250 }),
31
- tool('kaleidoswap_atomic_init', { atomic_id: 'a-1', maker_invoice: 'lnbc1maker' }, /* spend */ true),
32
- tool('kaleidoswap_atomic_execute', { status: 'completed' }, /* spend */ true),
34
+ tool('kaleidoswap_get_quote', {
35
+ rfq_id: 'rfq-1',
36
+ from_asset: { asset_id: 'USDT', ticker: 'USDT', amount: 10_000_000 },
37
+ to_asset: { asset_id: 'BTC', ticker: 'BTC', amount: 15_250_000 },
38
+ from_amount_display: '10 USDT',
39
+ to_amount_display: '15,250 sats',
40
+ fee_display: '154 sats',
41
+ }),
42
+ tool('kaleidoswap_atomic_init', { swapstring: 'SWAP/abc/def', payment_hash: 'ph-1' }, /* spend */ true),
43
+ tool('kaleidoswap_atomic_execute', { status: 200, message: 'Swap executed successfully.' }, /* spend */ true),
33
44
  ]),
34
45
  new InProcessToolSource('rln', [
35
- tool('rln_create_rgb_invoice', { invoice: 'rgb:invoice:USDT:100' }),
36
- tool('rln_create_ln_invoice', { invoice: 'lnbc1user' }),
37
- tool('rln_pay_invoice', { status: 'SUCCESS', payment_hash: 'h' }, /* spend */ true),
46
+ tool('rln_get_node_info', { pubkey: '03c31dae' }),
47
+ tool('rln_whitelist_swap', { ok: true }, /* spend */ true),
38
48
  ]),
39
49
  ]);
40
50
  }
41
51
 
42
- describe('kaleidoswapAtomicRecipe — selection (match + triggers)', () => {
43
- it('triggers on explicit atomic-swap phrasings', () => {
44
- expect(kaleidoswapAtomicRecipe.match!('atomic swap 100000 sats for usdt')).toBe(true);
45
- expect(kaleidoswapAtomicRecipe.match!('trustless swap btc to usdt')).toBe(true);
46
- expect(kaleidoswapAtomicRecipe.match!('htlc swap 1000 sats to USDT')).toBe(true);
52
+ describe('kaleidoswapAtomicRecipe — selection', () => {
53
+ it('triggers on swap phrasings', () => {
54
+ expect(kaleidoswapAtomicRecipe.match!('swap 10 usdt to btc')).toBe(true);
55
+ expect(kaleidoswapAtomicRecipe.match!('exchange 100000 sats for usdt')).toBe(true);
56
+ expect(kaleidoswapAtomicRecipe.match!('convert btc to usdt')).toBe(true);
47
57
  });
58
+ it('triggers on buy/sell of a crypto asset (the reported bug)', () => {
59
+ expect(kaleidoswapAtomicRecipe.match!('buy one usdt from kaleido')).toBe(true);
60
+ expect(kaleidoswapAtomicRecipe.match!('sell 100 usdt')).toBe(true);
61
+ });
62
+ it('does NOT trigger on buying a gift card (that is commerce, not a swap)', () => {
63
+ expect(kaleidoswapAtomicRecipe.match!('buy a gift card')).toBe(false);
64
+ expect(kaleidoswapAtomicRecipe.match!('buy an amazon voucher')).toBe(false);
65
+ });
66
+ it('does not trigger on a balance question', () => {
67
+ expect(kaleidoswapAtomicRecipe.match!('what is my balance')).toBe(false);
68
+ });
69
+ });
70
+
71
+ describe('kaleidoswapAtomicRecipe — forceModelExtract (less deterministic slot parsing)', () => {
72
+ it('always uses 1 LLM inference for slots even when a det extract would succeed (model does the NL understanding)', async () => {
73
+ const captured: { name: string; args: any }[] = [];
74
+ const tools = buildStubs(captured);
75
+
76
+ // Provider that handles the synthetic extract_request tool the runner builds.
77
+ const modelExtractProvider: LLMProvider = {
78
+ name: 'model-extract',
79
+ runTurn: async (input) => {
80
+ // The runner sends a single-turn request with the extract tool.
81
+ const call = input.tools?.find((t) => t.name === 'extract_request');
82
+ if (call && input.messages.some((m) => m.role === 'user' && /usdt/i.test(m.content || ''))) {
83
+ // Simulate the model correctly parsing a natural "buy" phrasing.
84
+ return {
85
+ text: '',
86
+ rawContent: '',
87
+ toolCalls: [{
88
+ id: 'ex1',
89
+ name: 'extract_request',
90
+ arguments: { from_asset: 'BTC', to_asset: 'USDT', amount: 1, amount_side: 'to' },
91
+ }],
92
+ };
93
+ }
94
+ return { text: '', rawContent: '', toolCalls: [] };
95
+ },
96
+ };
48
97
 
49
- it('does NOT fire on a plain swap (those go to swapRecipe)', () => {
50
- expect(kaleidoswapAtomicRecipe.match!('swap 10 usdt for btc')).toBe(false);
51
- expect(kaleidoswapAtomicRecipe.match!('exchange 1000 sats for usdt')).toBe(false);
98
+ const res = await runRecipe(kaleidoswapAtomicRecipe, 'buy 1 usdt', {
99
+ provider: modelExtractProvider,
100
+ tools,
101
+ onConfirm: async () => ({ approved: true }),
102
+ });
103
+
104
+ expect(res.status).toBe('done');
105
+ expect(res.inferences).toBe(1); // forced through the model
106
+ // The execution still used the model-provided slots (from_asset came from the "model" not regex default).
107
+ // (The stub quote in the test is for USDT→BTC, but the point is the inference count + that it ran.)
108
+ expect(captured[0].name).toBe('kaleidoswap_get_quote');
52
109
  });
53
110
  });
54
111
 
55
- describe('kaleidoswapAtomicRecipe — RGB receive leg', () => {
56
- it('runs quote → rgb_invoiceatomic_initpayatomic_execute (one inference)', async () => {
112
+ describe('kaleidoswapAtomicRecipe — full chain', () => {
113
+ it('runs quote → initnodeinfowhitelistexecute in order (one inference)', async () => {
57
114
  const captured: { name: string; args: any }[] = [];
58
115
  const tools = buildStubs(captured);
59
116
 
60
- const res = await runRecipe(kaleidoswapAtomicRecipe, 'atomic swap 100000 sats for usdt', {
117
+ const res = await runRecipe(kaleidoswapAtomicRecipe, 'swap 10 usdt to btc', {
61
118
  provider: refusingProvider,
62
119
  tools,
63
120
  onConfirm: async () => ({ approved: true }),
121
+ // Pre-supply slots so refusingProvider is not hit. This simulates a
122
+ // successful prior extraction (the normal fast path for most recipes,
123
+ // or the early Funnel heuristic for forceModelExtract recipes).
124
+ slots: { from_asset: 'USDT', to_asset: 'BTC', amount: 10, amount_side: 'from' },
64
125
  });
65
126
 
66
127
  expect(res.status).toBe('done');
67
- expect(res.inferences).toBe(0); // extractSwap handled it deterministically
68
-
69
- // The chain: quote → rgb_invoice → atomic_init → pay → atomic_execute (5 calls).
128
+ expect(res.inferences).toBe(0);
70
129
  expect(captured.map((c) => c.name)).toEqual([
71
130
  'kaleidoswap_get_quote',
72
- 'rln_create_rgb_invoice',
73
131
  'kaleidoswap_atomic_init',
74
- 'rln_pay_invoice',
132
+ 'rln_get_node_info',
133
+ 'rln_whitelist_swap',
75
134
  'kaleidoswap_atomic_execute',
76
135
  ]);
136
+ });
77
137
 
78
- // RGB invoice fed into atomic_init.
138
+ it('threads quote init args (flat asset ids + maker-unit amounts)', async () => {
139
+ const captured: { name: string; args: any }[] = [];
140
+ const tools = buildStubs(captured);
141
+ await runRecipe(kaleidoswapAtomicRecipe, 'swap 10 usdt to btc', {
142
+ provider: refusingProvider, tools, onConfirm: async () => ({ approved: true }),
143
+ slots: { from_asset: 'USDT', to_asset: 'BTC', amount: 10, amount_side: 'from' },
144
+ });
79
145
  const init = captured.find((c) => c.name === 'kaleidoswap_atomic_init')!;
80
- expect(init.args).toEqual({ quote_id: 'q-1', receive_invoice: 'rgb:invoice:USDT:100' });
81
-
82
- // Maker invoice fed into pay step.
83
- const pay = captured.find((c) => c.name === 'rln_pay_invoice')!;
84
- expect(pay.args).toEqual({ invoice: 'lnbc1maker' });
146
+ expect(init.args).toEqual({
147
+ rfq_id: 'rfq-1',
148
+ from_asset: 'USDT', from_amount: 10_000_000,
149
+ to_asset: 'BTC', to_amount: 15_250_000,
150
+ });
151
+ });
85
152
 
86
- // Final execute carried the atomic id.
153
+ it('threads init.swapstring + node.pubkey + init.payment_hash → execute', async () => {
154
+ const captured: { name: string; args: any }[] = [];
155
+ const tools = buildStubs(captured);
156
+ await runRecipe(kaleidoswapAtomicRecipe, 'swap 10 usdt to btc', {
157
+ provider: refusingProvider, tools, onConfirm: async () => ({ approved: true }),
158
+ slots: { from_asset: 'USDT', to_asset: 'BTC', amount: 10, amount_side: 'from' },
159
+ });
160
+ const whitelist = captured.find((c) => c.name === 'rln_whitelist_swap')!;
161
+ expect(whitelist.args).toEqual({ swapstring: 'SWAP/abc/def' });
87
162
  const exe = captured.find((c) => c.name === 'kaleidoswap_atomic_execute')!;
88
- expect(exe.args).toEqual({ atomic_id: 'a-1' });
163
+ expect(exe.args).toEqual({
164
+ swapstring: 'SWAP/abc/def',
165
+ taker_pubkey: '03c31dae',
166
+ payment_hash: 'ph-1',
167
+ });
89
168
  });
90
169
  });
91
170
 
92
- describe('kaleidoswapAtomicRecipe — BTC receive leg', () => {
93
- it('uses rln_create_ln_invoice (not rgb) when to_asset is BTC', async () => {
171
+ describe('kaleidoswapAtomicRecipe — single confirmation', () => {
172
+ it('fires ONE gate (before init), showing the quote summary, then runs ungated', async () => {
94
173
  const captured: { name: string; args: any }[] = [];
95
174
  const tools = buildStubs(captured);
175
+ const confirms: { name: string; summary?: string }[] = [];
96
176
 
97
- const res = await runRecipe(kaleidoswapAtomicRecipe, 'atomic swap 100 usdt for btc', {
177
+ const res = await runRecipe(kaleidoswapAtomicRecipe, 'swap 10 usdt to btc', {
98
178
  provider: refusingProvider,
99
179
  tools,
100
- onConfirm: async () => ({ approved: true }),
180
+ onConfirm: async (call) => {
181
+ confirms.push({ name: call.name, summary: (call as any).summary });
182
+ return { approved: true };
183
+ },
184
+ slots: { from_asset: 'USDT', to_asset: 'BTC', amount: 10, amount_side: 'from' },
101
185
  });
102
186
 
103
187
  expect(res.status).toBe('done');
104
- expect(captured.map((c) => c.name)).toEqual([
105
- 'kaleidoswap_get_quote',
106
- 'rln_create_ln_invoice',
107
- 'kaleidoswap_atomic_init',
108
- 'rln_pay_invoice',
109
- 'kaleidoswap_atomic_execute',
110
- ]);
111
- const init = captured.find((c) => c.name === 'kaleidoswap_atomic_init')!;
112
- expect(init.args.receive_invoice).toBe('lnbc1user');
188
+ // Exactly one confirm, on the first spend step (init), with the rich summary.
189
+ expect(confirms).toHaveLength(1);
190
+ expect(confirms[0]!.name).toBe('kaleidoswap_atomic_init');
191
+ expect(confirms[0]!.summary).toContain('10 USDT');
192
+ expect(confirms[0]!.summary).toContain('15,250 sats');
193
+ expect(confirms[0]!.summary).toContain('154 sats');
113
194
  });
114
- });
115
195
 
116
- describe('kaleidoswapAtomicRecipe confirmation gate', () => {
117
- it('cancels the chain when the user declines a spend gate', async () => {
196
+ it('declining the single gate cancels the whole chain before any spend', async () => {
118
197
  const captured: { name: string; args: any }[] = [];
119
198
  const tools = buildStubs(captured);
120
- let firstSpendSeen = false;
121
199
 
122
- const res = await runRecipe(kaleidoswapAtomicRecipe, 'atomic swap 100000 sats for usdt', {
200
+ const res = await runRecipe(kaleidoswapAtomicRecipe, 'swap 10 usdt to btc', {
123
201
  provider: refusingProvider,
124
202
  tools,
125
- onConfirm: async () => {
126
- if (firstSpendSeen) return { approved: true };
127
- firstSpendSeen = true;
128
- return { approved: false, reason: 'user said no' };
129
- },
203
+ onConfirm: async () => ({ approved: false, reason: 'user said no' }),
204
+ slots: { from_asset: 'USDT', to_asset: 'BTC', amount: 10, amount_side: 'from' },
130
205
  });
131
206
 
132
- expect(res.status).not.toBe('done');
133
- // The first spend tool (atomic_init) should NOT have completed successfully
134
- // the chain stops before pay/execute.
135
- expect(captured.some((c) => c.name === 'rln_pay_invoice')).toBe(false);
136
- expect(captured.some((c) => c.name === 'kaleidoswap_atomic_execute')).toBe(false);
207
+ expect(res.status).toBe('cancelled');
208
+ // Quote ran (read-only), but NOTHING after the declined gate.
209
+ expect(captured.map((c) => c.name)).toEqual(['kaleidoswap_get_quote']);
137
210
  });
138
211
  });