@kaleidorg/mind 0.10.6 → 0.10.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaleidorg/mind",
3
- "version": "0.10.6",
3
+ "version": "0.10.8",
4
4
  "description": "Local-first reasoning + function-calling engine for KaleidoSwap. QVAC-powered.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -17,10 +17,10 @@ ticker (`BTC`, `USDT`, `XAUT`) or an `rgb:…` id.
17
17
  A swap runs over Lightning: it needs a channel with enough outbound in the
18
18
  asset you send and enough inbound in the asset you receive. If
19
19
  `rln_list_channels` shows none, buy one first (see `channel-manager`).
20
- Sending BTC, the node sends the amount plus 3,000 sats (the RGB node's HTLC
21
- minimum) in one payment, so one usable channel needs outbound of at least
22
- amount + 3,000 sats. Less than that: offer a smaller swap (outbound − 3,000)
23
- or a bigger channel.
20
+ Each leg is one payment plus the node's 3,000-sat HTLC minimum: sending BTC
21
+ needs one channel with outbound ≥ amount + 3,000 sats, receiving BTC inbound ≥
22
+ amount + 3,000; the asset side needs an asset channel holding the asset (or
23
+ inbound for it). Short: offer a smaller swap or a bigger channel.
24
24
 
25
25
  ## Do
26
26
  - Quote with exactly one amount: `from_amount` to sell a fixed input,
package/src/engine.ts CHANGED
@@ -367,7 +367,7 @@ export class Engine {
367
367
  };
