@kaleidorg/mind 0.5.1 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/autonomy/index.d.ts +21 -0
- package/dist/autonomy/index.d.ts.map +1 -0
- package/dist/autonomy/index.js +16 -0
- package/dist/autonomy/index.js.map +1 -0
- package/dist/autonomy/prompt.d.ts +21 -0
- package/dist/autonomy/prompt.d.ts.map +1 -0
- package/dist/autonomy/prompt.js +37 -0
- package/dist/autonomy/prompt.js.map +1 -0
- package/dist/autonomy/risk.d.ts +53 -0
- package/dist/autonomy/risk.d.ts.map +1 -0
- package/dist/autonomy/risk.js +74 -0
- package/dist/autonomy/risk.js.map +1 -0
- package/dist/autonomy/run-state.d.ts +39 -0
- package/dist/autonomy/run-state.d.ts.map +1 -0
- package/dist/autonomy/run-state.js +118 -0
- package/dist/autonomy/run-state.js.map +1 -0
- package/dist/autonomy/scheduler.d.ts +18 -0
- package/dist/autonomy/scheduler.d.ts.map +1 -0
- package/dist/autonomy/scheduler.js +113 -0
- package/dist/autonomy/scheduler.js.map +1 -0
- package/dist/autonomy/task-store.d.ts +44 -0
- package/dist/autonomy/task-store.d.ts.map +1 -0
- package/dist/autonomy/task-store.js +139 -0
- package/dist/autonomy/task-store.js.map +1 -0
- package/dist/autonomy/types.d.ts +164 -0
- package/dist/autonomy/types.d.ts.map +1 -0
- package/dist/autonomy/types.js +20 -0
- package/dist/autonomy/types.js.map +1 -0
- package/dist/bitrefill/contract.d.ts +60 -0
- package/dist/bitrefill/contract.d.ts.map +1 -0
- package/dist/bitrefill/contract.js +119 -0
- package/dist/bitrefill/contract.js.map +1 -0
- package/dist/context/compress.d.ts +65 -0
- package/dist/context/compress.d.ts.map +1 -0
- package/dist/context/compress.js +181 -0
- package/dist/context/compress.js.map +1 -0
- package/dist/engine.d.ts +20 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +23 -4
- package/dist/engine.js.map +1 -1
- package/dist/evidence.d.ts +62 -0
- package/dist/evidence.d.ts.map +1 -0
- package/dist/evidence.js +47 -0
- package/dist/evidence.js.map +1 -0
- package/dist/flashnet/contract.d.ts +56 -0
- package/dist/flashnet/contract.d.ts.map +1 -0
- package/dist/flashnet/contract.js +100 -0
- package/dist/flashnet/contract.js.map +1 -0
- package/dist/funnel.d.ts +11 -0
- package/dist/funnel.d.ts.map +1 -1
- package/dist/funnel.js +62 -7
- package/dist/funnel.js.map +1 -1
- package/dist/index.d.ts +12 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/kaleidoswap/contract.js +1 -1
- package/dist/kaleidoswap/contract.js.map +1 -1
- package/dist/knowledge/bitcoin-copilot.d.ts.map +1 -1
- package/dist/knowledge/bitcoin-copilot.js +85 -2
- package/dist/knowledge/bitcoin-copilot.js.map +1 -1
- package/dist/providers/types.d.ts +17 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/qvac/index.d.ts +1 -1
- package/dist/qvac/index.d.ts.map +1 -1
- package/dist/qvac/index.js.map +1 -1
- package/dist/qvac/parse.d.ts +18 -0
- package/dist/qvac/parse.d.ts.map +1 -1
- package/dist/qvac/parse.js +1 -0
- package/dist/qvac/parse.js.map +1 -1
- package/dist/qvac/provider.d.ts +16 -0
- package/dist/qvac/provider.d.ts.map +1 -1
- package/dist/qvac/provider.js +40 -1
- package/dist/qvac/provider.js.map +1 -1
- package/dist/qvac/stream.d.ts +22 -0
- package/dist/qvac/stream.d.ts.map +1 -1
- package/dist/qvac/stream.js +33 -1
- package/dist/qvac/stream.js.map +1 -1
- package/dist/recipe/buy-asset-channel.d.ts +1 -1
- package/dist/recipe/buy-asset-channel.d.ts.map +1 -1
- package/dist/recipe/buy-asset-channel.js +4 -3
- package/dist/recipe/buy-asset-channel.js.map +1 -1
- package/dist/recipe/flashnet-swap.d.ts +35 -0
- package/dist/recipe/flashnet-swap.d.ts.map +1 -0
- package/dist/recipe/flashnet-swap.js +239 -0
- package/dist/recipe/flashnet-swap.js.map +1 -0
- package/dist/recipe/kaleidoswap-atomic.d.ts +1 -1
- package/dist/recipe/kaleidoswap-atomic.d.ts.map +1 -1
- package/dist/recipe/kaleidoswap-atomic.js +42 -20
- package/dist/recipe/kaleidoswap-atomic.js.map +1 -1
- package/dist/recipe/kaleidoswap-channel-order.d.ts.map +1 -1
- package/dist/recipe/kaleidoswap-channel-order.js +31 -10
- package/dist/recipe/kaleidoswap-channel-order.js.map +1 -1
- package/dist/recipe/kaleidoswap-price.d.ts.map +1 -1
- package/dist/recipe/kaleidoswap-price.js +7 -1
- package/dist/recipe/kaleidoswap-price.js.map +1 -1
- package/dist/recipe/runner.d.ts.map +1 -1
- package/dist/recipe/runner.js +43 -3
- package/dist/recipe/runner.js.map +1 -1
- package/dist/recipe/swap.d.ts.map +1 -1
- package/dist/recipe/swap.js +14 -1
- package/dist/recipe/swap.js.map +1 -1
- package/dist/tools/mcp.d.ts +19 -0
- package/dist/tools/mcp.d.ts.map +1 -1
- package/dist/tools/mcp.js +51 -9
- package/dist/tools/mcp.js.map +1 -1
- package/dist/wallet/confirm.d.ts.map +1 -1
- package/dist/wallet/confirm.js +1 -0
- package/dist/wallet/confirm.js.map +1 -1
- package/dist/wallet/contract.d.ts.map +1 -1
- package/dist/wallet/contract.js +20 -4
- package/dist/wallet/contract.js.map +1 -1
- package/package.json +5 -4
- package/skills/bitrefill/SKILL.md +152 -52
- package/skills/channel-manager/SKILL.md +59 -0
- package/skills/dca/SKILL.md +48 -0
- package/skills/flashnet-swaps/SKILL.md +158 -0
- package/skills/kaleido-lsps/SKILL.md +34 -17
- package/skills/kaleido-trading/SKILL.md +37 -13
- package/skills/liquidity-optimizer/SKILL.md +91 -0
- package/skills/merchant-finder/SKILL.md +2 -2
- package/skills/portfolio-manager/SKILL.md +67 -0
- package/skills/rgb-lightning-node/SKILL.md +38 -11
- package/skills/spark-wallet/SKILL.md +235 -0
- package/skills/wallet-assistant/SKILL.md +2 -2
- package/src/autonomy/autonomy.test.ts +348 -0
- package/src/autonomy/index.ts +50 -0
- package/src/autonomy/prompt.ts +48 -0
- package/src/autonomy/risk.ts +139 -0
- package/src/autonomy/run-state.ts +144 -0
- package/src/autonomy/scheduler.ts +120 -0
- package/src/autonomy/task-store.ts +167 -0
- package/src/autonomy/types.ts +186 -0
- package/src/bitrefill/contract.test.ts +89 -0
- package/src/bitrefill/contract.ts +190 -0
- package/src/context/compress.test.ts +120 -0
- package/src/context/compress.ts +230 -0
- package/src/engine.test.ts +34 -0
- package/src/engine.ts +35 -4
- package/src/evidence.test.ts +80 -0
- package/src/evidence.ts +114 -0
- package/src/flashnet/contract.test.ts +101 -0
- package/src/flashnet/contract.ts +164 -0
- package/src/funnel.mind.test.ts +390 -0
- package/src/funnel.ts +73 -8
- package/src/index.ts +92 -1
- package/src/kaleidoswap/contract.ts +1 -1
- package/src/knowledge/bitcoin-copilot.ts +96 -2
- package/src/providers/types.ts +18 -0
- package/src/qvac/index.ts +1 -0
- package/src/qvac/parse.ts +20 -0
- package/src/qvac/provider.test.ts +17 -0
- package/src/qvac/provider.ts +62 -2
- package/src/qvac/stream.test.ts +36 -0
- package/src/qvac/stream.ts +54 -1
- package/src/recipe/buy-asset-channel.test.ts +5 -0
- package/src/recipe/buy-asset-channel.ts +6 -3
- package/src/recipe/flashnet-swap.test.ts +114 -0
- package/src/recipe/flashnet-swap.ts +266 -0
- package/src/recipe/kaleidoswap-atomic.test.ts +24 -3
- package/src/recipe/kaleidoswap-atomic.ts +39 -20
- package/src/recipe/kaleidoswap-channel-order.test.ts +38 -0
- package/src/recipe/kaleidoswap-channel-order.ts +27 -9
- package/src/recipe/kaleidoswap-price.ts +7 -1
- package/src/recipe/recipe.test.ts +21 -0
- package/src/recipe/runner.ts +46 -3
- package/src/recipe/swap.ts +16 -1
- package/src/tools/mcp.live.test.ts +116 -0
- package/src/tools/mcp.parse.test.ts +37 -0
- package/src/tools/mcp.ts +55 -9
- package/src/wallet/confirm.test.ts +8 -0
- package/src/wallet/confirm.ts +1 -0
- package/src/wallet/contract.test.ts +10 -0
- package/src/wallet/contract.ts +26 -4
|
@@ -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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
48
|
-
//
|
|
49
|
-
|
|
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 —
|
|
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 `
|
|
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) &&
|
|
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;
|
|
@@ -72,6 +72,22 @@ describe('runRecipe — pay a contact', () => {
|
|
|
72
72
|
expect(sent).toHaveLength(0);
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
+
it('never reports a failed wallet result as sent', async () => {
|
|
76
|
+
const tools = new ToolRegistry([new InProcessToolSource('wallet', [
|
|
77
|
+
{ name: 'resolve_contact', description: '', parameters: { type: 'object', properties: {} }, handler: async ({ name }) => ({ name, ln_address: `${name}@kaleidoswap.com` }) },
|
|
78
|
+
{ name: 'fiat_to_sats', description: '', parameters: { type: 'object', properties: {} }, handler: async ({ amount }) => ({ sats: Math.round(Number(amount) * 1000) }) },
|
|
79
|
+
{ name: 'send_payment', description: '', parameters: { type: 'object', properties: {} }, requiresConfirmation: true, handler: async () => ({ success: false, message: 'insufficient balance' }) },
|
|
80
|
+
])]);
|
|
81
|
+
const res = await runRecipe(paymentsRecipe, 'pay bob 3 eur', {
|
|
82
|
+
provider: approve,
|
|
83
|
+
tools,
|
|
84
|
+
onConfirm: async () => ({ approved: true }),
|
|
85
|
+
});
|
|
86
|
+
expect(res.status).toBe('error');
|
|
87
|
+
expect(res.text).toContain('insufficient balance');
|
|
88
|
+
expect(res.text).not.toContain('Sent');
|
|
89
|
+
});
|
|
90
|
+
|
|
75
91
|
it('falls back to ONE LLM extraction when regex misses', async () => {
|
|
76
92
|
const sent: any[] = [];
|
|
77
93
|
const tools = stubTools({ send: (a) => sent.push(a) });
|
|
@@ -142,6 +158,11 @@ describe('extractPriceQuery', () => {
|
|
|
142
158
|
amount: 1, from_asset: 'BTC', to_asset: 'USDT', amount_side: 'to',
|
|
143
159
|
});
|
|
144
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
|
+
});
|
|
145
166
|
it('handles "cost of xaut" and "how much does 1 btc cost"', () => {
|
|
146
167
|
expect((extractPriceQuery('cost of xaut') as any)?.to_asset).toBe('XAUT');
|
|
147
168
|
expect((extractPriceQuery('how much does 1 btc cost') as any)?.to_asset).toBe('BTC');
|
package/src/recipe/runner.ts
CHANGED
|
@@ -29,6 +29,43 @@ export interface RunRecipeOptions {
|
|
|
29
29
|
signal?: AbortSignal;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
function toolFailure(result: unknown): string | null {
|
|
33
|
+
// A plain-string result (non-JSON MCP text, or a tool that returns prose):
|
|
34
|
+
// flag obvious error text so a failed action isn't reported as success.
|
|
35
|
+
if (typeof result === 'string') {
|
|
36
|
+
const s = result.trim();
|
|
37
|
+
return /^(error|failed|failure|exception)\b\s*[:\-]?/i.test(s) ? s : null;
|
|
38
|
+
}
|
|
39
|
+
if (!result || typeof result !== 'object') return null;
|
|
40
|
+
const r = result as Record<string, unknown>;
|
|
41
|
+
if (typeof r.error === 'string' && r.error.trim()) return r.error;
|
|
42
|
+
if (r.success === false || r.ok === false) {
|
|
43
|
+
return String(r.message ?? r.reason ?? 'The wallet action failed.');
|
|
44
|
+
}
|
|
45
|
+
const status = String(r.status ?? r.state ?? '').toLowerCase();
|
|
46
|
+
if (['error', 'failed', 'failure', 'rejected'].includes(status)) {
|
|
47
|
+
return String(r.message ?? r.reason ?? `The wallet returned status "${status}".`);
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function failedResult(
|
|
53
|
+
recipe: Recipe,
|
|
54
|
+
ctx: RecipeContext,
|
|
55
|
+
inferences: number,
|
|
56
|
+
message: string,
|
|
57
|
+
): RecipeResult {
|
|
58
|
+
return {
|
|
59
|
+
recipe: recipe.name,
|
|
60
|
+
slots: ctx.slots,
|
|
61
|
+
results: ctx.results,
|
|
62
|
+
text: `Couldn't complete that: ${message}`,
|
|
63
|
+
status: 'error',
|
|
64
|
+
error: message,
|
|
65
|
+
inferences,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
32
69
|
/** Extract the recipe's slots — deterministic regex first, else ONE LLM call. */
|
|
33
70
|
export async function extractSlots(
|
|
34
71
|
provider: LLMProvider,
|
|
@@ -58,10 +95,12 @@ export async function extractSlots(
|
|
|
58
95
|
const system = [
|
|
59
96
|
`Call ${EXTRACT_TOOL} with the fields from the user's message.`,
|
|
60
97
|
recipe.description ? `This extraction is for: ${recipe.description}.` : '',
|
|
61
|
-
'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).',
|
|
62
99
|
'Canonical assets: BTC, USDT, XAUT (pass as strings like "BTC" or "USDT").',
|
|
63
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).',
|
|
64
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.',
|
|
65
104
|
'Do not call any other tool and do not add commentary.',
|
|
66
105
|
].filter(Boolean).join(' ');
|
|
67
106
|
|
|
@@ -138,10 +177,10 @@ export async function runRecipe(recipe: Recipe, text: string, opts: RunRecipeOpt
|
|
|
138
177
|
if (recipe.confident && !recipe.confident(ctx.slots)) {
|
|
139
178
|
const missing = recipe.slots
|
|
140
179
|
.filter((s) => s.required && (ctx.slots[s.name] == null || ctx.slots[s.name] === ''))
|
|
141
|
-
.map((s) =>
|
|
180
|
+
.map((s) => s.name);
|
|
142
181
|
const ask =
|
|
143
182
|
missing.length > 0
|
|
144
|
-
? `I need a bit more info — please specify
|
|
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).`
|
|
145
184
|
: "I don't have enough info to do that — could you rephrase with the specifics?";
|
|
146
185
|
return { recipe: recipe.name, slots: ctx.slots, results: ctx.results, text: ask, status: 'needs-info', inferences };
|
|
147
186
|
}
|
|
@@ -187,6 +226,8 @@ export async function runRecipe(recipe: Recipe, text: string, opts: RunRecipeOpt
|
|
|
187
226
|
const result = await opts.tools.execute(step.tool, args);
|
|
188
227
|
ctx.results[step.as ?? step.tool] = result;
|
|
189
228
|
opts.onStep?.(step.tool, args, result);
|
|
229
|
+
const failure = toolFailure(result);
|
|
230
|
+
if (failure) return failedResult(recipe, ctx, inferences, failure);
|
|
190
231
|
}
|
|
191
232
|
|
|
192
233
|
// Final action.
|
|
@@ -195,6 +236,8 @@ export async function runRecipe(recipe: Recipe, text: string, opts: RunRecipeOpt
|
|
|
195
236
|
const finalResult = await opts.tools.execute(recipe.final.tool, finalArgs);
|
|
196
237
|
ctx.results[recipe.final.as ?? recipe.final.tool] = finalResult;
|
|
197
238
|
opts.onStep?.(recipe.final.tool, finalArgs, finalResult);
|
|
239
|
+
const failure = toolFailure(finalResult);
|
|
240
|
+
if (failure) return failedResult(recipe, ctx, inferences, failure);
|
|
198
241
|
|
|
199
242
|
const out = recipe.summary?.(ctx, finalResult) ?? 'Done.';
|
|
200
243
|
return { recipe: recipe.name, slots: ctx.slots, results: ctx.results, final: finalResult, text: out, status: 'done', inferences };
|
package/src/recipe/swap.ts
CHANGED
|
@@ -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
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live MCP integration — regression guard for the "tool-less desktop chat" bug.
|
|
3
|
+
*
|
|
4
|
+
* The desktop agent (desktop-app/src-tauri/src/mind.rs → apps/provider
|
|
5
|
+
* connectMcpIfConfigured) wires tools EXACTLY the way this test does: spawn
|
|
6
|
+
* `node <kaleido-mcp>/dist/index.js` over stdio with RLN_NODE_URL pointing at
|
|
7
|
+
* the user's RGB-Lightning node, then listTools()/execute(). When that wiring
|
|
8
|
+
* breaks, the registry is empty, the model goes "tool-less", and it NARRATES
|
|
9
|
+
* tool calls it can never run ("Could you use the kaleidoswap_get_quote tool?")
|
|
10
|
+
* instead of returning real data — the exact 2026-06 symptom.
|
|
11
|
+
*
|
|
12
|
+
* This drives that chain end-to-end against a REAL running node and asserts the
|
|
13
|
+
* tools both EXIST (not tool-less) and EXECUTE (return live node data). A unit
|
|
14
|
+
* test can't catch this: the bug is in process/env wiring, not pure logic.
|
|
15
|
+
*
|
|
16
|
+
* Auto-skips unless (a) kaleido-mcp/dist is built and (b) an RLN node answers,
|
|
17
|
+
* so it's a no-op in CI and a real check on a dev box with a node up. Run it
|
|
18
|
+
* explicitly against a node with:
|
|
19
|
+
* RLN_NODE_URL=http://localhost:3001 pnpm --filter @kaleidorg/mind test:live
|
|
20
|
+
*/
|
|
21
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
22
|
+
import { existsSync } from 'node:fs';
|
|
23
|
+
import { dirname, resolve } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { McpToolSource } from './mcp.js';
|
|
26
|
+
|
|
27
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
// $KALEIDO_MCP_PATH override (what mind.rs sets), else the sibling repo's build.
|
|
29
|
+
const MCP_ENTRY =
|
|
30
|
+
process.env.KALEIDO_MCP_PATH ??
|
|
31
|
+
resolve(here, '../../../../../kaleido-mcp/dist/index.js');
|
|
32
|
+
const NODE_URL = (process.env.RLN_NODE_URL ?? 'http://localhost:3001').replace(/\/+$/, '');
|
|
33
|
+
|
|
34
|
+
/** Probe the RLN node directly so we can (a) gate the suite and (b) compare the
|
|
35
|
+
* MCP tool's output to ground truth pulled straight from the node. */
|
|
36
|
+
async function fetchNodePubkey(): Promise<string | null> {
|
|
37
|
+
try {
|
|
38
|
+
const r = await fetch(`${NODE_URL}/nodeinfo`, { signal: AbortSignal.timeout(4000) });
|
|
39
|
+
if (!r.ok) return null;
|
|
40
|
+
const j = (await r.json()) as { pubkey?: string };
|
|
41
|
+
return typeof j.pubkey === 'string' && j.pubkey.length > 0 ? j.pubkey : null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const hasDist = existsSync(MCP_ENTRY);
|
|
48
|
+
const livePubkey = hasDist ? await fetchNodePubkey() : null;
|
|
49
|
+
const RUN = hasDist && !!livePubkey;
|
|
50
|
+
|
|
51
|
+
if (!RUN) {
|
|
52
|
+
const why = !hasDist ? `no built MCP at ${MCP_ENTRY}` : `no RLN node at ${NODE_URL}`;
|
|
53
|
+
// eslint-disable-next-line no-console
|
|
54
|
+
console.warn(`[mcp.live] skipping live MCP integration — ${why}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe.skipIf(!RUN)('MCP live integration (real RLN node)', () => {
|
|
58
|
+
let src: McpToolSource;
|
|
59
|
+
|
|
60
|
+
beforeAll(async () => {
|
|
61
|
+
src = new McpToolSource({
|
|
62
|
+
id: 'kaleido-test',
|
|
63
|
+
transport: {
|
|
64
|
+
kind: 'stdio',
|
|
65
|
+
command: 'node',
|
|
66
|
+
args: [MCP_ENTRY],
|
|
67
|
+
// Mirror the provider: inherit env, force the node URL, allow no WDK seed
|
|
68
|
+
// (rln_*/kaleidoswap_* register regardless; only spark_*/wdk_* need it).
|
|
69
|
+
env: {
|
|
70
|
+
...process.env,
|
|
71
|
+
RLN_NODE_URL: NODE_URL,
|
|
72
|
+
WDK_SEED: process.env.WDK_SEED ?? '',
|
|
73
|
+
} as Record<string, string>,
|
|
74
|
+
},
|
|
75
|
+
timeoutMs: 30_000,
|
|
76
|
+
});
|
|
77
|
+
await src.connect();
|
|
78
|
+
}, 45_000);
|
|
79
|
+
|
|
80
|
+
afterAll(async () => {
|
|
81
|
+
await src?.close();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('exposes a non-empty tool registry (the model is NOT tool-less)', () => {
|
|
85
|
+
const tools = src.listTools();
|
|
86
|
+
expect(tools.length).toBeGreaterThan(0);
|
|
87
|
+
// The exact tools the agent narrated when it couldn't call them.
|
|
88
|
+
expect(src.has('rln_get_node_info')).toBe(true);
|
|
89
|
+
expect(src.has('rln_get_balances')).toBe(true);
|
|
90
|
+
expect(src.has('kaleidoswap_get_quote')).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('preserves the confirmation gate on known spend tools', () => {
|
|
94
|
+
const spend = src.listTools().find((tool) => tool.name === 'rln_pay_invoice');
|
|
95
|
+
if (spend) expect(spend.requiresConfirmation).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('rln_get_node_info EXECUTES against the node (returns the live pubkey)', async () => {
|
|
99
|
+
const out = await src.execute('rln_get_node_info', {});
|
|
100
|
+
const text = typeof out === 'string' ? out : JSON.stringify(out);
|
|
101
|
+
// Real execution returns the node's actual identity — not a narrated promise.
|
|
102
|
+
expect(text).toContain(livePubkey!);
|
|
103
|
+
}, 30_000);
|
|
104
|
+
|
|
105
|
+
it('rln_get_balances EXECUTES against the node (returns live balance fields)', async () => {
|
|
106
|
+
const out = await src.execute('rln_get_balances', {});
|
|
107
|
+
const text = typeof out === 'string' ? out : JSON.stringify(out);
|
|
108
|
+
const parsed = JSON.parse(text) as {
|
|
109
|
+
lightning_balance_sat?: number;
|
|
110
|
+
btc_onchain?: Record<string, number>;
|
|
111
|
+
};
|
|
112
|
+
expect(parsed).toHaveProperty('lightning_balance_sat');
|
|
113
|
+
expect(typeof parsed.lightning_balance_sat).toBe('number');
|
|
114
|
+
expect(parsed).toHaveProperty('btc_onchain');
|
|
115
|
+
}, 30_000);
|
|
116
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** parseMcpResult — JSON parsing + isError handling for MCP tool results. */
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import { parseMcpResult } from './mcp.js';
|
|
5
|
+
|
|
6
|
+
describe('parseMcpResult', () => {
|
|
7
|
+
it('parses JSON text content into an object (so recipes thread real fields)', () => {
|
|
8
|
+
const res = { content: [{ type: 'text', text: '{"rfq_id":"abc","total_sat":1500}' }] };
|
|
9
|
+
expect(parseMcpResult(res)).toEqual({ rfq_id: 'abc', total_sat: 1500 });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('surfaces isError as an {error} object (so a failed spend is not "success")', () => {
|
|
13
|
+
const res = { isError: true, content: [{ type: 'text', text: 'insufficient funds' }] };
|
|
14
|
+
expect(parseMcpResult(res)).toEqual({ error: 'insufficient funds' });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('errors with no text still produce an {error} object', () => {
|
|
18
|
+
expect(parseMcpResult({ isError: true, content: [] })).toEqual({
|
|
19
|
+
error: 'The tool reported an error.',
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('passes non-JSON prose through unchanged', () => {
|
|
24
|
+
const res = { content: [{ type: 'text', text: 'Bitcoin is digital cash.' }] };
|
|
25
|
+
expect(parseMcpResult(res)).toBe('Bitcoin is digital cash.');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns the content array when there is no text block', () => {
|
|
29
|
+
const res = { content: [{ type: 'image', data: 'x' }] };
|
|
30
|
+
expect(parseMcpResult(res)).toEqual([{ type: 'image', data: 'x' }]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('joins multiple text blocks before parsing', () => {
|
|
34
|
+
const res = { content: [{ type: 'text', text: '{"a":1,' }, { type: 'text', text: '"b":2}' }] };
|
|
35
|
+
expect(parseMcpResult(res)).toEqual({ a: 1, b: 2 });
|
|
36
|
+
});
|
|
37
|
+
});
|
package/src/tools/mcp.ts
CHANGED
|
@@ -20,6 +20,18 @@
|
|
|
20
20
|
|
|
21
21
|
import type { ToolDef } from '../types.js';
|
|
22
22
|
import type { ToolSource } from './source.js';
|
|
23
|
+
import { isKaleidoswapSpendTool } from '../kaleidoswap/contract.js';
|
|
24
|
+
import { isLsps1SpendTool } from '../lsps1/contract.js';
|
|
25
|
+
import { isSpendTool } from '../wallet/contract.js';
|
|
26
|
+
|
|
27
|
+
function toolRequiresConfirmation(name: string, description: string): boolean {
|
|
28
|
+
return (
|
|
29
|
+
isSpendTool(name) ||
|
|
30
|
+
isKaleidoswapSpendTool(name) ||
|
|
31
|
+
isLsps1SpendTool(name) ||
|
|
32
|
+
/\bSPEND\b.*\bconfirm/i.test(description)
|
|
33
|
+
);
|
|
34
|
+
}
|
|
23
35
|
|
|
24
36
|
export type McpTransport =
|
|
25
37
|
| { kind: 'stdio'; command: string; args?: string[]; env?: Record<string, string> }
|
|
@@ -30,10 +42,47 @@ export interface McpToolSourceOptions {
|
|
|
30
42
|
transport: McpTransport;
|
|
31
43
|
/** Optional allowlist — only expose these tool names if provided. */
|
|
32
44
|
allow?: string[];
|
|
45
|
+
/** Optional prefix denylist applied after discovery (for host-specific rails). */
|
|
46
|
+
denyPrefixes?: string[];
|
|
33
47
|
/** Per-call timeout (ms). Default 60_000. */
|
|
34
48
|
timeoutMs?: number;
|
|
35
49
|
}
|
|
36
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Normalize an MCP `callTool` result into a structured value.
|
|
53
|
+
*
|
|
54
|
+
* Two fixes vs. returning the raw text content:
|
|
55
|
+
* - `isError` (the MCP failure signal) becomes an `{ error }` object, so callers
|
|
56
|
+
* — the recipe runner's `toolFailure`, the agent — treat it as a FAILURE
|
|
57
|
+
* instead of a successful result. Without this the agent claimed a spend had
|
|
58
|
+
* succeeded when the wallet actually rejected it.
|
|
59
|
+
* - JSON text is PARSED, so recipes thread real fields (rfq_id, total_sat,
|
|
60
|
+
* order_id) and any failure fields (error/status) are visible. A raw string
|
|
61
|
+
* hid both — the quote's rfq_id never reached the create call, and the canned
|
|
62
|
+
* success summary fired regardless. Non-JSON text passes through unchanged;
|
|
63
|
+
* the engine re-stringifies objects when feeding the model.
|
|
64
|
+
*
|
|
65
|
+
* Exported for unit testing.
|
|
66
|
+
*/
|
|
67
|
+
export function parseMcpResult(res: unknown): unknown {
|
|
68
|
+
const r = res as { content?: Array<{ type?: string; text?: string }>; isError?: boolean } | null;
|
|
69
|
+
const text = Array.isArray(r?.content)
|
|
70
|
+
? r!.content
|
|
71
|
+
.filter((c) => c?.type === 'text')
|
|
72
|
+
.map((c) => c?.text ?? '')
|
|
73
|
+
.join('\n')
|
|
74
|
+
: '';
|
|
75
|
+
if (r?.isError) return { error: text || 'The tool reported an error.' };
|
|
76
|
+
if (text) {
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(text);
|
|
79
|
+
} catch {
|
|
80
|
+
return text;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return Array.isArray(r?.content) ? r!.content : res;
|
|
84
|
+
}
|
|
85
|
+
|
|
37
86
|
export class McpToolSource implements ToolSource {
|
|
38
87
|
readonly id: string;
|
|
39
88
|
private readonly opts: McpToolSourceOptions;
|
|
@@ -71,12 +120,15 @@ export class McpToolSource implements ToolSource {
|
|
|
71
120
|
|
|
72
121
|
const listed = await this.client.listTools();
|
|
73
122
|
const allow = this.opts.allow ? new Set(this.opts.allow) : null;
|
|
123
|
+
const denied = this.opts.denyPrefixes ?? [];
|
|
74
124
|
this.tools = (listed.tools ?? [])
|
|
75
125
|
.filter((t: any) => !allow || allow.has(t.name))
|
|
126
|
+
.filter((t: any) => !denied.some((prefix) => t.name.startsWith(prefix)))
|
|
76
127
|
.map((t: any) => ({
|
|
77
128
|
name: t.name,
|
|
78
129
|
description: t.description ?? '',
|
|
79
130
|
parameters: t.inputSchema ?? { type: 'object', properties: {} },
|
|
131
|
+
requiresConfirmation: toolRequiresConfirmation(t.name, t.description ?? ''),
|
|
80
132
|
}));
|
|
81
133
|
}
|
|
82
134
|
|
|
@@ -95,15 +147,9 @@ export class McpToolSource implements ToolSource {
|
|
|
95
147
|
undefined,
|
|
96
148
|
{ timeout: this.opts.timeoutMs ?? 60_000 },
|
|
97
149
|
);
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
.filter((c: any) => c.type === 'text')
|
|
102
|
-
.map((c: any) => c.text)
|
|
103
|
-
.join('\n');
|
|
104
|
-
return text || res.content;
|
|
105
|
-
}
|
|
106
|
-
return res;
|
|
150
|
+
// Parse JSON + surface isError so recipes/agent get structured results and
|
|
151
|
+
// real failures (not an opaque string that hid both). See parseMcpResult.
|
|
152
|
+
return parseMcpResult(res);
|
|
107
153
|
}
|
|
108
154
|
|
|
109
155
|
async close(): Promise<void> {
|
|
@@ -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?');
|
package/src/wallet/confirm.ts
CHANGED
|
@@ -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']);
|
package/src/wallet/contract.ts
CHANGED
|
@@ -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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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).'),
|