@kaleidorg/mind 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/dist/bitrefill/contract.d.ts +60 -0
  2. package/dist/bitrefill/contract.d.ts.map +1 -0
  3. package/dist/bitrefill/contract.js +119 -0
  4. package/dist/bitrefill/contract.js.map +1 -0
  5. package/dist/context/compress.d.ts +65 -0
  6. package/dist/context/compress.d.ts.map +1 -0
  7. package/dist/context/compress.js +181 -0
  8. package/dist/context/compress.js.map +1 -0
  9. package/dist/engine.d.ts +20 -0
  10. package/dist/engine.d.ts.map +1 -1
  11. package/dist/engine.js +23 -4
  12. package/dist/engine.js.map +1 -1
  13. package/dist/evidence.d.ts +62 -0
  14. package/dist/evidence.d.ts.map +1 -0
  15. package/dist/evidence.js +47 -0
  16. package/dist/evidence.js.map +1 -0
  17. package/dist/flashnet/contract.d.ts +56 -0
  18. package/dist/flashnet/contract.d.ts.map +1 -0
  19. package/dist/flashnet/contract.js +100 -0
  20. package/dist/flashnet/contract.js.map +1 -0
  21. package/dist/funnel.d.ts +11 -0
  22. package/dist/funnel.d.ts.map +1 -1
  23. package/dist/funnel.js +50 -7
  24. package/dist/funnel.js.map +1 -1
  25. package/dist/index.d.ts +10 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +7 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/kaleidoswap/contract.js +1 -1
  30. package/dist/kaleidoswap/contract.js.map +1 -1
  31. package/dist/knowledge/bitcoin-copilot.d.ts.map +1 -1
  32. package/dist/knowledge/bitcoin-copilot.js +83 -0
  33. package/dist/knowledge/bitcoin-copilot.js.map +1 -1
  34. package/dist/providers/types.d.ts +17 -0
  35. package/dist/providers/types.d.ts.map +1 -1
  36. package/dist/qvac/provider.d.ts.map +1 -1
  37. package/dist/qvac/provider.js +23 -0
  38. package/dist/qvac/provider.js.map +1 -1
  39. package/dist/qvac/stream.d.ts +6 -0
  40. package/dist/qvac/stream.d.ts.map +1 -1
  41. package/dist/qvac/stream.js +12 -0
  42. package/dist/qvac/stream.js.map +1 -1
  43. package/dist/recipe/flashnet-swap.d.ts +35 -0
  44. package/dist/recipe/flashnet-swap.d.ts.map +1 -0
  45. package/dist/recipe/flashnet-swap.js +239 -0
  46. package/dist/recipe/flashnet-swap.js.map +1 -0
  47. package/dist/recipe/kaleidoswap-atomic.d.ts.map +1 -1
  48. package/dist/recipe/kaleidoswap-atomic.js +66 -32
  49. package/dist/recipe/kaleidoswap-atomic.js.map +1 -1
  50. package/dist/recipe/kaleidoswap-channel-order.d.ts.map +1 -1
  51. package/dist/recipe/kaleidoswap-channel-order.js +31 -10
  52. package/dist/recipe/kaleidoswap-channel-order.js.map +1 -1
  53. package/dist/recipe/kaleidoswap-price.d.ts.map +1 -1
  54. package/dist/recipe/kaleidoswap-price.js +7 -1
  55. package/dist/recipe/kaleidoswap-price.js.map +1 -1
  56. package/dist/recipe/runner.d.ts.map +1 -1
  57. package/dist/recipe/runner.js +5 -3
  58. package/dist/recipe/runner.js.map +1 -1
  59. package/dist/recipe/swap.d.ts.map +1 -1
  60. package/dist/recipe/swap.js +14 -1
  61. package/dist/recipe/swap.js.map +1 -1
  62. package/dist/wallet/confirm.d.ts.map +1 -1
  63. package/dist/wallet/confirm.js +1 -0
  64. package/dist/wallet/confirm.js.map +1 -1
  65. package/dist/wallet/contract.d.ts.map +1 -1
  66. package/dist/wallet/contract.js +20 -4
  67. package/dist/wallet/contract.js.map +1 -1
  68. package/package.json +4 -4
  69. package/skills/bitrefill/SKILL.md +152 -52
  70. package/skills/flashnet-swaps/SKILL.md +158 -0
  71. package/skills/kaleido-lsps/SKILL.md +25 -8
  72. package/skills/kaleido-trading/SKILL.md +36 -12
  73. package/skills/merchant-finder/SKILL.md +1 -1
  74. package/skills/rgb-lightning-node/SKILL.md +35 -8
  75. package/skills/spark-wallet/SKILL.md +235 -0
  76. package/skills/wallet-assistant/SKILL.md +2 -2
  77. package/src/bitrefill/contract.test.ts +89 -0
  78. package/src/bitrefill/contract.ts +190 -0
  79. package/src/context/compress.test.ts +120 -0
  80. package/src/context/compress.ts +230 -0
  81. package/src/engine.test.ts +34 -0
  82. package/src/engine.ts +35 -4
  83. package/src/evidence.test.ts +80 -0
  84. package/src/evidence.ts +114 -0
  85. package/src/flashnet/contract.test.ts +101 -0
  86. package/src/flashnet/contract.ts +164 -0
  87. package/src/funnel.mind.test.ts +3 -5
  88. package/src/funnel.ts +59 -8
  89. package/src/index.ts +51 -1
  90. package/src/kaleidoswap/contract.ts +1 -1
  91. package/src/knowledge/bitcoin-copilot.ts +94 -0
  92. package/src/providers/types.ts +18 -0
  93. package/src/qvac/provider.ts +25 -1
  94. package/src/qvac/stream.test.ts +11 -0
  95. package/src/qvac/stream.ts +16 -0
  96. package/src/recipe/flashnet-swap.test.ts +114 -0
  97. package/src/recipe/flashnet-swap.ts +266 -0
  98. package/src/recipe/kaleidoswap-atomic.test.ts +52 -6
  99. package/src/recipe/kaleidoswap-atomic.ts +71 -34
  100. package/src/recipe/kaleidoswap-channel-order.test.ts +38 -0
  101. package/src/recipe/kaleidoswap-channel-order.ts +27 -9
  102. package/src/recipe/kaleidoswap-price.ts +7 -1
  103. package/src/recipe/recipe.test.ts +5 -0
  104. package/src/recipe/runner.ts +5 -3
  105. package/src/recipe/swap.ts +16 -1
  106. package/src/wallet/confirm.test.ts +8 -0
  107. package/src/wallet/confirm.ts +1 -0
  108. package/src/wallet/contract.test.ts +10 -0
  109. package/src/wallet/contract.ts +26 -4