368
368
  } else if (def.requiresConfirmation) {
369
369
  args = check.args;
370
- const summary = confirmReadback({ name: call.name, arguments: args }) ?? undefined;
370
+ const summary = confirmReadback({ name: call.name, arguments: args }, { results: state.executed }) ?? undefined;
371
371
  const decision = opts.onConfirm
372
372
  ? await opts.onConfirm({ name: call.name, arguments: args, ...(summary ? { summary } : {}) })
373
373
  : { approved: false, reason: 'no confirmation handler available' };
@@ -212,9 +212,9 @@ describe('desktop mind — buy assets via atomic swap', () => {
212
212
  // The full deterministic chain, in order.
213
213
  expect(calls.map((c) => c.name)).toEqual([
214
214
  'kaleidoswap_get_quote',
215
+ 'rln_get_node_info',
215
216
  'rln_list_channels',
216
217
  'kaleidoswap_atomic_init',
217
- 'rln_get_node_info',
218
218
  'rln_atomic_taker',
219
219
  'kaleidoswap_atomic_execute',
220
220
  ]);
@@ -257,9 +257,9 @@ describe('desktop mind — buy assets via atomic swap', () => {
257
257
  expect(res.route).toBe('kaleidoswap-atomic');
258
258
  expect(calls.map((c) => c.name)).toEqual([
259
259
  'kaleidoswap_get_quote',
260
+ 'rln_get_node_info',
260
261
  'rln_list_channels',
261
262
  'kaleidoswap_atomic_init',
262
- 'rln_get_node_info',
263
263
  'rln_atomic_taker',
264
264
  'kaleidoswap_atomic_execute',
265
265
  ]);
@@ -204,8 +204,8 @@ describe('kaleidoswapAtomicRecipe — full chain', () => {
204
204
  expect(res.inferences).toBe(0);
205
205
  expect(captured.map((c) => c.name)).toEqual([
206
206
  'kaleidoswap_get_quote',
207
- 'kaleidoswap_atomic_init',
208
207
  'rln_get_node_info',
208
+ 'kaleidoswap_atomic_init',
209
209
  'rln_atomic_taker',
210
210
  'kaleidoswap_atomic_execute',
211
211
  ]);
@@ -307,7 +307,71 @@ describe('kaleidoswapAtomicRecipe — single confirmation', () => {
307
307
 
308
308
  expect(res.status).toBe('cancelled');
309
309
  // Quote ran (read-only), but NOTHING after the declined gate.
310
- expect(captured.map((c) => c.name)).toEqual(['kaleidoswap_get_quote']);
310
+ expect(captured.map((c) => c.name)).toEqual(['kaleidoswap_get_quote', 'rln_get_node_info']);
311
+ });
312
+ });
313
+
314
+ describe('kaleidoswapAtomicRecipe — channel liquidity preflight', () => {
315
+ const btcToUsdt = {
316
+ rfq_id: 'rfq-2',
317
+ from_asset: { asset_id: 'BTC', ticker: 'BTC', layer: 'BTC_LN', amount_raw: 50_000_000, amount_display: '50,000 sats' },
318
+ to_asset: { asset_id: 'rgb:usdt', ticker: 'USDT', layer: 'RGB_LN', amount_raw: 30_000_000, amount_display: '30' },
319
+ };
320
+ const usdtToBtc = {
321
+ rfq_id: 'rfq-3',
322
+ from_asset: { asset_id: 'rgb:usdt', ticker: 'USDT', layer: 'RGB_LN', amount_raw: 10_000_000, amount_display: '10' },
323
+ to_asset: { asset_id: 'BTC', ticker: 'BTC', layer: 'BTC_LN', amount_raw: 15_000_000, amount_display: '15,000 sats' },
324
+ };
325
+ const usdtChannel = (over: Record<string, unknown> = {}) => ({
326
+ ready: true,
327
+ is_usable: true,
328
+ asset_id: 'rgb:usdt',
329
+ asset_local_amount: 20_000_000,
330
+ asset_remote_amount: 40_000_000,
331
+ next_outbound_htlc_limit_msat: 60_000_000,
332
+ inbound_balance_msat: 50_000_000,
333
+ ...over,
334
+ });
335
+
336
+ it('BTC -> asset: BTC outbound must cover the amount plus the HTLC minimum', async () => {
337
+ const { swapLiquidityShortfall } = await import('./kaleidoswap-atomic.js');
338
+ expect(swapLiquidityShortfall(btcToUsdt as never, { channels: [usdtChannel({ next_outbound_htlc_limit_msat: 51_000_000 })] })).toBe(
339
+ 'your channels can send at most 51,000 sats, and this swap needs 53,000 sats (the amount plus the 3,000 sats HTLC minimum). Swap at most 48,000 sats, or buy a bigger channel.',
340
+ );
341
+ expect(swapLiquidityShortfall(btcToUsdt as never, { channels: [usdtChannel()] })).toBeNull();
342
+ expect(swapLiquidityShortfall(btcToUsdt as never, { channels: [usdtChannel({ next_outbound_htlc_limit_msat: 3_000_000 })] })).toBe(
343
+ 'your channels can send at most 3,000 sats, which only covers the 3,000 sats HTLC minimum, so no BTC swap fits. This one needs 53,000 sats: buy a channel with more outbound.',
344
+ );
345
+ expect(swapLiquidityShortfall(btcToUsdt as never, { channels: [] })).toMatch(/no channels/);
346
+ expect(swapLiquidityShortfall(btcToUsdt as never, { channels: [usdtChannel({ ready: false })] })).toMatch(/none can send right now/);
347
+ });
348
+
349
+ it('BTC -> asset: an asset channel must be able to receive the asset', async () => {
350
+ const { swapLiquidityShortfall } = await import('./kaleidoswap-atomic.js');
351
+ expect(swapLiquidityShortfall(btcToUsdt as never, { channels: [usdtChannel({ asset_remote_amount: 5_000_000 })] })).toMatch(
352
+ /^no channel can receive 30 USDT/,
353
+ );
354
+ expect(swapLiquidityShortfall(btcToUsdt as never, { channels: [usdtChannel({ inbound_balance_msat: 1_000_000 })] })).toMatch(
355
+ /^no channel can receive 30 USDT/,
356
+ );
357
+ });
358
+
359
+ it('asset -> BTC: the asset must be on our side and BTC inbound must cover amount + minimum', async () => {
360
+ const { swapLiquidityShortfall } = await import('./kaleidoswap-atomic.js');
361
+ expect(swapLiquidityShortfall(usdtToBtc as never, { channels: [usdtChannel()] })).toBeNull();
362
+ expect(swapLiquidityShortfall(usdtToBtc as never, { channels: [usdtChannel({ asset_local_amount: 1_000_000 })] })).toMatch(
363
+ /^no channel can send 10 USDT/,
364
+ );
365
+ expect(swapLiquidityShortfall(usdtToBtc as never, { channels: [usdtChannel({ inbound_balance_msat: 10_000_000 })] })).toBe(
366
+ 'your channels can receive at most 10,000 sats, and this swap pays you 18,000 sats (the amount plus the 3,000 sats HTLC minimum). Swap for less BTC, or get more inbound.',
367
+ );
368
+ });
369
+
370
+ it("uses the node's HTLC minimum and doesn't block on unreadable data", async () => {
371
+ const { swapLiquidityShortfall } = await import('./kaleidoswap-atomic.js');
372
+ expect(swapLiquidityShortfall(btcToUsdt as never, { channels: [usdtChannel({ next_outbound_htlc_limit_msat: 51_000_000 })] }, 1_000_000)).toBeNull();
373
+ expect(swapLiquidityShortfall(btcToUsdt as never, { channels: [{ channel_id: 'x' }] })).toBeNull();
374
+ expect(swapLiquidityShortfall(btcToUsdt as never, 'not json')).toBeNull();
311
375
  });
312
376
  });
313
377
 
@@ -321,10 +385,10 @@ describe('kaleidoswapAtomicRecipe — outbound preflight (BTC leg + RLN HTLC min
321
385
  it('reports the shortfall in plain numbers', async () => {
322
386
  const { outboundShortfall } = await import('./kaleidoswap-atomic.js');
323
387
  expect(outboundShortfall(btcQuote as never, { channels: [{ ready: true, is_usable: true, outbound_balance_msat: 51_000_000 }] })).toBe(
324
- 'your channels can send at most 51,000 sats, and this swap needs 53,000 sats (the amount plus RLN\'s 3,000-sat HTLC minimum). Swap at most 48,000 sats, or buy a bigger channel.',
388
+ 'your channels can send at most 51,000 sats, and this swap needs 53,000 sats (the amount plus the 3,000 sats HTLC minimum). Swap at most 48,000 sats, or buy a bigger channel.',
325
389
  );
326
390
  expect(outboundShortfall(btcQuote as never, { channels: [{ ready: true, next_outbound_htlc_limit_msat: 60_000_000 }] })).toBeNull();
327
- expect(outboundShortfall(btcQuote as never, { channels: [] })).toMatch(/none can send right now/);
391
+ expect(outboundShortfall(btcQuote as never, { channels: [] })).toMatch(/no channels/);
328
392
  expect(outboundShortfall(btcQuote as never, { something: 'else' })).toBeNull();
329
393
  expect(outboundShortfall(btcQuote as never, { channels: [{ ready: false, outbound_balance_msat: 900_000_000 }] })).toMatch(/none can send right now/);
330
394
  expect(outboundShortfall(btcQuote as never, { channels: [{ channel_id: 'x' }] })).toBeNull();
@@ -367,7 +431,40 @@ describe('kaleidoswapAtomicRecipe — outbound preflight (BTC leg + RLN HTLC min
367
431
  });
368
432
  expect(res.status).toBe('error');
369
433
  expect(res.text).toMatch(/can send at most 3,000 sats.*needs 53,000 sats/);
370
- expect(calls).toEqual(['kaleidoswap_get_quote', 'rln_list_channels']);
434
+ expect(calls).toEqual(['kaleidoswap_get_quote', 'rln_get_node_info', 'rln_list_channels']);
371
435
  expect(confirms).toEqual([]);
372
436
  });
373
437
  });
438
+
439
+ describe('kaleidoswapAtomicRecipe — amounts and the deterministic path', () => {
440
+ it('converts a BTC amount said in sats to display units for the quote', async () => {
441
+ const { quoteAmount } = await import('./kaleidoswap-atomic.js');
442
+ expect(quoteAmount('BTC', 2500, 'Swap 2500 sats into USDT')).toBe(0.000025);
443
+ expect(quoteAmount('BTC', 0.001, 'swap 0.001 btc to usdt')).toBe(0.001);
444
+ expect(quoteAmount('BTC', 100000, 'swap 100000 into usdt')).toBe(0.001);
445
+ expect(quoteAmount('USDT', 10, 'swap 10 usdt for btc')).toBe(10);
446
+ });
447
+
448
+ it('runs "Swap 2500 sats into USDT" without a model, quoting 0.000025 BTC', async () => {
449
+ const quoteArgs: unknown[] = [];
450
+ const tools = new ToolRegistry([
451
+ new InProcessToolSource('k', [
452
+ {
453
+ name: 'kaleidoswap_get_quote',
454
+ description: 'q',
455
+ parameters: { type: 'object', properties: {} },
456
+ handler: async (a: Record<string, unknown>) => {
457
+ quoteArgs.push(a);
458
+ return { error: 'stop here' };
459
+ },
460
+ },
461
+ ]),
462
+ ]);
463
+ const res = await runRecipe(kaleidoswapAtomicRecipe, 'Swap 2500 sats into USDT on KaleidoSwap', {
464
+ provider: { name: 'none', async runTurn() { throw new Error('the model must not be called'); } } as LLMProvider,
465
+ tools,
466
+ });
467
+ expect(res.inferences).toBe(0);
468
+ expect(quoteArgs[0]).toMatchObject({ from_asset_id: 'BTC', to_asset_id: 'USDT', from_amount: 0.000025 });
469
+ });
470
+ });
@@ -85,16 +85,36 @@ interface QuoteResult {
85
85
  const layerFor = (asset: unknown): string =>
86
86
  /^btc$/i.test(String(asset)) ? 'BTC_LN' : 'RGB_LN';
87
87
 
88
- /** RLN adds this to the taker's BTC HTLC in a BTC→RGB swap (rgb_htlc_min_msat). */
88
+ /**
89
+ * `kaleidoswap_get_quote` takes display units (0.0005 BTC). The slots carry
90
+ * what the user said: a BTC amount said in sats ("2500 sats") is converted;
91
+ * one said in BTC ("0.001 btc") is kept. Without a unit word, below 1 reads as
92
+ * BTC and 1 or more as sats.
93
+ */
94
+ export function quoteAmount(asset: unknown, amount: unknown, text = ''): unknown {
95
+ const n = Number(amount);
96
+ if (!/^(btc|sats?|bitcoin)$/i.test(String(asset ?? '').trim()) || !Number.isFinite(n)) return amount;
97
+ const saidSats = /\b(sats?|satoshis?)\b/i.test(text);
98
+ const saidBtc = /\b(btc|bitcoins?)\b/i.test(text.replace(/\b(sats?|satoshis?)\b/gi, ''));
99
+ const inSats = saidSats || (!saidBtc && n >= 1);
100
+ return inSats ? n / 1e8 : n;
101
+ }
102
+
103
+ /** "swap|convert|exchange|trade|sell <N> <asset> for|to|into <asset>": nothing for a model to resolve. */
104
+ const EXPLICIT_SWAP = /\b(?:swap|convert|exchange|trade|sell)\s+\d[\d,]*(?:\.\d+)?\s*(?:sats?|satoshis?|btc|bitcoin|usdt|xaut)\s+(?:for|to|into)\s+(?:sats?|btc|bitcoin|usdt|xaut)\b/i;
105
+
106
+ /** RLN's minimum HTLC for RGB payments when the node doesn't report one (rgb_htlc_min_msat). */
89
107
  export const RLN_HTLC_MIN_MSAT = 3_000_000;
90
108
 
91
- function paysBtcOverLightning(q?: QuoteResult): boolean {
92
- const leg = q?.from_asset as { layer?: string; ticker?: string } | undefined;
93
- return leg?.layer === 'BTC_LN' || (!leg?.layer && String(leg?.ticker ?? '').toUpperCase() === 'BTC');
94
- }
109
+ type Leg = { asset_id?: string; ticker?: string; layer?: string; amount_raw?: number; amount_display?: string };
110
+ type Channel = Record<string, unknown>;
111
+
112
+ const isBtc = (leg?: Leg) => leg?.layer === 'BTC_LN' || (!leg?.layer && String(leg?.ticker ?? '').toUpperCase() === 'BTC');
113
+ const num = (v: unknown): number | undefined => (v == null || v === '' || !Number.isFinite(Number(v)) ? undefined : Number(v));
114
+ const fmtSats = (msat: number) => `${Math.floor(msat / 1000).toLocaleString('en-US')} sats`;
115
+ const legText = (leg?: Leg) => (leg?.amount_display ? `${leg.amount_display}${leg.ticker && !/sats?$/i.test(leg.amount_display) ? ` ${leg.ticker}` : ''}` : (leg?.ticker ?? 'the asset'));
95
116
 
96
- /** The most one usable channel can send now, in msat; undefined when the shape is unknown. */
97
- function maxOutboundMsat(channels: unknown): number | undefined {
117
+ function channelRows(channels: unknown): Channel[] | undefined {
98
118
  let list: unknown = channels;
99
119
  if (typeof list === 'string') {
100
120
  try {
@@ -104,37 +124,98 @@ function maxOutboundMsat(channels: unknown): number | undefined {
104
124
  }
105
125
  }
106
126
  const rows = Array.isArray(list) ? list : (list as { channels?: unknown })?.channels;
107
- if (!Array.isArray(rows)) return undefined;
108
- // No channels at all: nothing can send. Channels without readable amounts:
109
- // unknown, so don't block.
110
- if (!rows.length) return 0;
127
+ return Array.isArray(rows) ? (rows as Channel[]) : undefined;
128
+ }
129
+
130
+ /** Largest value of `pick` over usable channels; undefined when no channel reports it. */
131
+ function maxOver(rows: Channel[], pick: (c: Channel) => number | undefined): number | undefined {
111
132
  let best: number | undefined;
112
133
  let readable = false;
113
- for (const c of rows as Array<Record<string, unknown>>) {
114
- const msat = Number(c.next_outbound_htlc_limit_msat ?? c.outbound_balance_msat);
115
- if (!Number.isFinite(msat)) continue;
134
+ for (const c of rows) {
135
+ const v = pick(c);
136
+ if (v === undefined) continue;
116
137
  readable = true;
117
138
  if (c.is_usable === false || c.ready === false) continue;
118
- best = Math.max(best ?? 0, msat);
139
+ best = Math.max(best ?? 0, v);
119
140
  }
120
- // Readable channels, none usable: nothing can send.
121
141
  return best ?? (readable ? 0 : undefined);
122
142
  }
123
143
 
124
- const fmtSats = (msat: number) => `${Math.floor(msat / 1000).toLocaleString('en-US')} sats`;
144
+ const outbound = (c: Channel) => num(c.next_outbound_htlc_limit_msat ?? c.outbound_balance_msat);
145
+ const inbound = (c: Channel) => num(c.inbound_balance_msat);
146
+
147
+ /**
148
+ * Why the node can't carry this swap over its Lightning channels, or null when
149
+ * it can (or the channel data doesn't say). RLN sends each leg as one HTLC and
150
+ * adds its RGB HTLC minimum to the BTC side and to the asset payment:
151
+ * - BTC → asset: BTC outbound ≥ amount + minimum; an asset channel with inbound
152
+ * ≥ the asset received and BTC inbound ≥ minimum.
153
+ * - asset → BTC: an asset channel holding the asset with BTC outbound ≥
154
+ * minimum; BTC inbound ≥ amount + minimum.
155
+ */
156
+ export function swapLiquidityShortfall(
157
+ q: QuoteResult | undefined,
158
+ channels: unknown,
159
+ htlcMinMsat: number = RLN_HTLC_MIN_MSAT,
160
+ ): string | null {
161
+ const from = q?.from_asset as Leg | undefined;
162
+ const to = q?.to_asset as Leg | undefined;
163
+ const fromBtc = isBtc(from);
164
+ const toBtc = isBtc(to);
165
+ if (fromBtc === toBtc) return null;
166
+ const rows = channelRows(channels);
167
+ if (!rows) return null;
168
+ const min = fmtSats(htlcMinMsat);
169
+ if (!rows.length) return `this swap runs over Lightning and you have no channels. Buy a channel first.`;
170
+ const usable = rows.filter((c) => c.is_usable !== false && c.ready !== false);
171
+ const hasAssetData = rows.some((c) => 'asset_id' in c);
172
+
173
+ if (fromBtc) {
174
+ const amount = num(from?.amount_raw);
175
+ const maxOut = maxOver(rows, outbound);
176
+ if (amount !== undefined && maxOut !== undefined && maxOut < amount + htlcMinMsat) {
177
+ const need = amount + htlcMinMsat;
178
+ if (maxOut === 0) {
179
+ return `this swap needs a Lightning channel that can send ${fmtSats(need)} (the amount plus the ${min} HTLC minimum), and none can send right now. Buy a channel with at least that much outbound first.`;
180
+ }
181
+ if (maxOut <= htlcMinMsat) {
182
+ return `your channels can send at most ${fmtSats(maxOut)}, which only covers the ${min} HTLC minimum, so no BTC swap fits. This one needs ${fmtSats(need)}: buy a channel with more outbound.`;
183
+ }
184
+ return `your channels can send at most ${fmtSats(maxOut)}, and this swap needs ${fmtSats(need)} (the amount plus the ${min} HTLC minimum). Swap at most ${fmtSats(Math.max(0, maxOut - htlcMinMsat))}, or buy a bigger channel.`;
185
+ }
186
+ const want = num(to?.amount_raw);
187
+ if (hasAssetData && want !== undefined) {
188
+ const ok = usable.some(
189
+ (c) => c.asset_id === to?.asset_id && (num(c.asset_remote_amount) ?? 0) >= want && (inbound(c) ?? Infinity) >= htlcMinMsat,
190
+ );
191
+ if (!ok) {
192
+ return `no channel can receive ${legText(to)}: you need a ${to?.ticker ?? 'asset'} channel with at least that much inbound and ${min} of BTC inbound. Buy an asset channel from the LSP first.`;
193
+ }
194
+ }
195
+ return null;
196
+ }
125
197
 
126
- /** Why the node can't send this swap's BTC leg, or null when it can (or can't tell). */
127
- export function outboundShortfall(q: QuoteResult | undefined, channels: unknown): string | null {
128
- const amount = Number(q?.from_asset?.amount_raw);
129
- const max = maxOutboundMsat(channels);
130
- if (!Number.isFinite(amount) || max === undefined) return null;
131
- const need = amount + RLN_HTLC_MIN_MSAT;
132
- if (max >= need) return null;
133
- return max === 0
134
- ? `this swap needs a Lightning channel that can send ${fmtSats(need)} (the amount plus RLN's 3,000-sat HTLC minimum), and none can send right now. Buy a channel with at least that much outbound first.`
135
- : `your channels can send at most ${fmtSats(max)}, and this swap needs ${fmtSats(need)} (the amount plus RLN's 3,000-sat HTLC minimum). Swap at most ${fmtSats(Math.max(0, max - RLN_HTLC_MIN_MSAT))}, or buy a bigger channel.`;
198
+ // asset → BTC
199
+ const have = num(from?.amount_raw);
200
+ if (hasAssetData && have !== undefined) {
201
+ const ok = usable.some(
202
+ (c) => c.asset_id === from?.asset_id && (num(c.asset_local_amount) ?? 0) >= have && (outbound(c) ?? Infinity) >= htlcMinMsat,
203
+ );
204
+ if (!ok) {
205
+ return `no channel can send ${legText(from)}: you need a ${from?.ticker ?? 'asset'} channel holding at least that much, with ${min} of BTC outbound.`;
206
+ }
207
+ }
208
+ const amount = num(to?.amount_raw);
209
+ const maxIn = maxOver(rows, inbound);
210
+ if (amount !== undefined && maxIn !== undefined && maxIn < amount + htlcMinMsat) {
211
+ return `your channels can receive at most ${fmtSats(maxIn)}, and this swap pays you ${fmtSats(amount + htlcMinMsat)} (the amount plus the ${min} HTLC minimum). Swap for less BTC, or get more inbound.`;
212
+ }
213
+ return null;
136
214
  }
137
215
 
216
+ /** @deprecated Use `swapLiquidityShortfall`. */
217
+ export const outboundShortfall = swapLiquidityShortfall;
218
+
138
219
  // Render a quote leg as "<amount> <TICKER>" from the MCP quote echo, or undefined
139
220
  // if the leg is missing (callers fall back to the user's slot values).
140
221
  const quoteLeg = (leg?: { ticker?: string; amount_display?: string }): string | undefined =>
@@ -160,6 +241,9 @@ export const kaleidoswapAtomicRecipe: Recipe = {
160
241
  // always ask the model to produce the actual slots used for execution.
161
242
  extract: extractSwap,
162
243
  forceModelExtract: true,
244
+ // An explicit amount + both assets needs no model, which small models
245
+ // otherwise misread ("2500 sats into USDT" as 2,500 USDT).
246
+ trustExtract: (text) => EXPLICIT_SWAP.test(text),
163
247
  confident: (s) => !!s.from_asset && !!s.to_asset && !!s.amount,
164
248
  steps: [
165
249
  // 1. MAKER quotes the swap (read-only). Returns rfq_id + full asset specs
@@ -178,22 +262,31 @@ export const kaleidoswapAtomicRecipe: Recipe = {
178
262
  from_layer: layerFor(ctx.slots.from_asset),
179
263
  to_layer: layerFor(ctx.slots.to_asset),
180
264
  };
181
- return side === 'to'
182
- ? { ...base, to_amount: ctx.slots.amount }
183
- : { ...base, from_amount: ctx.slots.amount };
265
+ const asset = side === 'to' ? ctx.slots.to_asset : ctx.slots.from_asset;
266
+ const amount = quoteAmount(asset, ctx.slots.amount, ctx.text);
267
+ return side === 'to' ? { ...base, to_amount: amount } : { ...base, from_amount: amount };
184
268
  },
185
269
  },
186
- // 1b. NODE: the BTC leg goes out as one Lightning HTLC of the amount plus
187
- // RLN's 3,000-sat HTLC minimum, so a channel must be able to send that
188
- // much. Stop here, before the confirmation, instead of failing with
189
- // NoRoute after the maker has locked the swap.
270
+ // 1b. NODE: pubkey for execute, and the node's RGB HTLC minimum.
271
+ {
272
+ tool: 'rln_get_node_info',
273
+ as: 'node',
274
+ args: () => ({}),
275
+ },
276
+ // 1c. NODE: can the channels carry both legs? RLN sends each leg as one
277
+ // HTLC and adds its HTLC minimum. Stop here, before the confirmation,
278
+ // instead of failing with NoRoute after the maker has locked the swap.
190
279
  {
191
280
  tool: 'rln_list_channels',
192
281
  as: 'channels',
193
282
  optional: true,
194
283
  args: () => ({}),
195
- skipIf: (ctx) => !paysBtcOverLightning(ctx.results.quote as QuoteResult | undefined),
196
- check: (ctx) => outboundShortfall(ctx.results.quote as QuoteResult | undefined, ctx.results.channels),
284
+ check: (ctx) =>
285
+ swapLiquidityShortfall(
286
+ ctx.results.quote as QuoteResult | undefined,
287
+ ctx.results.channels,
288
+ num((ctx.results.node as { rgb_htlc_min_msat?: unknown } | undefined)?.rgb_htlc_min_msat) ?? RLN_HTLC_MIN_MSAT,
289
+ ),
197
290
  },
198
291
  // 2. MAKER locks the swap. SwapRequest is flat (asset ids + maker-unit
199
292
  // amounts) — sourced straight from the quote result, no re-scaling.
@@ -212,12 +305,6 @@ export const kaleidoswapAtomicRecipe: Recipe = {
212
305
  };
213
306
  },
214
307
  },
215
- // 3. NODE: read our pubkey — the maker needs it as taker_pubkey for execute.
216
- {
217
- tool: 'rln_get_node_info',
218
- as: 'node',
219
- args: () => ({}),
220
- },
221
308
  // 4. NODE: the taker whitelists the maker's swapstring (accept the swap).
222
309
  // Exposed by kaleido-mcp as `rln_atomic_taker` (calls rln.whitelistSwap).
223
310
  // Ungated — covered by the single confirm above.
@@ -76,7 +76,7 @@ export async function extractSlots(
76
76
  const det = recipe.extract?.(text);
77
77
  const detValid = det && Object.values(det).some((v) => v !== undefined && v !== null && v !== '');
78
78
 
79
- if (detValid && !recipe.forceModelExtract) {
79
+ if (detValid && (!recipe.forceModelExtract || recipe.trustExtract?.(text))) {
80
80
  return { slots: det, inferences: 0 };
81
81
  }
82
82
 
@@ -62,6 +62,11 @@ export interface Recipe {
62
62
  * reliable multi-step execution plan and single-confirmation safety.
63
63
  */
64
64
  forceModelExtract?: boolean;
65
+ /**
66
+ * With `forceModelExtract`, use the deterministic extraction anyway when this
67
+ * returns true for the request (an unambiguous phrasing).
68
+ */
69
+ trustExtract?: (text: string) => boolean;
65
70
  /**
66
71
  * Whether the recipe is confident enough to RUN deterministically given the
67
72
  * extracted slots (vs falling back to the agentic loop). e.g. payments needs a
@@ -60,8 +60,8 @@ describe('confirmReadback', () => {
60
60
  .toBe('Open a 100,000 sats channel to 03abcd…6789. Confirm?');
61
61
  expect(confirmReadback({ name: 'rln_close_channel', arguments: { channel_id: 'chan0123456789abcdef0123', peer_pubkey: 'x', force: true } }))
62
62
  .toBe('Force-close channel chan01…0123. Confirm?');
63
- expect(confirmReadback({ name: 'rln_atomic_taker', arguments: { swapstring: '30/rgb:asset/10/btc/3600/abcdef0123456789' } }))
64
- .toBe('Accept atomic swap 30/rgb…6789 on your node. Confirm?');
63
+ expect(confirmReadback({ name: 'rln_atomic_taker', arguments: { swapstring: '30/rgb:asset/10000/btc/3600/abcdef0123456789' } }))
64
+ .toBe('Accept atomic swap 30 units of rgb:asset ⇄ 10 sats (30/rgb…6789). Confirm?');
65
65
  });
66
66
 
67
67
  it('execute_swap: from → to with amount', () => {
@@ -90,3 +90,62 @@ describe('confirmReadback: LSP orders', () => {
90
90
  ).toBe('Order a channel from the LSP with 10 USDT inbound. The order total is paid in a separate step. Confirm?');
91
91
  });
92
92
  });
93
+
94
+ describe('confirmReadback: accepting an atomic swap', () => {
95
+ const swapstring = '2500000/btc/1500000/rgb:usdt-abcdefgh/1791400000/ph1';
96
+ const quote = {
97
+ name: 'kaleidoswap_get_quote',
98
+ result: { from_asset: { ticker: 'BTC', amount_display: '2,500 sats' }, to_asset: { ticker: 'USDT', amount_display: '1.5' } },
99
+ };
100
+
101
+ it("shows the run's quote amounts", () => {
102
+ const init = { name: 'kaleidoswap_atomic_init', result: { swapstring, payment_hash: 'ph1' } };
103
+ expect(confirmReadback({ name: 'rln_atomic_taker', arguments: { swapstring } }, { results: [quote, init] })).toBe(
104
+ 'Accept the atomic swap: you send 2,500 sats, you receive 1.5 USDT. Confirm?',
105
+ );
106
+ });
107
+
108
+ it('flags a swapstring that is not the one just created', () => {
109
+ const init = { name: 'kaleidoswap_atomic_init', result: { swapstring: 'other/btc/1/btc/1/x' } };
110
+ expect(confirmReadback({ name: 'rln_atomic_taker', arguments: { swapstring } }, { results: [quote, init] })).toMatch(
111
+ /\(this is not the swap just created\)\. Confirm\?$/,
112
+ );
113
+ });
114
+
115
+ it('decodes the swapstring when there is no quote', () => {
116
+ expect(confirmReadback({ name: 'wdk_atomic_taker', arguments: { swapstring } })).toMatch(
117
+ /^Accept atomic swap 2,500 sats ⇄ 1,500,000 units of rgb:/,
118
+ );
119
+ });
120
+ });
121
+
122
+ describe('confirmReadback: starting a KaleidoSwap swap', () => {
123
+ const quote = {
124
+ name: 'kaleidoswap_get_quote',
125
+ result: {
126
+ rfq_id: 'rfq-9',
127
+ from_asset: { ticker: 'BTC', amount_display: '2,500 sats', amount_raw: 2_500_000 },
128
+ to_asset: { ticker: 'USDT', amount_display: '2.020975', amount_raw: 2_020_975 },
129
+ expires_at: Math.floor(Date.now() / 1000) + 45,
130
+ },
131
+ };
132
+ const args = { rfq_id: 'rfq-9', from_asset_id: 'BTC', from_amount_raw: 2_500_000, to_asset_id: 'rgb:usdt', to_amount_raw: 2_020_975 };
133
+
134
+ it('reads the quote amounts and expiry', () => {
135
+ expect(confirmReadback({ name: 'kaleidoswap_atomic_init', arguments: args }, { results: [quote] })).toMatch(
136
+ /^Swap 2,500 sats for 2\.020975 USDT on KaleidoSwap \(quote expires in 4[0-9]s\)\. Confirm\?$/,
137
+ );
138
+ expect(confirmReadback({ name: 'kaleidoswap_atomic_execute', arguments: {} }, { results: [quote] })).toBe(
139
+ 'Settle the KaleidoSwap swap: you send 2,500 sats, you receive 2.020975 USDT. Confirm?',
140
+ );
141
+ });
142
+
143
+ it('flags amounts that differ from the quote, and reads raw amounts without one', () => {
144
+ expect(confirmReadback({ name: 'kaleidoswap_atomic_init', arguments: { ...args, to_amount_raw: 1 } }, { results: [quote] })).toMatch(
145
+ /\(the amounts sent differ from the quote\)\. Confirm\?$/,
146
+ );
147
+ expect(confirmReadback({ name: 'kaleidoswap_atomic_init', arguments: args })).toBe(
148
+ 'Swap 2,500 sats for 2,020,975 raw units of rgb:usdt on KaleidoSwap (no quote in this conversation). Confirm?',
149
+ );
150
+ });
151
+ });
@@ -63,7 +63,86 @@ const asset = (amount: unknown, ticker: unknown) => {
63
63
  };
64
64
 
65
65
  /** A spoken confirmation ending in "Confirm?", or null for non-spend tools. */
66
- export function confirmReadback(call: { name: string; arguments: Record<string, unknown> }): string | null {
66
+ /** What earlier calls in the same run returned, for readbacks that depend on them. */
67
+ export interface ReadbackContext {
68
+ results?: Array<{ name: string; result: unknown }>;
69
+ }
70
+
71
+ type QuoteLeg = { ticker?: string; amount_display?: string };
72
+
73
+ const legText = (leg?: QuoteLeg) =>
74
+ leg?.amount_display ? `${leg.amount_display}${leg.ticker && !/sats?$/i.test(leg.amount_display) ? ` ${leg.ticker}` : ''}` : undefined;
75
+
76
+ const lastResult = (ctx: ReadbackContext | undefined, re: RegExp): Record<string, any> | undefined => {
77
+ const hit = [...(ctx?.results ?? [])].reverse().find((r) => re.test(r.name));
78
+ let r = hit?.result;
79
+ if (typeof r === 'string') {
80
+ try {
81
+ r = JSON.parse(r);
82
+ } catch {
83
+ return undefined;
84
+ }
85
+ }
86
+ return r && typeof r === 'object' ? (r as Record<string, any>) : undefined;
87
+ };
88
+
89
+ /** RLN swapstring: qty_from/from_asset/qty_to/to_asset/expiry/payment_hash (BTC in msat). */
90
+ function swapstringLegs(swapstring: string): string | undefined {
91
+ const [qtyFrom, from, qtyTo, to] = swapstring.split('/');
92
+ if (!qtyFrom || !from || !qtyTo || !to || !/^\d+$/.test(qtyFrom) || !/^\d+$/.test(qtyTo)) return undefined;
93
+ const leg = (qty: string, asset: string) =>
94
+ asset === 'btc' ? sats(Math.floor(Number(qty) / 1000)) : `${Number(qty).toLocaleString('en-US')} units of ${shortRef(asset)}`;
95
+ return `${leg(qtyFrom, from)} ⇄ ${leg(qtyTo, to)}`;
96
+ }
97
+
98
+ function expiresIn(expiresAt: unknown): string {
99
+ const t = Number(expiresAt);
100
+ if (!Number.isFinite(t) || t <= 0) return '';
101
+ const secs = Math.round((t > 1e12 ? t : t * 1000) / 1000 - Date.now() / 1000);
102
+ return secs > 0 ? ` (quote expires in ${secs}s)` : ' (the quote has expired)';
103
+ }
104
+
105
+ /** One leg from init args alone: BTC raw is msat; an asset's raw units without its precision. */
106
+ function rawLeg(assetId: unknown, raw: unknown): string {
107
+ const n = Number(raw);
108
+ if (!Number.isFinite(n)) return String(assetId ?? '?');
109
+ return /^(btc|sats?)$/i.test(String(assetId ?? '')) ? sats(Math.floor(n / 1000)) : `${n.toLocaleString('en-US')} raw units of ${shortRef(String(assetId ?? '?'))}`;
110
+ }
111
+
112
+ function atomicInitReadback(a: Record<string, unknown>, ctx?: ReadbackContext): string {
113
+ const quote = lastResult(ctx, /(^|_)get_quote$/);
114
+ const from = legText(quote?.from_asset);
115
+ const to = legText(quote?.to_asset);
116
+ if (quote && from && to && (!a.rfq_id || !quote.rfq_id || a.rfq_id === quote.rfq_id)) {
117
+ const differs =
118
+ (a.from_amount_raw != null && quote.from_asset?.amount_raw != null && Number(a.from_amount_raw) !== Number(quote.from_asset.amount_raw)) ||
119
+ (a.to_amount_raw != null && quote.to_asset?.amount_raw != null && Number(a.to_amount_raw) !== Number(quote.to_asset.amount_raw));
120
+ return `Swap ${from} for ${to} on KaleidoSwap${expiresIn(quote.expires_at)}${differs ? ' (the amounts sent differ from the quote)' : ''}`;
121
+ }
122
+ return `Swap ${rawLeg(a.from_asset_id, a.from_amount_raw)} for ${rawLeg(a.to_asset_id, a.to_amount_raw)} on KaleidoSwap (no quote in this conversation)`;
123
+ }
124
+
125
+ function atomicExecuteReadback(ctx?: ReadbackContext): string {
126
+ const quote = lastResult(ctx, /(^|_)get_quote$/);
127
+ const from = legText(quote?.from_asset);
128
+ const to = legText(quote?.to_asset);
129
+ return from && to ? `Settle the KaleidoSwap swap: you send ${from}, you receive ${to}` : 'Settle the KaleidoSwap atomic swap';
130
+ }
131
+
132
+ function atomicTakerReadback(swapstring: string, ctx?: ReadbackContext): string {
133
+ const quote = lastResult(ctx, /(^|_)get_quote$/);
134
+ const init = lastResult(ctx, /(^|_)atomic_init$/);
135
+ const from = legText(quote?.from_asset);
136
+ const to = legText(quote?.to_asset);
137
+ if (from && to) {
138
+ const other = init?.swapstring && init.swapstring !== swapstring ? ' (this is not the swap just created)' : '';
139
+ return `Accept the atomic swap: you send ${from}, you receive ${to}${other}`;
140
+ }
141
+ const legs = swapstringLegs(swapstring);
142
+ return legs ? `Accept atomic swap ${legs} (${shortRef(swapstring)})` : `Accept atomic swap ${shortRef(swapstring)}`;
143
+ }
144
+
145
+ export function confirmReadback(call: { name: string; arguments: Record<string, unknown> }, context?: ReadbackContext): string | null {
67
146
  const { name, arguments: a } = call;
68
147
  const to = (k = 'to') => shortRef(String(a[k] ?? ''));
69
148
  const ask = (s: string) => `${s}. Confirm?`;
@@ -102,8 +181,13 @@ export function confirmReadback(call: { name: string; arguments: Record<string,
102
181
  }
103
182
  case 'rln_close_channel':
104
183
  return ask(`${a.force ? 'Force-close' : 'Close'} channel ${shortRef(String(a.channel_id ?? ''))}`);
184
+ case 'kaleidoswap_atomic_init':
185
+ return ask(atomicInitReadback(a, context));
186
+ case 'kaleidoswap_atomic_execute':
187
+ return ask(atomicExecuteReadback(context));
105
188
  case 'rln_atomic_taker':
106
- return ask(`Accept atomic swap ${shortRef(String(a.swapstring ?? ''))} on your node`);
189
+ case 'wdk_atomic_taker':
190
+ return ask(atomicTakerReadback(String(a.swapstring ?? ''), context));
107
191
  case 'execute_swap':
108
192
  return ask(`Swap ${asset(a.amount, a.from_asset)} for ${label(a.to_asset)}`);
109
193
  // The order total is only known once the LSP creates the order; the