@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
@@ -0,0 +1,548 @@
1
+ /**
2
+ * Built-in "buy inbound channel capacity from the LSP" recipe (LSPS1).
3
+ *
4
+ * "buy a 500k inbound channel"
5
+ * "I need 200000 sats of inbound liquidity"
6
+ * "open a channel from the LSP, 1M inbound for 30 days"
7
+ * ↓ heuristic pre-filter (0 inf) decides to enter the recipe branch
8
+ * ↓ 1 model inference (forced LLM slot extraction)
9
+ * lsp_get_info ← LSP options + assets (read-only)
10
+ * lsp_estimate_fees ← LSP fee preview (read-only)
11
+ * rln_get_node_info ← NODE client_pubkey (read-only)
12
+ * ↓ [ONE confirmation gate — shows estimated total fee + channel terms]
13
+ * lsp_create_order ← LSP creates the order → bolt11 invoice
14
+ * rln_pay_invoice ← NODE pays the LSP invoice → channel opens
15
+ *
16
+ * Mirrors the single-confirm pattern from kaleidoswapAtomicRecipe: the user
17
+ * decides ONCE on the fee, then create_order + pay_invoice run as one
18
+ * approved unit. The channel's actual opening is asynchronous — the recipe
19
+ * reports "order placed and paid, channel opening" and leaves polling to a
20
+ * follow-up turn (lsp_get_order) so the chat isn't blocked.
21
+ *
22
+ * forceModelExtract: a small model can't reliably regex out a fuzzy phrasing
23
+ * like "I want a channel from the LSP, 500k inbound for a month, no push" —
24
+ * so the recipe still owns the chain + the single confirm, but lets the LLM
25
+ * do the natural-language understanding for slot extraction.
26
+ */
27
+
28
+ import type { Recipe, RecipeContext } from './types.js';
29
+
30
+ /** Default expiry: ~30 days at 10-min blocks. The recipe surfaces this in confirm. */
31
+ const DEFAULT_EXPIRY_BLOCKS = 4320;
32
+
33
+ /**
34
+ * Fire on inbound-liquidity / channel-order intent. Excludes:
35
+ * - explanatory / educational questions ("why do I need a channel?", "what
36
+ * is a channel?") — those go to RAG-backed agentic answering.
37
+ * - the trading skill's territory.
38
+ */
39
+ function CHANNEL_INTENT(t: string): boolean {
40
+ // Explanatory/question phrasing → not an order, let the agentic path handle it.
41
+ if (/\b(why|how|what|when|explain|tell\s+me|do\s+I\s+need|should\s+I|can\s+I)\b/i.test(t)) return false;
42
+ // Explicit LSPS1 keywords always match.
43
+ if (/\b(lsps1|lsp\s+order|channel\s+order)\b/i.test(t)) return true;
44
+ // Inbound liquidity asks.
45
+ if (/\binbound(\s+(liquidity|capacity|channel))?\b/i.test(t)) return true;
46
+ if (/\bcan('?t| not)\s+receive\b/i.test(t)) return true;
47
+ // "Buy / open / get a channel from the LSP" (or just "from KaleidoSwap"
48
+ // when the keyword "channel" is present).
49
+ if (/\b(buy|open|get|order)\b.*\bchannel\b/i.test(t)) return true;
50
+ return false;
51
+ }
52
+
53
+ const SATS_RE =
54
+ /\b([0-9][\d,.]*)\s*(k|m|million|million sats?)?\s*(sats?|satoshis?|inbound|channel)?\b/i;
55
+
56
+ /** "500k" → 500000; "1m" → 1_000_000; "200,000" → 200000. */
57
+ function parseAmountWord(num: string, suffix?: string): number | undefined {
58
+ const n = Number(num.replace(/,/g, ''));
59
+ if (!Number.isFinite(n)) return undefined;
60
+ if (!suffix) return Math.round(n);
61
+ const s = suffix.toLowerCase();
62
+ if (/^k/.test(s)) return Math.round(n * 1_000);
63
+ if (/^m/.test(s)) return Math.round(n * 1_000_000);
64
+ return Math.round(n);
65
+ }
66
+
67
+ /**
68
+ * Deterministic extractor — fast pre-filter for the Funnel to decide whether
69
+ * to enter the recipe branch. The model still runs (forceModelExtract) for
70
+ * the slots actually used in execution.
71
+ */
72
+ export function extractChannelOrder(text: string): Record<string, unknown> | null {
73
+ const t = text.trim();
74
+ if (!CHANNEL_INTENT(t)) return null;
75
+
76
+ // Count standalone numeric tokens. Two-or-more numbers (e.g.
77
+ // "20000 my side 80000 lsp") are ambiguous to the regex — bail out and
78
+ // let the LLM disambiguate; the Funnel still fires the recipe via
79
+ // forceModelExtract + match().
80
+ const numberTokens = t.match(/\b\d[\d,.]*\s*(?:k|m|million)?\b/gi) ?? [];
81
+ const multipleNumbers = numberTokens.length >= 2;
82
+
83
+ // Expiry in days/months/blocks → blocks (10 min ≈ 1 block). Safe to parse
84
+ // independently of the balance numbers because the unit token disambiguates.
85
+ let channel_expiry_blocks: number | undefined;
86
+ const exp = t.match(/(\d+)\s*(day|days|week|weeks|month|months|block|blocks)\b/i);
87
+ if (exp) {
88
+ const n = Number(exp[1]);
89
+ const unit = exp[2]!.toLowerCase();
90
+ if (/block/.test(unit)) channel_expiry_blocks = n;
91
+ else if (/day/.test(unit)) channel_expiry_blocks = n * 144;
92
+ else if (/week/.test(unit)) channel_expiry_blocks = n * 144 * 7;
93
+ else if (/month/.test(unit)) channel_expiry_blocks = n * 144 * 30;
94
+ }
95
+
96
+ // Side-tagged amounts. We try KEYWORD-FIRST patterns ("on my side 5000",
97
+ // "lsp_balance 100000") before NUMBER-FIRST ("5000 on my side", "100k on lsp"),
98
+ // because "5000 lsp_balance 100000" is ambiguous to number-first regexes.
99
+ let client_balance_sat: number | undefined;
100
+ let lsp_balance_sat: number | undefined;
101
+
102
+ // NUMBER then KEYWORD — directional phrases ("X on my side", "X on lsp",
103
+ // "X sats my side"). The (?:sats?\s+)? lets the user say the unit between
104
+ // the number and the side keyword: "100k sats my side" → 100000.
105
+ const clientNum = t.match(/\b(\d[\d,.]*)\s*(k|m)?\s+(?:sats?\s+)?(?:on\s+(?:my|client|user)\s+side|on\s+my\b|on\s+mine\b|on\s+client|my\s+side|mine\b|as\s+push|push|outbound)\b/i);
106
+ if (clientNum && clientNum[1]) client_balance_sat = parseAmountWord(clientNum[1], clientNum[2]);
107
+
108
+ const lspNum = t.match(/\b(\d[\d,.]*)\s*(k|m)?\s+(?:sats?\s+)?(?:on\s+(?:the\s+)?lsps?|for\s+(?:the\s+)?lsps?|as\s+inbound|inbound|lsps?[_\s]+side)\b/i);
109
+ if (lspNum && lspNum[1]) lsp_balance_sat = parseAmountWord(lspNum[1], lspNum[2]);
110
+
111
+ // "on the other" / "the other side" (when "my side" was mentioned) -> lsp side
112
+ if (client_balance_sat != null && lsp_balance_sat == null) {
113
+ const otherNum = t.match(/\b(\d[\d,.]*)\s*(k|m)?\s+(?:sats?\s+)?(?:on\s+the\s+other|the\s+other\s+side)\b/i);
114
+ if (otherNum && otherNum[1]) lsp_balance_sat = parseAmountWord(otherNum[1], otherNum[2]);
115
+ }
116
+
117
+ // Specific anchored pattern for "on my side X and Y on the other" or similar structures
118
+ if (client_balance_sat != null && lsp_balance_sat == null) {
119
+ const otherAfterMy = t.match(/on\s+my\s+side.*?\b(\d[\d,.]*)\s*(k|m)?\s+(?:sats?\s+)?on\s+the\s+other\b/i);
120
+ if (otherAfterMy && otherAfterMy[1]) {
121
+ lsp_balance_sat = parseAmountWord(otherAfterMy[1]!, otherAfterMy[2]);
122
+ }
123
+ }
124
+
125
+ // If we have a "my side" client and a second untagged number, treat the second as lsp (the other side)
126
+ if (client_balance_sat != null && lsp_balance_sat == null) {
127
+ const allNumMatches = [...t.matchAll(/\b(\d[\d,.]*)\s*(k|m)?\b/gi)];
128
+ const clientStr = client_balance_sat.toString();
129
+ const otherMatch = allNumMatches.find(m => {
130
+ const n = m[1] ? parseAmountWord(m[1], m[2]) : null;
131
+ return n != null && n.toString() !== clientStr && n > 0;
132
+ });
133
+ if (otherMatch) {
134
+ lsp_balance_sat = parseAmountWord(otherMatch[1]!, otherMatch[2]);
135
+ }
136
+ }
137
+
138
+ // KEYWORD then NUMBER — programmatic phrasings ("client_balance 5000",
139
+ // "lsp_balance 100000", "with 10k push"). Skip "my side" / "on lsp" here
140
+ // — those are number-first in real English (handled above).
141
+ if (client_balance_sat == null) {
142
+ const clientKw = t.match(/(?:client[_\s]+balance|push\s+of|outbound\s+of|with\s+(\d[\d,.]*)\s*(k|m)?\s+push)\s*(?:of\s+)?(\d[\d,.]*)?\s*(k|m)?\b/i);
143
+ if (clientKw) {
144
+ // Either "with N push" (groups 1+2) or "client_balance N" (groups 3+4).
145
+ const num = clientKw[1] ?? clientKw[3];
146
+ const suf = clientKw[1] ? clientKw[2] : clientKw[4];
147
+ if (num) client_balance_sat = parseAmountWord(num, suf);
148
+ }
149
+ }
150
+ if (lsp_balance_sat == null) {
151
+ const lspKw = t.match(/(?:lsp[_\s]+balance|lsp[_\s]+side|inbound\s+capacity|inbound\s+of|lsps?[_\s]+balance)\s*(?:of\s+)?(\d[\d,.]*)\s*(k|m)?\b/i);
152
+ if (lspKw && lspKw[1]) lsp_balance_sat = parseAmountWord(lspKw[1], lspKw[2]);
153
+ }
154
+
155
+ // Flexible "lsp 100k" / "100k lsp" / "100k on lsp" / "on lsps" patterns (handles "lsps", "lsp's" etc.)
156
+ if (lsp_balance_sat == null) {
157
+ const lspNumFirst = t.match(/\b(\d[\d,.]*)\s*(k|m)?\s+(?:sats?\s+)?lsps?\b/i);
158
+ if (lspNumFirst && lspNumFirst[1]) lsp_balance_sat = parseAmountWord(lspNumFirst[1], lspNumFirst[2]);
159
+ const lspWordFirst = t.match(/\blsps?\s+(?:balance\s+)?(\d[\d,.]*)\s*(k|m)?\b/i);
160
+ if (lsp_balance_sat == null && lspWordFirst && lspWordFirst[1]) lsp_balance_sat = parseAmountWord(lspWordFirst[1], lspWordFirst[2]);
161
+ const lspOn = t.match(/\b(\d[\d,.]*)\s*(k|m)?\s+(?:sats?\s+)?on\s+lsps?\b/i);
162
+ if (lsp_balance_sat == null && lspOn && lspOn[1]) lsp_balance_sat = parseAmountWord(lspOn[1], lspOn[2]);
163
+ const lspAfter = t.match(/\b(\d[\d,.]*)\s*(k|m)?\s+lsps?\s*(?:side|balance)?\b/i);
164
+ if (lsp_balance_sat == null && lspAfter && lspAfter[1]) lsp_balance_sat = parseAmountWord(lspAfter[1], lspAfter[2]);
165
+ }
166
+
167
+ // SINGLE-amount default: if there's only one number and we couldn't tag it
168
+ // as client/lsp by phrasing, treat it as lsp_balance_sat (the user is
169
+ // asking for inbound liquidity). With multiple numbers and no disambiguating
170
+ // phrasing, return null and let the LLM sort it out.
171
+ if (!lsp_balance_sat && !multipleNumbers) {
172
+ const m = t.match(/\b(\d[\d,.]*)\s*(k|m|million)?\b/i);
173
+ if (m && m[1]) lsp_balance_sat = parseAmountWord(m[1], m[2]);
174
+ }
175
+
176
+ // RGB asset channel: a ticker (USDT/XAUT) plus an asset amount.
177
+ // We don't resolve the ticker → asset_id here — that happens deterministically
178
+ // from lsp_get_info during the recipe. We just record what the user said.
179
+ let asset_ticker: string | undefined;
180
+ const tickerMatch = t.match(/\b(usdt|tether|xaut|gold)\b/i);
181
+ if (tickerMatch) {
182
+ const x = tickerMatch[1]!.toLowerCase();
183
+ asset_ticker = /usdt|tether/.test(x) ? 'USDT' : 'XAUT';
184
+ }
185
+
186
+ let lsp_asset_amount: number | undefined;
187
+ let client_asset_amount: number | undefined;
188
+ // Asset amount keywords. "N USDT" alone is ambiguous; with side keywords
189
+ // we can disambiguate: "N USDT inbound" / "N USDT lsp side" → lsp side;
190
+ // "N USDT my side" / "N USDT pushed" / "pushed N USDT" → client side.
191
+ if (asset_ticker) {
192
+ // CLIENT-side asset (push)
193
+ const pushAssetNum = t.match(/\b(\d[\d,.]*)\s+(?:usdt|tether|xaut|gold)\s+(?:on\s+my\s+side|on\s+(?:my|client|user)\s+side|my\s+side|pushed?(?:\s+to\s+(?:my|client)\s+side)?)\b/i);
194
+ if (pushAssetNum && pushAssetNum[1]) client_asset_amount = parseAmountWord(pushAssetNum[1]);
195
+ const pushAssetKw = t.match(/\bpush(?:ed)?\s+(\d[\d,.]*)\s*(?:usdt|tether|xaut|gold)\b/i);
196
+ if (client_asset_amount == null && pushAssetKw && pushAssetKw[1]) client_asset_amount = parseAmountWord(pushAssetKw[1]);
197
+
198
+ // LSP-side asset (inbound)
199
+ const lspAssetNum = t.match(/\b(\d[\d,.]*)\s+(?:usdt|tether|xaut|gold)\s+(?:inbound|on\s+(?:the\s+)?lsp(?:\s+side)?|for\s+(?:the\s+)?lsp|lsp[_\s]+side)\b/i);
200
+ if (lspAssetNum && lspAssetNum[1]) lsp_asset_amount = parseAmountWord(lspAssetNum[1]);
201
+
202
+ // Default: a single "N USDT" without a side keyword → lsp side (the
203
+ // inbound ask). Skip if we already captured client_asset_amount and the
204
+ // SAME number could be the user-side amount.
205
+ if (lsp_asset_amount == null) {
206
+ const allAssetMatches = t.match(/\b\d[\d,.]*\s*(?:usdt|tether|xaut|gold)\b/gi) ?? [];
207
+ const ambiguous = allAssetMatches.length > 1;
208
+ if (!ambiguous) {
209
+ const am = t.match(/\b(\d[\d,.]*)\s*(?:usdt|tether|xaut|gold)\b/i);
210
+ if (am && am[1]) lsp_asset_amount = parseAmountWord(am[1]);
211
+ }
212
+ }
213
+ }
214
+
215
+ const out: Record<string, unknown> = {};
216
+ if (lsp_balance_sat != null) out.lsp_balance_sat = lsp_balance_sat;
217
+ if (client_balance_sat != null) out.client_balance_sat = client_balance_sat;
218
+ if (channel_expiry_blocks != null) out.channel_expiry_blocks = channel_expiry_blocks;
219
+ if (asset_ticker != null) out.asset_ticker = asset_ticker;
220
+ if (lsp_asset_amount != null) out.lsp_asset_amount = lsp_asset_amount;
221
+ if (client_asset_amount != null) out.client_asset_amount = client_asset_amount;
222
+ // Return null when no concrete fields were extracted — the Funnel still
223
+ // fires the recipe because forceModelExtract + match() carry the intent.
224
+ // The runner's LLM extraction populates slots; if even the LLM can't
225
+ // produce lsp_balance_sat, runRecipe returns status:'needs-info'.
226
+ //
227
+ // Note: the deterministic extractor is intentionally "best effort" and a bit
228
+ // brittle for Funnel pre-filtering. The LLM (via forceModelExtract) is the
229
+ // primary slot parser for varied natural language. If you change sentence
230
+ // structure a lot, the LLM descriptions (with examples) are what save us.
231
+ return Object.keys(out).length > 0 ? out : null;
232
+ }
233
+
234
+ interface LspAsset {
235
+ asset_id?: string;
236
+ ticker?: string;
237
+ name?: string;
238
+ precision?: number;
239
+ min_initial_lsp_amount?: number;
240
+ max_initial_lsp_amount?: number;
241
+ }
242
+ interface LspInfo {
243
+ lsp_connection_url?: string;
244
+ options?: {
245
+ min_initial_lsp_balance_sat?: number;
246
+ max_initial_lsp_balance_sat?: number;
247
+ max_channel_expiry_blocks?: number;
248
+ };
249
+ assets?: LspAsset[];
250
+ }
251
+
252
+ /** Find the LSP's record for a ticker (USDT, XAUT). Case-insensitive. */
253
+ function findAsset(info: LspInfo | undefined, ticker: string | undefined): LspAsset | undefined {
254
+ if (!info?.assets || !ticker) return undefined;
255
+ const t = ticker.toUpperCase();
256
+ return info.assets.find((a) => (a.ticker ?? '').toUpperCase() === t);
257
+ }
258
+
259
+ /** "100" USDT (precision 6) → 100_000_000 micro-USDT. */
260
+ function scaleAsset(amount: number | undefined, precision: number | undefined): number | undefined {
261
+ if (amount == null) return undefined;
262
+ const p = Number(precision ?? 0);
263
+ return Math.round(amount * Math.pow(10, p));
264
+ }
265
+ interface FeesResult {
266
+ setup_fee?: number;
267
+ capacity_fee?: number;
268
+ duration_fee?: number;
269
+ total_fee?: number;
270
+ }
271
+ interface NodeInfo { pubkey?: string }
272
+ interface OrderResult {
273
+ order_id?: string;
274
+ access_token?: string;
275
+ order_state?: string;
276
+ // The maker echoes the capacities it ACCEPTED — may differ from what was
277
+ // requested (e.g. it can zero client_balance_sat). Used for verification.
278
+ lsp_balance_sat?: number;
279
+ client_balance_sat?: number;
280
+ asset_id?: string;
281
+ lsp_asset_amount?: number;
282
+ client_asset_amount?: number;
283
+ payment?: {
284
+ bolt11?: {
285
+ invoice?: string;
286
+ order_total_sat?: number;
287
+ fee_total_sat?: number;
288
+ };
289
+ };
290
+ }
291
+ interface ChannelRow {
292
+ channel_id?: string;
293
+ capacity_sat?: number;
294
+ inbound_sat?: number;
295
+ outbound_sat?: number;
296
+ asset_id?: string;
297
+ asset_local_amount?: number;
298
+ asset_remote_amount?: number;
299
+ ready?: boolean;
300
+ status?: string;
301
+ }
302
+ interface ChannelsResult { channels?: ChannelRow[]; count?: number }
303
+
304
+ export const kaleidoswapChannelOrderRecipe: Recipe = {
305
+ name: 'kaleidoswap-channel-order',
306
+ description:
307
+ "Buy inbound Lightning channel capacity from the LSP via LSPS1: check options, estimate fees, fetch the user's pubkey, confirm once, create the order and pay the LSP invoice.",
308
+ match: (t) => CHANNEL_INTENT(t),
309
+ triggers: ['inbound', 'liquidity', 'channel order', 'lsps1', 'lsp', 'open channel'],
310
+ slots: [
311
+ {
312
+ name: 'lsp_balance_sat',
313
+ type: 'number',
314
+ description:
315
+ "Sats the LSP commits on THEIR side — the inbound capacity for the user. " +
316
+ "Phrasings: 'inbound', 'lsp side', 'their side', 'on lsp', 'on lsps', 'X for lsp', 'lsp balance', 'lsp 100k', '100k lsp', '100k on lsp', 'on the other', 'the other side' (when 'my side' mentioned). " +
317
+ "Example: in 'buy a channel, 20000 my side, 80000 on lsp', lsp_balance_sat = 80000. " +
318
+ "Another: 'buy a channel for me with 100000 on lsps and 20000 on my side' → lsp_balance_sat = 100000. " +
319
+ "Example: 'get a channel with 30000 on my side and 80000 on the other' → lsp_balance_sat = 80000.",
320
+ required: true,
321
+ },
322
+ {
323
+ name: 'client_balance_sat',
324
+ type: 'number',
325
+ description:
326
+ "Sats the user PRE-FUNDS into the channel (push amount). 0 by default. " +
327
+ "Phrasings: 'my side', 'client side', 'outbound', 'push', 'I put in', 'X on my side', 'on my side' (the other is lsp). " +
328
+ "Example: in 'buy a channel, 20000 my side, 80000 on lsp', client_balance_sat = 20000. " +
329
+ "Another: 'with 100000 on lsps and 20000 on my side' → client_balance_sat = 20000. " +
330
+ "Example: 'get a channel with 30000 on my side and 80000 on the other' → client_balance_sat = 30000.",
331
+ },
332
+ {
333
+ name: 'channel_expiry_blocks',
334
+ type: 'number',
335
+ description:
336
+ "Lease duration in blocks (10 min per block). Default 4320 (~30 days). " +
337
+ "Map natural language: '1 month' → 4320, '1 week' → 1008, 'N days' → N*144.",
338
+ },
339
+ {
340
+ name: 'asset_ticker',
341
+ type: 'string',
342
+ description:
343
+ "RGB asset ticker for an asset channel (USDT or XAUT). Omit for a plain BTC channel. " +
344
+ "Recognise: 'USDT channel', 'a USDT channel', 'channel with USDT', 'Tether' → USDT; " +
345
+ "'gold', 'XAUT' → XAUT.",
346
+ },
347
+ {
348
+ name: 'lsp_asset_amount',
349
+ type: 'number',
350
+ description:
351
+ "Asset units the LSP commits on their side. UNITS, not micro-units (the host scales " +
352
+ "by the asset's precision). Example: '100 USDT' → lsp_asset_amount = 100. " +
353
+ "Only set when the user is buying an asset channel.",
354
+ },
355
+ {
356
+ name: 'client_asset_amount',
357
+ type: 'number',
358
+ description:
359
+ "Asset units the LSP pushes to the USER's side at channel open (costs sats at the " +
360
+ "current swap rate). UNITS, not micro-units. Default 0. Only set if the user wants " +
361
+ "spendable asset balance immediately, not just inbound capacity. Requires rfq_id.",
362
+ },
363
+ {
364
+ name: 'rfq_id',
365
+ type: 'string',
366
+ description:
367
+ "Quote id from a prior kaleidoswap_get_quote — required only when client_asset_amount > 0 " +
368
+ "so the LSP can price the asset push at a fixed rate. Omit otherwise.",
369
+ },
370
+ ],
371
+ extract: extractChannelOrder,
372
+ forceModelExtract: true,
373
+ confident: (s) => Number(s.lsp_balance_sat) > 0,
374
+ steps: [
375
+ // 1. LSP options (limits + node URI). Read-only.
376
+ {
377
+ tool: 'lsp_get_info',
378
+ as: 'info',
379
+ args: () => ({}),
380
+ },
381
+ // 2. Fee estimate for the requested size. For asset channels, the maker's
382
+ // estimate_fees doesn't yet take asset_id (per the integration test
383
+ // body) — the asset spec is on the create_order body. Estimate the
384
+ // sats portion; the asset side is provisioned LSP-server-side.
385
+ {
386
+ tool: 'lsp_estimate_fees',
387
+ as: 'fees',
388
+ args: (ctx) => ({
389
+ lsp_balance_sat: Number(ctx.slots.lsp_balance_sat),
390
+ client_balance_sat: Number(ctx.slots.client_balance_sat ?? 0),
391
+ channel_expiry_blocks: Number(ctx.slots.channel_expiry_blocks ?? DEFAULT_EXPIRY_BLOCKS),
392
+ }),
393
+ },
394
+ // 3. User's node pubkey — needed for create_order. Deterministic.
395
+ {
396
+ tool: 'rln_get_node_info',
397
+ as: 'node',
398
+ args: () => ({}),
399
+ },
400
+ // 3a. Snapshot existing channels so we can identify the NEW one after the
401
+ // order opens (diff by channel_id). Without this, verification can't
402
+ // tell the freshly-opened channel from pre-existing ones.
403
+ {
404
+ tool: 'rln_list_channels',
405
+ as: 'channels_before',
406
+ args: () => ({}),
407
+ },
408
+ // 3b. Asset push leg: when client_asset_amount > 0, the maker requires
409
+ // a fresh rfq_id from kaleidoswap_get_quote(BTC → asset) so the LSP
410
+ // can lock the BTC price for the asset push. The maker's RFQ ↔
411
+ // order asset-id check is strict, so pass the FULL rgb: URI (not
412
+ // the ticker) as to_asset, matching what create_order will send.
413
+ // Skip when there's no push asset.
414
+ {
415
+ tool: 'kaleidoswap_get_quote',
416
+ as: 'asset_quote',
417
+ args: (ctx) => {
418
+ const info = ctx.results.info as LspInfo | undefined;
419
+ const ticker = ctx.slots.asset_ticker ? String(ctx.slots.asset_ticker) : undefined;
420
+ const asset = findAsset(info, ticker);
421
+ return {
422
+ from_asset: 'BTC',
423
+ to_asset: asset?.asset_id ?? ticker ?? 'USDT',
424
+ amount: scaleAsset(Number(ctx.slots.client_asset_amount ?? 0), asset?.precision),
425
+ amount_side: 'to',
426
+ };
427
+ },
428
+ skipIf: (ctx) => Number(ctx.slots.client_asset_amount ?? 0) <= 0,
429
+ },
430
+ // 4. Create the order. Spend → this is where the single confirm gate
431
+ // fires. For asset channels we resolve the ticker → asset_id from
432
+ // lsp_get_info.assets, and scale the agent-facing unit amount by the
433
+ // asset's precision (USDT precision=6 → ×1e6).
434
+ {
435
+ tool: 'lsp_create_order',
436
+ as: 'order',
437
+ args: (ctx) => {
438
+ const node = ctx.results.node as NodeInfo | undefined;
439
+ const info = ctx.results.info as LspInfo | undefined;
440
+ const tickerSlot = ctx.slots.asset_ticker ? String(ctx.slots.asset_ticker) : undefined;
441
+ const asset = findAsset(info, tickerSlot);
442
+ const body: Record<string, unknown> = {
443
+ client_pubkey: node?.pubkey,
444
+ lsp_balance_sat: Number(ctx.slots.lsp_balance_sat),
445
+ client_balance_sat: Number(ctx.slots.client_balance_sat ?? 0),
446
+ channel_expiry_blocks: Number(ctx.slots.channel_expiry_blocks ?? DEFAULT_EXPIRY_BLOCKS),
447
+ };
448
+ if (asset?.asset_id && (ctx.slots.lsp_asset_amount != null || ctx.slots.client_asset_amount != null)) {
449
+ body.asset_id = asset.asset_id;
450
+ const lspAmt = scaleAsset(Number(ctx.slots.lsp_asset_amount ?? 0), asset.precision);
451
+ const cliAmt = scaleAsset(Number(ctx.slots.client_asset_amount ?? 0), asset.precision);
452
+ if (lspAmt != null) body.lsp_asset_amount = lspAmt;
453
+ if (cliAmt != null) body.client_asset_amount = cliAmt;
454
+ // client_asset_amount > 0 requires an rfq_id from a BTC→asset quote.
455
+ // Auto-sourced from step 3b above; falls back to a user-supplied
456
+ // slot if step 3b was skipped or didn't return one.
457
+ const autoQuote = ctx.results.asset_quote as { rfq_id?: string } | undefined;
458
+ const rfq = autoQuote?.rfq_id ?? (ctx.slots.rfq_id != null ? String(ctx.slots.rfq_id) : undefined);
459
+ if (rfq) body.rfq_id = rfq;
460
+ }
461
+ return body;
462
+ },
463
+ },
464
+ // 5. Pay the LSP's Lightning invoice. Spend, but no second prompt — the
465
+ // single recipe-level confirm covered the decision to commit funds.
466
+ {
467
+ tool: 'rln_pay_invoice',
468
+ as: 'paid',
469
+ args: (ctx) => {
470
+ const order = ctx.results.order as OrderResult | undefined;
471
+ return { invoice: order?.payment?.bolt11?.invoice };
472
+ },
473
+ },
474
+ ],
475
+ // 6. VERIFY: list the node's channels so we can compare the requested
476
+ // capacity against what actually opened. Read-only, so no gate. On
477
+ // regtest the channel funds within seconds; on slower nets it may not
478
+ // be visible yet — the summary reports either way.
479
+ final: {
480
+ tool: 'rln_list_channels',
481
+ as: 'channels',
482
+ args: () => ({}),
483
+ },
484
+ // ONE confirmation, fired after estimate_fees + get_node_info, before
485
+ // lsp_create_order. Shows the real total fee + BOTH sides of the channel.
486
+ confirm: (ctx: RecipeContext) => {
487
+ const fees = ctx.results.fees as FeesResult | undefined;
488
+ const inbound = Number(ctx.slots.lsp_balance_sat);
489
+ const mine = Number(ctx.slots.client_balance_sat ?? 0);
490
+ const expiry = Number(ctx.slots.channel_expiry_blocks ?? DEFAULT_EXPIRY_BLOCKS);
491
+ const days = Math.round(expiry / 144);
492
+ const feeStr = fees?.total_fee != null ? ` for ${fees.total_fee.toLocaleString()} sats` : '';
493
+ const minePart = mine > 0 ? ` + ${mine.toLocaleString()} sats on your side` : '';
494
+ const ticker = ctx.slots.asset_ticker ? String(ctx.slots.asset_ticker) : undefined;
495
+ const lspAsset = Number(ctx.slots.lsp_asset_amount ?? 0);
496
+ const cliAsset = Number(ctx.slots.client_asset_amount ?? 0);
497
+ const assetPart = ticker
498
+ ? ` + ${lspAsset.toLocaleString()} ${ticker} inbound${cliAsset > 0 ? ` and ${cliAsset.toLocaleString()} ${ticker} on your side` : ''}`
499
+ : '';
500
+ return `Buy a channel: ${inbound.toLocaleString()} sats inbound${minePart}${assetPart} from the LSP (~${days} days)${feeStr}. Proceed?`;
501
+ },
502
+ summary: (ctx) => {
503
+ const order = ctx.results.order as OrderResult | undefined;
504
+ const channels = ctx.results.channels as ChannelsResult | undefined;
505
+ const id = order?.order_id ?? '?';
506
+ const token = order?.access_token;
507
+ const tokenNote = token ? ` order_id=${id} access_token=${token}` : '';
508
+ const total = order?.payment?.bolt11?.order_total_sat;
509
+ const paid = total != null ? `, paid ${total.toLocaleString()} sats` : '';
510
+
511
+ // VERIFY requested vs accepted (the maker echoes what it actually took).
512
+ const reqInbound = Number(ctx.slots.lsp_balance_sat);
513
+ const reqMine = Number(ctx.slots.client_balance_sat ?? 0);
514
+ const gotInbound = order?.lsp_balance_sat;
515
+ const gotMine = order?.client_balance_sat;
516
+ const mismatches: string[] = [];
517
+ if (gotInbound != null && gotInbound !== reqInbound) {
518
+ mismatches.push(`inbound ${reqInbound.toLocaleString()}→${gotInbound.toLocaleString()} sats`);
519
+ }
520
+ if (gotMine != null && gotMine !== reqMine) {
521
+ mismatches.push(`your side ${reqMine.toLocaleString()}→${gotMine.toLocaleString()} sats`);
522
+ }
523
+ const adjusted = mismatches.length
524
+ ? ` ⚠ the LSP adjusted: ${mismatches.join(', ')}.`
525
+ : '';
526
+
527
+ // VERIFY against the freshly-opened channel — identified by DIFF against
528
+ // the pre-order snapshot, so we never mistake a pre-existing channel for
529
+ // the new one.
530
+ const before = ctx.results.channels_before as ChannelsResult | undefined;
531
+ const beforeIds = new Set((before?.channels ?? []).map((c) => c.channel_id));
532
+ const fresh = (channels?.channels ?? []).filter((c) => c.channel_id && !beforeIds.has(c.channel_id));
533
+ const match = fresh[0];
534
+ let opened = ' The channel will open once the LSP confirms the payment — ask me to check its status (call lsp_get_order with the exact order_id and access_token above).';
535
+ if (match) {
536
+ const cap = match.capacity_sat != null ? `${match.capacity_sat.toLocaleString()}-sat` : 'new';
537
+ const ready = match.ready ? 'ready' : (match.status ?? 'opening');
538
+ const inb = match.inbound_sat != null ? `, ${match.inbound_sat.toLocaleString()} sats inbound` : '';
539
+ opened = ` New channel ${cap} is open (${ready})${inb}.`;
540
+ }
541
+
542
+ const ticker = ctx.slots.asset_ticker ? String(ctx.slots.asset_ticker) : undefined;
543
+ const lspAsset = Number(ctx.slots.lsp_asset_amount ?? 0);
544
+ const assetPart = ticker ? ` (${lspAsset.toLocaleString()} ${ticker} inbound)` : '';
545
+
546
+ return `Channel order created. To check status use: lsp_get_order with${tokenNote} .${paid}${assetPart}.${adjusted}${opened}`;
547
+ },
548
+ };
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Price / rate / "how much" recipe — quote-only.
3
+ *
4
+ * A price question is NOT a swap. The user wants the rate, not to move funds.
5
+ * This recipe fires the maker quote (with amount=1 on the asked-about asset)
6
+ * and stops — no init, no execute, no confirmation gate. The user can then
7
+ * say "ok, do it" if they want to actually swap, which routes to the atomic
8
+ * recipe.
9
+ *
10
+ * "what is the price of usdt in sats" → quote {from:BTC, to:USDT, amt 1 on TO}
11
+ * "btc price" → quote {from:USDT, to:BTC, amt 1 on TO}
12
+ * "how much sats for 1 usdt" → quote {from:BTC, to:USDT, amt 1 on TO}
13
+ *
14
+ * Disjoint from `kaleidoswapAtomicRecipe`: this matches PRICE phrasings only;
15
+ * the atomic recipe matches swap/buy/sell phrasings only. Order matters in
16
+ * the Funnel's recipe list — register the price recipe FIRST so a phrase
17
+ * like "what's the BTC price" never reaches the swap recipe.
18
+ */
19
+
20
+ import type { Recipe, RecipeContext } from './types.js';
21
+ import { extractPriceQuery } from './swap.js';
22
+
23
+ const ASSET = /\b(btc|bitcoin|sats?|usdt|tether|xaut|gold)\b/i;
24
+ const PRICE_INTENT = (t: string) =>
25
+ /\b(price|rate|cost|worth|how\s+(?:much|many))\b/i.test(t) && ASSET.test(t);
26
+
27
+ interface QuoteResult {
28
+ rfq_id?: string;
29
+ from_amount_display?: string;
30
+ to_amount_display?: string;
31
+ fee_display?: string;
32
+ }
33
+
34
+ export const kaleidoswapPriceRecipe: Recipe = {
35
+ name: 'kaleidoswap-price',
36
+ description:
37
+ 'Quote the rate of one asset in another (read-only, no swap). Triggered by "price of X", "X price", "rate of X", "how much is X", "cost of X".',
38
+ match: (t) => PRICE_INTENT(t),
39
+ triggers: ['price', 'rate', 'cost', 'worth'],
40
+ slots: [
41
+ { name: 'from_asset', type: 'string', description: 'Denomination (the unit you want the price IN)', required: true },
42
+ { name: 'to_asset', type: 'string', description: 'The asset whose price you want', required: true },
43
+ { name: 'amount', type: 'number', description: 'Always 1 — pricing a unit of to_asset', required: true },
44
+ { name: 'amount_side', type: 'string', description: "Always 'to' — the amount sits on the priced leg" },
45
+ ],
46
+ extract: extractPriceQuery,
47
+ confident: (s) => !!s.from_asset && !!s.to_asset,
48
+ // No intermediate steps; the quote is the final action. It is read-only,
49
+ // so no confirmation gate fires — and there's no spend after it.
50
+ steps: [],
51
+ final: {
52
+ tool: 'kaleidoswap_get_quote',
53
+ as: 'quote',
54
+ args: (ctx) => ({
55
+ from_asset: ctx.slots.from_asset,
56
+ to_asset: ctx.slots.to_asset,
57
+ amount: ctx.slots.amount ?? 1,
58
+ amount_side: ctx.slots.amount_side ?? 'to',
59
+ }),
60
+ },
61
+ summary: (ctx: RecipeContext) => {
62
+ const q = ctx.results.quote as QuoteResult | undefined;
63
+ const from = q?.from_amount_display;
64
+ const to = q?.to_amount_display;
65
+ if (from && to) return `${to} = ${from}.`;
66
+ return `Quoted ${ctx.slots.to_asset} at 1 unit.`;
67
+ },
68
+ };