@@ -34,35 +34,62 @@
34
34
  import type { Recipe, RecipeContext } from './types.js';
35
35
  import { extractSwap } from './swap.js';
36
36
 
37
- // Fire on swap intent "swap/exchange/convert/trade", or "buy/sell/get" when a
38
- // crypto asset is named (so "buy one usdt" routes here, but "buy a gift card"
39
- // does not). PRICE / rate / "how much" questions are read-only and go to
40
- // `kaleidoswapPriceRecipe` instead keep them out of this match.
41
- const ASSET = /\b(btc|bitcoin|sats?|usdt|tether|xaut|gold)\b/i;
37
+ // KaleidoSwap is a BTC↔RGB ATOMIC swap venue (maker + RLN node). It is NOT the
38
+ // only swap venue anymore Flashnet (Spark-native AMM, BTC↔Spark tokens like
39
+ // USDB) is a sibling, handled by the agentic `flashnet-swaps` skill. The Funnel
40
+ // runs recipes BEFORE skills, so a greedy "any swap word" match here would
41
+ // monopolize every swap and starve Flashnet. To let both coexist, this recipe
42
+ // only claims swaps that point at ITS venue:
43
+ // - names an RGB/maker asset or the venue itself (RGB_CUE), AND
44
+ // - does NOT name a Flashnet/Spark cue (FLASHNET_CUE → defer to the skill).
45
+ // A bare "swap" with no venue cue falls through to the agentic tier, where the
46
+ // skill selector disambiguates (or the model asks).
47
+ const RGB_CUE = /\b(usdt|tether|xaut|gold|rgb|kaleidoswap|kaleido|atomic)\b/i;
48
+ const FLASHNET_CUE = /\b(flashnet|usdb|spark)\b/i;
42
49
  const SWAP_INTENT = (t: string) => {
43
50
  // Explanatory / educational questions → route to RAG-backed agentic answer,
44
51
  // not the deterministic spend chain.
45
52
  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;
46
- if (/\b(swap|exchange|convert|trade)\b/i.test(t)) return true;
47
- if (
53
+ // Flashnet owns its venue — defer to the flashnet-swaps skill.
54
+ if (FLASHNET_CUE.test(t)) return false;
55
+ // Portfolio analysis belongs to the portfolio skill, which reads balances
56
+ // and targets before suggesting any action. A mention of "trade" in a review
57
+ // request—especially "do not trade"—must never become an immediate swap.
58
+ if (/\b(portfolio|allocation|holdings|rebalance|rebalancing)\b/i.test(t)) return false;
59
+ if (/\b(?:do\s+not|don't|without|no)\s+(?:place\s+(?:a\s+)?)?(?:trade|swap|buy|sell|trading)\b/i.test(t)) return false;
60
+ const swapVerb = /\b(swap|exchange|convert|trade)\b/i.test(t);
61
+ const buyVerb =
48
62
  /\b(buy|sell|get|purchase|acquire)\b/i.test(t) &&
49
- ASSET.test(t) &&
50
63
  // Exclude commerce / receive / LSPS1 channel-order phrasings that share
51
64
  // the buy/get verb. "Buy a USDT channel" is a channel order, not a swap.
52
- !/\b(gift\s?card|top-?up|esim|voucher|invoice|address|channel|inbound|liquidity|lsps?\b)\b/i.test(t)
53
- ) return true;
65
+ !/\b(gift\s?card|top-?up|esim|voucher|invoice|address|channel|inbound|liquidity|lsps?\b)\b/i.test(t);
66
+ // Only claim the swap when an RGB/maker asset (or the venue) is named, so a
67
+ // bare/ambiguous "swap" or a Flashnet-asset swap doesn't get grabbed here.
68
+ if (swapVerb || buyVerb) return RGB_CUE.test(t);
54
69
  return false;
55
70
  };
56
71
 
57
72
  interface QuoteResult {
58
73
  rfq_id?: string;
59
- from_asset?: { asset_id?: string; ticker?: string; amount?: number };
60
- to_asset?: { asset_id?: string; ticker?: string; amount?: number };
74
+ // kaleido-mcp `kaleidoswap_get_quote` echoes each leg with the resolved
75
+ // asset_id, ticker, layer, the raw integer amount (amount_raw) and a display string.
76
+ from_asset?: { asset_id?: string; ticker?: string; amount_raw?: number; amount_display?: string };
77
+ to_asset?: { asset_id?: string; ticker?: string; amount_raw?: number; amount_display?: string };
61
78
  from_amount_display?: string;
62
79
  to_amount_display?: string;
63
80
  fee_display?: string;
64
81
  }
65
- interface InitResult { swapstring?: string; payment_hash?: string }
82
+
83
+ // KaleidoSwap atomic is a BTC ↔ RGB venue: BTC settles on Lightning, RGB
84
+ // assets on RGB-over-Lightning. The maker/MCP layer is derived from the asset.
85
+ const layerFor = (asset: unknown): string =>
86
+ /^btc$/i.test(String(asset)) ? 'BTC_LN' : 'RGB_LN';
87
+
88
+ // Render a quote leg as "<amount> <TICKER>" from the MCP quote echo, or undefined
89
+ // if the leg is missing (callers fall back to the user's slot values).
90
+ const quoteLeg = (leg?: { ticker?: string; amount_display?: string }): string | undefined =>
91
+ leg?.amount_display != null ? `${leg.amount_display}${leg.ticker ? ` ${leg.ticker}` : ''}` : undefined;
92
+ interface InitResult { swapstring?: string; payment_hash?: string; atomic_id?: string }
66
93
  interface NodeInfo { pubkey?: string }
67
94
 
68
95
  export const kaleidoswapAtomicRecipe: Recipe = {
@@ -72,10 +99,10 @@ export const kaleidoswapAtomicRecipe: Recipe = {
72
99
  match: (t) => SWAP_INTENT(t),
73
100
  triggers: ['swap', 'exchange', 'convert', 'trade', 'buy', 'sell'],
74
101
  slots: [
75
- { name: 'from_asset', type: 'string', description: 'Asset to spend (BTC / USDT / XAUT)', required: true },
76
- { name: 'to_asset', type: 'string', description: 'Asset to receive (BTC / USDT / XAUT)', required: true },
77
- { name: 'amount', type: 'number', description: 'The amount the user named' },
78
- { name: 'amount_side', type: 'string', description: "Which leg the amount is on: 'from' (sell/swap) or 'to' (buy)" },
102
+ { name: 'from_asset', type: 'string', description: 'Asset to spend (BTC / USDT / XAUT). Example: "swap 10 usdt to btc" → from_asset=USDT', required: true },
103
+ { name: 'to_asset', type: 'string', description: 'Asset to receive (BTC / USDT / XAUT). Example: "buy 1 usdt" → to_asset=USDT', required: true },
104
+ { name: 'amount', type: 'number', description: 'The amount the user named (in from_asset units for sell, to_asset for buy). E.g. "buy 1 usdt" amount=1; "swap 100000 sats" amount=100000' },
105
+ { name: 'amount_side', type: 'string', description: "Which leg the amount is on: 'from' (sell/swap) or 'to' (buy). Use examples in descriptions and 'buy X Y' means to_asset." },
79
106
  ],
80
107
  // Keep the fast `extract` for the Funnel's cheap pre-filter (so "buy 1 usdt"
81
108
  // reliably enters the recipe branch instead of falling to free agentic).
@@ -90,14 +117,21 @@ export const kaleidoswapAtomicRecipe: Recipe = {
90
117
  {
91
118
  tool: 'kaleidoswap_get_quote',
92
119
  as: 'quote',
93
- args: (ctx) => ({
94
- from_asset: ctx.slots.from_asset,
95
- to_asset: ctx.slots.to_asset,
96
- amount: ctx.slots.amount,
97
- // 'to' for buy ("buy 1 USDT" → amount is what you RECEIVE); default
98
- // 'from' for sell/swap. The host puts the amount on the right leg.
99
- amount_side: ctx.slots.amount_side ?? 'from',
100
- }),
120
+ args: (ctx) => {
121
+ // 'to' for buy ("buy 1 USDT" → amount is what you RECEIVE) goes on the
122
+ // to_amount leg; 'from' (sell/swap) goes on from_amount. Layers are
123
+ // derived from the asset. Exactly one amount leg is set.
124
+ const side = ctx.slots.amount_side ?? 'from';
125
+ const base = {
126
+ from_asset_id: ctx.slots.from_asset,
127
+ to_asset_id: ctx.slots.to_asset,
128
+ from_layer: layerFor(ctx.slots.from_asset),
129
+ to_layer: layerFor(ctx.slots.to_asset),
130
+ };
131
+ return side === 'to'
132
+ ? { ...base, to_amount: ctx.slots.amount }
133
+ : { ...base, from_amount: ctx.slots.amount };
134
+ },
101
135
  },
102
136
  // 2. MAKER locks the swap. SwapRequest is flat (asset ids + maker-unit
103
137
  // amounts) — sourced straight from the quote result, no re-scaling.
@@ -109,10 +143,10 @@ export const kaleidoswapAtomicRecipe: Recipe = {
109
143
  const q = ctx.results.quote as QuoteResult | undefined;
110
144
  return {
111
145
  rfq_id: q?.rfq_id,
112
- from_asset: q?.from_asset?.asset_id,
113
- from_amount: q?.from_asset?.amount,
114
- to_asset: q?.to_asset?.asset_id,
115
- to_amount: q?.to_asset?.amount,
146
+ from_asset_id: q?.from_asset?.asset_id,
147
+ from_amount_raw: q?.from_asset?.amount_raw,
148
+ to_asset_id: q?.to_asset?.asset_id,
149
+ to_amount_raw: q?.to_asset?.amount_raw,
116
150
  };
117
151
  },
118
152
  },
@@ -150,15 +184,18 @@ export const kaleidoswapAtomicRecipe: Recipe = {
150
184
  // ONE confirmation, fired after the quote / before init, with the real numbers.
151
185
  confirm: (ctx: RecipeContext) => {
152
186
  const q = ctx.results.quote as QuoteResult | undefined;
153
- const from = q?.from_amount_display ?? `${ctx.slots.amount} ${ctx.slots.from_asset}`;
154
- const to = q?.to_amount_display ?? String(ctx.slots.to_asset);
187
+ const from = quoteLeg(q?.from_asset) ?? `${ctx.slots.amount} ${ctx.slots.from_asset}`;
188
+ const to = quoteLeg(q?.to_asset) ?? String(ctx.slots.to_asset);
155
189
  const fee = q?.fee_display ? ` · fee ${q.fee_display}` : '';
156
190
  return `Swap ${from} → ${to}${fee} on KaleidoSwap. Proceed?`;
157
191
  },
158
192
  summary: (ctx) => {
159
193
  const q = ctx.results.quote as QuoteResult | undefined;
160
- const from = q?.from_amount_display ?? `${ctx.slots.amount} ${ctx.slots.from_asset}`;
161
- const to = q?.to_amount_display ?? String(ctx.slots.to_asset);
162
- return `Swap submitted: ${from} → ${to}. Settling now ask me to check the status.`;
194
+ const from = quoteLeg(q?.from_asset) ?? `${ctx.slots.amount} ${ctx.slots.from_asset}`;
195
+ const to = quoteLeg(q?.to_asset) ?? String(ctx.slots.to_asset);
196
+ const init = ctx.results.init as InitResult | undefined;
197
+ const id = init?.atomic_id || init?.payment_hash || '?';
198
+ return `remember: atomic swap atomic_id=${id} (for later kaleidoswap_atomic_status checks).
199
+ Swap submitted: ${from} → ${to}. To check status later, call: kaleidoswap_atomic_status(atomic_id=${id}). Say "check my swap status" and I will recall + poll automatically.`;
163
200
  },
164
201
  };
@@ -166,6 +166,21 @@ describe('extractChannelOrder — deterministic prefilter', () => {
166
166
  expect(extractChannelOrder('what is my balance')).toBeNull();
167
167
  expect(extractChannelOrder('swap 1000 sats to usdt')).toBeNull();
168
168
  });
169
+
170
+ it('catches "on the other" after "my side" (user-reported variation)', () => {
171
+ const r = extractChannelOrder('get a channel with 30000 on my side and 80000 on the other');
172
+ expect(r).toMatchObject({ client_balance_sat: 30_000, lsp_balance_sat: 80_000 });
173
+ });
174
+
175
+ it('catches "with X on my side and Y on the other side"', () => {
176
+ const r = extractChannelOrder('buy a channel with 20000 on my side and 100000 on the other side');
177
+ expect(r).toMatchObject({ client_balance_sat: 20_000, lsp_balance_sat: 100_000 });
178
+ });
179
+
180
+ it('catches "on lsps" variant with "on the other"', () => {
181
+ const r = extractChannelOrder('get a channel for me with 100000 on lsps and 20000 on the other');
182
+ expect(r).toMatchObject({ client_balance_sat: 20_000, lsp_balance_sat: 100_000 });
183
+ });
169
184
  });
170
185
 
171
186
  describe('kaleidoswapChannelOrderRecipe — selection', () => {
@@ -193,6 +208,29 @@ describe('kaleidoswapChannelOrderRecipe — selection', () => {
193
208
  expect(m('do I need a channel to receive lightning payments?')).toBe(false);
194
209
  expect(m('can I receive without an inbound channel?')).toBe(false);
195
210
  });
211
+
212
+ it('does NOT trigger on read/verify questions about EXISTING channels', () => {
213
+ const m = kaleidoswapChannelOrderRecipe.match!;
214
+ // A spend must never fire from a question about channels the user has.
215
+ // These route to rgb-lightning-node (rln_list_channels).
216
+ expect(m('list my channels')).toBe(false);
217
+ expect(m('list my channels and their capacities')).toBe(false);
218
+ expect(m('do I have a channel with about 60000 inbound and 15000 on my side?')).toBe(false);
219
+ expect(m('show my channels')).toBe(false);
220
+ expect(m('check my channel status')).toBe(false);
221
+ expect(m('which channels do I have')).toBe(false);
222
+ expect(m('what is the status of my channel order')).toBe(false);
223
+ });
224
+
225
+ it('STILL triggers on genuine acquire intents (regression guard)', () => {
226
+ const m = kaleidoswapChannelOrderRecipe.match!;
227
+ expect(m('buy me a channel: 60000 sats inbound and 15000 on my side')).toBe(true);
228
+ expect(m('buy a 500k inbound channel')).toBe(true);
229
+ expect(m('open a channel from the LSP, 200k inbound')).toBe(true);
230
+ expect(m('I need 1M inbound liquidity')).toBe(true);
231
+ expect(m("I can't receive payments")).toBe(true);
232
+ expect(m('order a lsps1 channel')).toBe(true);
233
+ });
196
234
  });
197
235
 
198
236
  describe('kaleidoswapChannelOrderRecipe — full chain', () => {
@@ -37,16 +37,27 @@ const DEFAULT_EXPIRY_BLOCKS = 4320;
37
37
  * - the trading skill's territory.
38
38
  */
39
39
  function CHANNEL_INTENT(t: string): boolean {
40
- // Explanatory/question phrasing → not an order, let the agentic path handle it.
40
+ // Explanatory phrasing → not an order; let RAG / agentic answer.
41
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.
42
+ // Read / verify phrasing → route to rgb-lightning-node (rln_list_channels),
43
+ // NEVER to a spend. "list my channels", "do I have a channel", "show/check
44
+ // my channels", "channel status".
45
+ if (/\b(list|show|view|check|which)\b/i.test(t)) return false;
46
+ if (/\bdo\s+I\s+(already\s+)?have\b/i.test(t)) return false;
47
+ if (/\bmy\s+channels?\b/i.test(t)) return false;
48
+ if (/\b(channel|order|lsp)\s+status\b/i.test(t) || /\bstatus\s+of\b/i.test(t)) return false;
49
+
50
+ // Explicit LSPS1 order keywords → acquire.
43
51
  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;
52
+ // "I can't receive" → wants inbound liquidity.
46
53
  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;
54
+
55
+ // Otherwise require an explicit ACQUIRE verb so a bare mention of "channel"
56
+ // or "inbound" in a question doesn't trigger a spend.
57
+ const acquire = /\b(buy|open|get|order|purchase|acquire|need|want|add|create)\b/i.test(t);
58
+ if (!acquire) return false;
59
+ if (/\bchannel\b/i.test(t)) return true;
60
+ if (/\binbound(\s+(liquidity|capacity))?\b/i.test(t)) return true;
50
61
  return false;
51
62
  }
52
63
 
@@ -114,6 +125,12 @@ export function extractChannelOrder(text: string): Record<string, unknown> | nul
114
125
  if (otherNum && otherNum[1]) lsp_balance_sat = parseAmountWord(otherNum[1], otherNum[2]);
115
126
  }
116
127
 
128
+ // "on the other" when lsp side tagged first (e.g. "100k on lsps and 20k on the other") -> client side
129
+ if (lsp_balance_sat != null && client_balance_sat == null) {
130
+ 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);
131
+ if (otherNum && otherNum[1]) client_balance_sat = parseAmountWord(otherNum[1], otherNum[2]);
132
+ }
133
+
117
134
  // Specific anchored pattern for "on my side X and Y on the other" or similar structures
118
135
  if (client_balance_sat != null && lsp_balance_sat == null) {
119
136
  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);
@@ -531,7 +548,7 @@ export const kaleidoswapChannelOrderRecipe: Recipe = {
531
548
  const beforeIds = new Set((before?.channels ?? []).map((c) => c.channel_id));
532
549
  const fresh = (channels?.channels ?? []).filter((c) => c.channel_id && !beforeIds.has(c.channel_id));
533
550
  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).';
551
+ let opened = ' The channel will open once the LSP confirms the payment — say "check my channel status" (or "lsp status") and I will recall the details + poll lsp_get_order automatically.';
535
552
  if (match) {
536
553
  const cap = match.capacity_sat != null ? `${match.capacity_sat.toLocaleString()}-sat` : 'new';
537
554
  const ready = match.ready ? 'ready' : (match.status ?? 'opening');
@@ -543,6 +560,7 @@ export const kaleidoswapChannelOrderRecipe: Recipe = {
543
560
  const lspAsset = Number(ctx.slots.lsp_asset_amount ?? 0);
544
561
  const assetPart = ticker ? ` (${lspAsset.toLocaleString()} ${ticker} inbound)` : '';
545
562
 
546
- return `Channel order created. To check status use: lsp_get_order with${tokenNote} .${paid}${assetPart}.${adjusted}${opened}`;
563
+ return `remember: LSPS1 channel order ${id} access_token=${token || ''} (for later lsp_get_order status checks).
564
+ Channel order created. ${tokenNote ? `order_id=${id} access_token=${token}. ` : ''}To check status later, call: lsp_get_order(order_id=${id}, access_token=${token || '...'}) .${paid}${assetPart}.${adjusted}${opened}`;
547
565
  },
548
566
  };
@@ -21,8 +21,14 @@ import type { Recipe, RecipeContext } from './types.js';
21
21
  import { extractPriceQuery } from './swap.js';
22
22
 
23
23
  const ASSET = /\b(btc|bitcoin|sats?|usdt|tether|xaut|gold)\b/i;
24
+ // Flashnet (Spark AMM) cues — a price/rate question aimed at a Flashnet asset
25
+ // or venue should NOT be quoted via the KaleidoSwap maker. Defer to the
26
+ // agentic tier (flashnet-swaps simulate_swap is the read-only quote there).
27
+ const FLASHNET_CUE = /\b(flashnet|usdb|spark)\b/i;
24
28
  const PRICE_INTENT = (t: string) =>
25
- /\b(price|rate|cost|worth|how\s+(?:much|many))\b/i.test(t) && ASSET.test(t);
29
+ /\b(price|rate|cost|worth|how\s+(?:much|many))\b/i.test(t) &&
30
+ ASSET.test(t) &&
31
+ !FLASHNET_CUE.test(t);
26
32
 
27
33
  interface QuoteResult {
28
34
  rfq_id?: string;
@@ -158,6 +158,11 @@ describe('extractPriceQuery', () => {
158
158
  amount: 1, from_asset: 'BTC', to_asset: 'USDT', amount_side: 'to',
159
159
  });
160
160
  });
161
+ it('parses a quantity quote with the amount on the priced asset', () => {
162
+ expect(extractPriceQuery('how many sats is 10 USDT worth?')).toEqual({
163
+ amount: 10, from_asset: 'BTC', to_asset: 'USDT', amount_side: 'to',
164
+ });
165
+ });
161
166
  it('handles "cost of xaut" and "how much does 1 btc cost"', () => {
162
167
  expect((extractPriceQuery('cost of xaut') as any)?.to_asset).toBe('XAUT');
163
168
  expect((extractPriceQuery('how much does 1 btc cost') as any)?.to_asset).toBe('BTC');
@@ -95,10 +95,12 @@ export async function extractSlots(
95
95
  const system = [
96
96
  `Call ${EXTRACT_TOOL} with the fields from the user's message.`,
97
97
  recipe.description ? `This extraction is for: ${recipe.description}.` : '',
98
- 'Only emit values that match the field descriptions.',
98
+ 'Only emit values that match the field descriptions. Use the examples and phrasings listed in each field\'s description (including context like "on the other" when "my side" appears).',
99
99
  'Canonical assets: BTC, USDT, XAUT (pass as strings like "BTC" or "USDT").',
100
100
  'amount_side: "to" when the named amount is what you receive/buy (e.g. "buy 1 USDT" → to_asset=USDT, amount=1, from_asset=BTC); "from" for sell/swap (amount on from_asset).',
101
101
  'The host binding handles per-asset precision scaling (BTC in sats → maker units; USDT/XAUT whole units). Pass the user\'s number as-is for the correct side.',
102
+ 'If a value is ambiguous from the message, prefer the mapping from the field descriptions rather than guessing.',
103
+ 'For status-related follow-ups the history (or recall result) will contain explicit "order_id=... access_token=..." or "atomic_id=..." strings from prior summaries — when relevant extract them exactly.',
102
104
  'Do not call any other tool and do not add commentary.',
103
105
  ].filter(Boolean).join(' ');
104
106
 
@@ -175,10 +177,10 @@ export async function runRecipe(recipe: Recipe, text: string, opts: RunRecipeOpt
175
177
  if (recipe.confident && !recipe.confident(ctx.slots)) {
176
178
  const missing = recipe.slots
177
179
  .filter((s) => s.required && (ctx.slots[s.name] == null || ctx.slots[s.name] === ''))
178
- .map((s) => `${s.name} (${s.description})`);
180
+ .map((s) => s.name);
179
181
  const ask =
180
182
  missing.length > 0
181
- ? `I need a bit more info — please specify: ${missing.join('; ')}.`
183
+ ? `I need a bit more info — please specify the ${missing.join(' and ')} (rephrase with the numbers, or use recall if this is a follow-up status check).`
182
184
  : "I don't have enough info to do that — could you rephrase with the specifics?";
183
185
  return { recipe: recipe.name, slots: ctx.slots, results: ctx.results, text: ask, status: 'needs-info', inferences };
184
186
  }
@@ -12,7 +12,7 @@
12
12
 
13
13
  import type { Recipe } from './types.js';
14
14
 
15
- const ASSET = /\b(btc|bitcoin|sats?|usdt|tether|xaut|gold)\b/i;
15
+ const ASSET = /\b(btc|bitcoin|sats?|usdt|tether|xaut|gold|usdb)\b/i;
16
16
 
17
17
  /** Strict: returns a canonical code only for a KNOWN crypto asset, else undefined
18
18
  * (so "kaleido", "the", etc. are not mistaken for an asset). */
@@ -22,6 +22,7 @@ function knownAsset(a?: string): string | undefined {
22
22
  if (/^(btc|bitcoin|sat|sats|satoshi|satoshis)$/.test(x)) return 'BTC';
23
23
  if (/^(usdt|tether)$/.test(x)) return 'USDT';
24
24
  if (/^(xaut|gold)$/.test(x)) return 'XAUT';
25
+ if (/^usdb$/.test(x)) return 'USDB';
25
26
  return undefined;
26
27
  }
27
28
 
@@ -106,6 +107,20 @@ export function extractPriceQuery(text: string): Record<string, unknown> | null
106
107
  // Reject swap intent — those go to the atomic recipe, not the price recipe.
107
108
  if (/\b(swap|exchange|convert|trade|buy|sell|get|purchase|acquire)\b/i.test(t)) return null;
108
109
 
110
+ // Natural quantity quote: "how many sats is 10 USDT worth?" The amount is
111
+ // on the priced asset (TO leg); the requested denomination is the FROM leg.
112
+ const quantityWorth = t.match(
113
+ /\bhow\s+(?:many|much)\s+([a-z]+)\s+(?:is|are)\s+(\d[\d.,]*)\s+([a-z]+)\s+worth\b/i,
114
+ );
115
+ if (quantityWorth) {
116
+ const denom = knownAsset(quantityWorth[1]);
117
+ const amount = parseAmount(quantityWorth[2]);
118
+ const asset = knownAsset(quantityWorth[3]);
119
+ if (denom && asset && amount != null) {
120
+ return { amount, from_asset: denom, to_asset: asset, amount_side: 'to' };
121
+ }
122
+ }
123
+
109
124
  // ORDER MATTERS: "how much B for A" (first) must be checked BEFORE
110
125
  // "how much X (in Y)?" — otherwise the latter would gobble the first asset
111
126
  // and miss the "for/per" tail. Optional "the" article is tolerated
@@ -40,6 +40,14 @@ describe('confirmReadback', () => {
40
40
  expect(line).toBe('Pay Lightning invoice lnbc1p…abcd over RLN. Confirm?');
41
41
  });
42
42
 
43
+ it('spark_pay_invoice: same readback shape, over Spark', () => {
44
+ const line = confirmReadback({
45
+ name: 'spark_pay_invoice',
46
+ arguments: { invoice: 'lnbc1ptestinvoice0123456789abcd' },
47
+ });
48
+ expect(line).toBe('Pay Lightning invoice lnbc1p…abcd over Spark. Confirm?');
49
+ });
50
+
43
51
  it('execute_swap: from → to with amount', () => {
44
52
  expect(confirmReadback({ name: 'execute_swap', arguments: { from_asset: 'BTC', to_asset: 'USDT', amount: 0.01 } }))
45
53
  .toBe('Swap 0.01 BTC for USDT. Confirm?');
@@ -64,6 +64,7 @@ export function confirmReadback(call: { name: string; arguments: Record<string,
64
64
  case 'liquid_send':
65
65
  return ask(`Send ${asset(a.amount, a.asset)} to ${to()}${over(name, a)}`);
66
66
  case 'rln_pay_invoice':
67
+ case 'spark_pay_invoice':
67
68
  return ask(`Pay Lightning invoice ${shortRef(String(a.invoice ?? ''))}${over(name, a)}`);
68
69
  case 'execute_swap':
69
70
  return ask(`Swap ${fmtNum(Number(a.amount))} ${String(a.from_asset)} for ${String(a.to_asset)}`);
@@ -39,12 +39,22 @@ describe('WALLET_TOOLS contract', () => {
39
39
  expect(isSpendTool('rln_send_asset')).toBe(true);
40
40
  expect(isSpendTool('execute_swap')).toBe(true);
41
41
  expect(isSpendTool('spark_send')).toBe(true);
42
+ expect(isSpendTool('spark_pay_invoice')).toBe(true);
42
43
  // reads are not
43
44
  expect(isSpendTool('get_balances')).toBe(false);
44
45
  expect(isSpendTool('get_price')).toBe(false);
45
46
  expect([...SPEND_TOOLS].length).toBeGreaterThanOrEqual(5);
46
47
  });
47
48
 
49
+ it('spark_pay_invoice is its own tool — BOLT11-shaped, amount optional', () => {
50
+ const def = getWalletTool('spark_pay_invoice');
51
+ expect(def?.layer).toBe('spark');
52
+ expect(def?.spend).toBe(true);
53
+ expect((def!.parameters as any).required).toEqual(['invoice']);
54
+ expect((def!.parameters as any).properties.invoice.type).toBe('string');
55
+ expect((def!.parameters as any).properties.amount_sats.type).toBe('number');
56
+ });
57
+
48
58
  it('required args declared on the actionable tools', () => {
49
59
  expect((getWalletTool('send_payment')!.parameters as any).required).toContain('to');
50
60
  expect((getWalletTool('fiat_to_sats')!.parameters as any).required).toEqual(['amount', 'currency']);
@@ -56,10 +56,32 @@ const asset = { type: 'string', description: "Asset ticker, e.g. 'USDT', 'XAUT',
56
56
  /** The full contract. Keep descriptions terse — small models read every word. */
57
57
  export const WALLET_TOOLS: WalletToolDef[] = [
58
58
  // ── Spark ──────────────────────────────────────────────────────────────
59
- t('spark', 'spark_get_balance', 'Get the Spark wallet BTC balance.'),
60
- t('spark', 'spark_get_address', 'Get a Spark deposit address to receive BTC.'),
61
- t('spark', 'spark_create_invoice', 'Create a Spark Lightning invoice to receive BTC.', { amount_sats: sats }),
62
- t('spark', 'spark_send', 'Send BTC from Spark to an address or invoice.', { amount_sats: sats, to: { type: 'string', description: 'Address or invoice' } }, ['amount_sats', 'to'], true),
59
+ t('spark', 'spark_get_balance', 'Get the Spark wallet balances — BTC sats AND every Spark-native token (e.g. USDB). Returns `{ total: <sats>, tokens: [{ address, balance, symbol?, decimals?, available_to_send? }], connected, layer, network }`. Use for ANY "balance / how much / what do I have on Spark" question — call it fresh every time, balances change. The `tokens` array surfaces ALL Spark-native tokens the wallet holds, so you do NOT need to call flashnet_get_balance separately for that (flashnet_get_balance is the AMM-client view of the same wallet and returns the same numbers). For RGB asset balances (USDT, XAUT) use the RLN tools — RGB assets are NOT on Spark.'),
60
+ // The user-facing "Spark address" an off-chain Spark identity (sparkrt1…/
61
+ // spark1…). For OFF-CHAIN peer transfers WITHIN Spark. NOT a Bitcoin
62
+ // on-chain address. Use spark_get_onchain_address for the on-chain deposit
63
+ // path; use spark_create_invoice for a Lightning invoice.
64
+ t('spark', 'spark_get_address', 'Get the user\'s Spark address (sparkrt1…/spark1…) — an OFF-CHAIN Spark identity for receiving Spark-to-Spark transfers. NOT a Bitcoin on-chain address (does not start with bc1/tb1/bcrt1) and NOT a Lightning invoice. For "an on-chain address to deposit BTC into Spark" use spark_get_onchain_address. For a Lightning invoice use spark_create_invoice.'),
65
+ // Real on-chain Bitcoin address used to deposit BTC FROM mainnet INTO the
66
+ // Spark wallet. The SDK calls this a "static deposit address" — bc1…/tb1…/
67
+ // bcrt1…. The opposite of spark_get_address.
68
+ t('spark', 'spark_get_onchain_address', 'Get a real Bitcoin ON-CHAIN address (bc1…/tb1…/bcrt1…) for depositing BTC from the Bitcoin L1 into the Spark wallet. Use ANY time the user asks for "an on-chain address", "deposit address", "Bitcoin address to fund Spark", "where do I send my on-chain BTC". This is NOT the Spark identity (spark_get_address) and NOT a Lightning invoice (spark_create_invoice).'),
69
+ t('spark', 'spark_create_invoice', 'Create a Spark Lightning invoice (BOLT11) to receive BTC over Lightning. Returns an invoice string the user can share. Use when the user asks for "an invoice", "a lightning invoice", "pay me", or names an amount they want received. NOT an address.', { amount_sats: sats }),
70
+ // Explicit Lightning-invoice payer. BOLT11 invoices encode the amount, so
71
+ // `amount_sats` is optional and only used for amount-less ("any-amount")
72
+ // invoices. Prefer this over `spark_send` when the destination is a BOLT11
73
+ // invoice — it removes ambiguity for small models and gives the cross-skill
74
+ // bitrefill flow a single, unambiguous target.
75
+ t('spark', 'spark_pay_invoice',
76
+ 'Pay a Lightning (BOLT11) invoice from the Spark wallet. The invoice already encodes the amount; pass amount_sats ONLY for amount-less invoices. Use this for any BOLT11 destination (Bitrefill, contact, raw invoice).',
77
+ { invoice: { type: 'string', description: 'BOLT11 Lightning invoice (lnbc…/lntb…/lnbcrt…).' }, amount_sats: { type: 'number', description: 'Required ONLY when the invoice has no amount; omit otherwise.' } },
78
+ ['invoice'],
79
+ /* spend */ true),
80
+ t('spark', 'spark_send',
81
+ 'Send BTC from Spark to an on-chain address (bc1…/tb1…). For BOLT11 invoices, prefer spark_pay_invoice.',
82
+ { amount_sats: sats, to: { type: 'string', description: 'On-chain Bitcoin address.' } },
83
+ ['amount_sats', 'to'],
84
+ /* spend */ true),
63
85
 
64
86
  // ── RLN / RGB ──────────────────────────────────────────────────────────
65
87
  t('rln', 'rln_get_balances', 'Get RLN node balances (BTC + RGB assets).'),