@kaleidorg/mind 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/funnel.d.ts +19 -0
- package/dist/funnel.d.ts.map +1 -1
- package/dist/funnel.js +48 -10
- package/dist/funnel.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/kaleidoswap/contract.d.ts +3 -3
- package/dist/kaleidoswap/contract.d.ts.map +1 -1
- package/dist/kaleidoswap/contract.js +16 -4
- package/dist/kaleidoswap/contract.js.map +1 -1
- package/dist/knowledge/bitcoin-copilot.d.ts.map +1 -1
- package/dist/knowledge/bitcoin-copilot.js +102 -0
- package/dist/knowledge/bitcoin-copilot.js.map +1 -1
- package/dist/knowledge/btc-map.d.ts +14 -17
- package/dist/knowledge/btc-map.d.ts.map +1 -1
- package/dist/knowledge/btc-map.js +66 -266
- package/dist/knowledge/btc-map.js.map +1 -1
- package/dist/lsps1/contract.d.ts.map +1 -1
- package/dist/lsps1/contract.js +28 -10
- package/dist/lsps1/contract.js.map +1 -1
- package/dist/recipe/buy-asset-channel.d.ts +26 -0
- package/dist/recipe/buy-asset-channel.d.ts.map +1 -0
- package/dist/recipe/buy-asset-channel.js +112 -0
- package/dist/recipe/buy-asset-channel.js.map +1 -0
- package/dist/recipe/kaleidoswap-atomic.d.ts +26 -18
- package/dist/recipe/kaleidoswap-atomic.d.ts.map +1 -1
- package/dist/recipe/kaleidoswap-atomic.js +101 -63
- package/dist/recipe/kaleidoswap-atomic.js.map +1 -1
- package/dist/recipe/kaleidoswap-channel-order.d.ts +35 -0
- package/dist/recipe/kaleidoswap-channel-order.d.ts.map +1 -0
- package/dist/recipe/kaleidoswap-channel-order.js +493 -0
- package/dist/recipe/kaleidoswap-channel-order.js.map +1 -0
- package/dist/recipe/kaleidoswap-price.d.ts +21 -0
- package/dist/recipe/kaleidoswap-price.d.ts.map +1 -0
- package/dist/recipe/kaleidoswap-price.js +57 -0
- package/dist/recipe/kaleidoswap-price.js.map +1 -0
- package/dist/recipe/runner.d.ts +7 -1
- package/dist/recipe/runner.d.ts.map +1 -1
- package/dist/recipe/runner.js +115 -29
- package/dist/recipe/runner.js.map +1 -1
- package/dist/recipe/swap.d.ts +26 -1
- package/dist/recipe/swap.d.ts.map +1 -1
- package/dist/recipe/swap.js +108 -13
- package/dist/recipe/swap.js.map +1 -1
- package/dist/recipe/types.d.ts +25 -1
- package/dist/recipe/types.d.ts.map +1 -1
- package/dist/skills/registry.d.ts +33 -1
- package/dist/skills/registry.d.ts.map +1 -1
- package/dist/skills/registry.js +45 -1
- package/dist/skills/registry.js.map +1 -1
- package/package.json +1 -1
- package/skills/README.md +3 -0
- package/skills/kaleido-lsps/SKILL.md +101 -43
- package/skills/kaleido-trading/SKILL.md +81 -31
- package/skills/merchant-finder/SKILL.md +96 -66
- package/skills/rgb-lightning-node/SKILL.md +108 -0
- package/skills/wallet-assistant/SKILL.md +32 -21
- package/src/funnel.ts +66 -11
- package/src/index.ts +14 -2
- package/src/kaleidoswap/contract.test.ts +7 -2
- package/src/kaleidoswap/contract.ts +27 -5
- package/src/knowledge/bitcoin-copilot.ts +111 -0
- package/src/knowledge/btc-map.test.ts +53 -96
- package/src/knowledge/btc-map.ts +72 -287
- package/src/lsps1/contract.ts +32 -14
- package/src/recipe/buy-asset-channel.test.ts +148 -0
- package/src/recipe/buy-asset-channel.ts +118 -0
- package/src/recipe/kaleidoswap-atomic.test.ts +134 -61
- package/src/recipe/kaleidoswap-atomic.ts +112 -66
- package/src/recipe/kaleidoswap-channel-order.test.ts +333 -0
- package/src/recipe/kaleidoswap-channel-order.ts +548 -0
- package/src/recipe/kaleidoswap-price.ts +68 -0
- package/src/recipe/recipe.test.ts +61 -5
- package/src/recipe/runner.ts +128 -31
- package/src/recipe/swap.ts +109 -13
- package/src/recipe/types.ts +25 -1
- package/src/skills/registry.ts +52 -1
|
@@ -1,53 +1,92 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Built-in "
|
|
2
|
+
* Built-in "swap on KaleidoSwap" recipe — the real atomic-swap chain.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* user pays the maker's Lightning invoice, and the maker releases.
|
|
4
|
+
* A swap (especially the full maker + RLN atomic) is a 6-step, two-service flow
|
|
5
|
+
* no small model can plan reliably, so the recipe carries the plan. The model
|
|
6
|
+
* is used for natural-language understanding of the request (slot extraction).
|
|
8
7
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* "buy 1 usdt" (or "swap 10 usdt to btc")
|
|
9
|
+
* ↓ heuristic pre-filter (0 inf) decides to enter the reliable recipe branch
|
|
10
|
+
* ↓ 1 model inference (forced LLM slot extraction — the model parses intent)
|
|
11
|
+
* kaleidoswap_get_quote ← MAKER prices the swap (read-only)
|
|
12
|
+
* ↓ [ONE confirmation gate — shows the real quote numbers]
|
|
13
|
+
* kaleidoswap_atomic_init ← MAKER locks the swap → swapstring, payment_hash
|
|
14
|
+
* rln_get_node_info ← NODE read pubkey (= taker_pubkey)
|
|
15
|
+
* rln_whitelist_swap ← NODE accept the swapstring
|
|
16
|
+
* kaleidoswap_atomic_execute ← MAKER settle (final)
|
|
12
17
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* kaleidoswap_atomic_init 🔒 ← maker locks the swap, returns its invoice
|
|
19
|
-
* rln_pay_invoice 🔒 ← user pays the maker
|
|
20
|
-
* kaleidoswap_atomic_execute 🔒 ← (final) maker releases the asset
|
|
18
|
+
* `forceModelExtract` ensures the model is always consulted for slot parsing
|
|
19
|
+
* (1 inference) so natural language like "buy 1 usdt" is interpreted by the LLM.
|
|
20
|
+
* A safety fallback in the runner uses the deterministic extractor if the model
|
|
21
|
+
* returns incomplete slots. The execution sequence + single-confirm gate remain
|
|
22
|
+
* fully deterministic and reliable.
|
|
21
23
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
+
* Status is NOT polled here — settlement takes seconds-to-minutes and blocking
|
|
25
|
+
* the chat is bad UX. The recipe reports "submitted, settling"; the user (or a
|
|
26
|
+
* follow-up turn) calls `kaleidoswap_atomic_status` on demand.
|
|
27
|
+
*
|
|
28
|
+
* Confirmation: the single decision a user makes is "given this quote, proceed?"
|
|
29
|
+
* — so the recipe declares ONE `confirm(ctx)` summary, fired after the quote and
|
|
30
|
+
* before init. init/whitelist/execute then run as one approved unit. (The
|
|
31
|
+
* runner's recipe-level confirm path handles this; see recipe/runner.ts.)
|
|
24
32
|
*/
|
|
25
33
|
|
|
26
|
-
import type { Recipe } from './types.js';
|
|
34
|
+
import type { Recipe, RecipeContext } from './types.js';
|
|
27
35
|
import { extractSwap } from './swap.js';
|
|
28
36
|
|
|
29
|
-
|
|
30
|
-
|
|
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;
|
|
42
|
+
const SWAP_INTENT = (t: string) => {
|
|
43
|
+
// Explanatory / educational questions → route to RAG-backed agentic answer,
|
|
44
|
+
// not the deterministic spend chain.
|
|
45
|
+
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 (
|
|
48
|
+
/\b(buy|sell|get|purchase|acquire)\b/i.test(t) &&
|
|
49
|
+
ASSET.test(t) &&
|
|
50
|
+
// Exclude commerce / receive / LSPS1 channel-order phrasings that share
|
|
51
|
+
// 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;
|
|
54
|
+
return false;
|
|
55
|
+
};
|
|
31
56
|
|
|
32
|
-
|
|
33
|
-
|
|
57
|
+
interface QuoteResult {
|
|
58
|
+
rfq_id?: string;
|
|
59
|
+
from_asset?: { asset_id?: string; ticker?: string; amount?: number };
|
|
60
|
+
to_asset?: { asset_id?: string; ticker?: string; amount?: number };
|
|
61
|
+
from_amount_display?: string;
|
|
62
|
+
to_amount_display?: string;
|
|
63
|
+
fee_display?: string;
|
|
34
64
|
}
|
|
65
|
+
interface InitResult { swapstring?: string; payment_hash?: string }
|
|
66
|
+
interface NodeInfo { pubkey?: string }
|
|
35
67
|
|
|
36
68
|
export const kaleidoswapAtomicRecipe: Recipe = {
|
|
37
69
|
name: 'kaleidoswap-atomic',
|
|
38
70
|
description:
|
|
39
|
-
'
|
|
40
|
-
match: (t) =>
|
|
41
|
-
triggers: ['
|
|
71
|
+
'Swap between BTC and an RGB asset on KaleidoSwap: quote, confirm once, then init (maker) → whitelist (node) → execute (maker).',
|
|
72
|
+
match: (t) => SWAP_INTENT(t),
|
|
73
|
+
triggers: ['swap', 'exchange', 'convert', 'trade', 'buy', 'sell'],
|
|
42
74
|
slots: [
|
|
43
75
|
{ name: 'from_asset', type: 'string', description: 'Asset to spend (BTC / USDT / XAUT)', required: true },
|
|
44
76
|
{ name: 'to_asset', type: 'string', description: 'Asset to receive (BTC / USDT / XAUT)', required: true },
|
|
45
|
-
{ name: 'amount', type: 'number', description: '
|
|
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)" },
|
|
46
79
|
],
|
|
80
|
+
// Keep the fast `extract` for the Funnel's cheap pre-filter (so "buy 1 usdt"
|
|
81
|
+
// reliably enters the recipe branch instead of falling to free agentic).
|
|
82
|
+
// `forceModelExtract` makes runRecipe ignore the deterministic result and
|
|
83
|
+
// always ask the model to produce the actual slots used for execution.
|
|
47
84
|
extract: extractSwap,
|
|
85
|
+
forceModelExtract: true,
|
|
48
86
|
confident: (s) => !!s.from_asset && !!s.to_asset && !!s.amount,
|
|
49
87
|
steps: [
|
|
50
|
-
// 1.
|
|
88
|
+
// 1. MAKER quotes the swap (read-only). Returns rfq_id + full asset specs
|
|
89
|
+
// (echoes the rgb: asset ids and maker-unit amounts) + *_display strings.
|
|
51
90
|
{
|
|
52
91
|
tool: 'kaleidoswap_get_quote',
|
|
53
92
|
as: 'quote',
|
|
@@ -55,63 +94,70 @@ export const kaleidoswapAtomicRecipe: Recipe = {
|
|
|
55
94
|
from_asset: ctx.slots.from_asset,
|
|
56
95
|
to_asset: ctx.slots.to_asset,
|
|
57
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',
|
|
58
100
|
}),
|
|
59
101
|
},
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
as: 'receive_rgb',
|
|
64
|
-
args: (ctx) => {
|
|
65
|
-
const q = ctx.results.quote as { receive_amount?: number } | undefined;
|
|
66
|
-
return { asset: ctx.slots.to_asset, amount: q?.receive_amount };
|
|
67
|
-
},
|
|
68
|
-
skipIf: (ctx) => isBtc(ctx.slots.to_asset),
|
|
69
|
-
},
|
|
70
|
-
// 2b. User's node creates an LN receive invoice (when to_asset is BTC).
|
|
71
|
-
{
|
|
72
|
-
tool: 'rln_create_ln_invoice',
|
|
73
|
-
as: 'receive_ln',
|
|
74
|
-
args: (ctx) => {
|
|
75
|
-
const q = ctx.results.quote as { receive_amount?: number } | undefined;
|
|
76
|
-
return { amount_sats: q?.receive_amount };
|
|
77
|
-
},
|
|
78
|
-
skipIf: (ctx) => !isBtc(ctx.slots.to_asset),
|
|
79
|
-
},
|
|
80
|
-
// 3. Maker locks the swap. Returns { atomic_id, maker_invoice }. Spend-gated.
|
|
102
|
+
// 2. MAKER locks the swap. SwapRequest is flat (asset ids + maker-unit
|
|
103
|
+
// amounts) — sourced straight from the quote result, no re-scaling.
|
|
104
|
+
// First spend step → the recipe-level confirm gate fires just before it.
|
|
81
105
|
{
|
|
82
106
|
tool: 'kaleidoswap_atomic_init',
|
|
83
|
-
as: '
|
|
107
|
+
as: 'init',
|
|
84
108
|
args: (ctx) => {
|
|
85
|
-
const
|
|
86
|
-
const ln = ctx.results.receive_ln as { invoice?: string } | undefined;
|
|
87
|
-
const q = ctx.results.quote as { quote_id?: string } | undefined;
|
|
109
|
+
const q = ctx.results.quote as QuoteResult | undefined;
|
|
88
110
|
return {
|
|
89
|
-
|
|
90
|
-
|
|
111
|
+
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,
|
|
91
116
|
};
|
|
92
117
|
},
|
|
93
118
|
},
|
|
94
|
-
//
|
|
119
|
+
// 3. NODE: read our pubkey — the maker needs it as taker_pubkey for execute.
|
|
95
120
|
{
|
|
96
|
-
tool: '
|
|
97
|
-
as: '
|
|
121
|
+
tool: 'rln_get_node_info',
|
|
122
|
+
as: 'node',
|
|
123
|
+
args: () => ({}),
|
|
124
|
+
},
|
|
125
|
+
// 4. NODE: whitelist the maker's swapstring (accept the swap). Ungated —
|
|
126
|
+
// covered by the single confirm above.
|
|
127
|
+
{
|
|
128
|
+
tool: 'rln_whitelist_swap',
|
|
129
|
+
as: 'whitelist',
|
|
98
130
|
args: (ctx) => {
|
|
99
|
-
const
|
|
100
|
-
return {
|
|
131
|
+
const init = ctx.results.init as InitResult | undefined;
|
|
132
|
+
return { swapstring: init?.swapstring };
|
|
101
133
|
},
|
|
102
134
|
},
|
|
103
135
|
],
|
|
104
|
-
// 5.
|
|
136
|
+
// 5. MAKER settles the swap. Needs swapstring + taker_pubkey + payment_hash.
|
|
105
137
|
final: {
|
|
106
138
|
tool: 'kaleidoswap_atomic_execute',
|
|
107
139
|
args: (ctx) => {
|
|
108
|
-
const
|
|
109
|
-
|
|
140
|
+
const init = ctx.results.init as InitResult | undefined;
|
|
141
|
+
const node = ctx.results.node as NodeInfo | undefined;
|
|
142
|
+
return {
|
|
143
|
+
swapstring: init?.swapstring,
|
|
144
|
+
taker_pubkey: node?.pubkey,
|
|
145
|
+
payment_hash: init?.payment_hash,
|
|
146
|
+
};
|
|
110
147
|
},
|
|
111
148
|
},
|
|
149
|
+
// ONE confirmation, fired after the quote / before init, with the real numbers.
|
|
150
|
+
confirm: (ctx: RecipeContext) => {
|
|
151
|
+
const q = ctx.results.quote as QuoteResult | undefined;
|
|
152
|
+
const from = q?.from_amount_display ?? `${ctx.slots.amount} ${ctx.slots.from_asset}`;
|
|
153
|
+
const to = q?.to_amount_display ?? String(ctx.slots.to_asset);
|
|
154
|
+
const fee = q?.fee_display ? ` · fee ${q.fee_display}` : '';
|
|
155
|
+
return `Swap ${from} → ${to}${fee} on KaleidoSwap. Proceed?`;
|
|
156
|
+
},
|
|
112
157
|
summary: (ctx) => {
|
|
113
|
-
const q = ctx.results.quote as
|
|
114
|
-
const
|
|
115
|
-
|
|
158
|
+
const q = ctx.results.quote as QuoteResult | undefined;
|
|
159
|
+
const from = q?.from_amount_display ?? `${ctx.slots.amount} ${ctx.slots.from_asset}`;
|
|
160
|
+
const to = q?.to_amount_display ?? String(ctx.slots.to_asset);
|
|
161
|
+
return `Swap submitted: ${from} → ${to}. Settling now — ask me to check the status.`;
|
|
116
162
|
},
|
|
117
163
|
};
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ToolRegistry } from '../tools/registry.js';
|
|
3
|
+
import { InProcessToolSource } from '../tools/in-process.js';
|
|
4
|
+
import type { LLMProvider } from '../providers/types.js';
|
|
5
|
+
import { runRecipe } from './runner.js';
|
|
6
|
+
import {
|
|
7
|
+
kaleidoswapChannelOrderRecipe,
|
|
8
|
+
extractChannelOrder,
|
|
9
|
+
} from './kaleidoswap-channel-order.js';
|
|
10
|
+
|
|
11
|
+
const refusingProvider: LLMProvider = {
|
|
12
|
+
name: 'refusing',
|
|
13
|
+
runTurn: async () => {
|
|
14
|
+
throw new Error('provider should NOT be called when slots are pre-supplied');
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Stubs that match the real LSPS1 + RLN response shapes. */
|
|
19
|
+
function buildStubs(captured: { name: string; args: any }[]) {
|
|
20
|
+
const tool = (name: string, response: any, spend = false) => ({
|
|
21
|
+
name,
|
|
22
|
+
description: '',
|
|
23
|
+
parameters: { type: 'object', properties: {} },
|
|
24
|
+
requiresConfirmation: spend,
|
|
25
|
+
handler: async (a: any) => {
|
|
26
|
+
captured.push({ name, args: a });
|
|
27
|
+
return typeof response === 'function' ? response(a) : response;
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
return new ToolRegistry([
|
|
31
|
+
new InProcessToolSource('lsps1', [
|
|
32
|
+
tool('lsp_get_info', {
|
|
33
|
+
lsp_connection_url: '03abc@1.2.3.4:9735',
|
|
34
|
+
options: {
|
|
35
|
+
min_initial_lsp_balance_sat: 50_000,
|
|
36
|
+
max_initial_lsp_balance_sat: 10_000_000,
|
|
37
|
+
max_channel_expiry_blocks: 20160,
|
|
38
|
+
},
|
|
39
|
+
assets: [],
|
|
40
|
+
}),
|
|
41
|
+
tool('lsp_estimate_fees', {
|
|
42
|
+
setup_fee: 100,
|
|
43
|
+
capacity_fee: 250,
|
|
44
|
+
duration_fee: 50,
|
|
45
|
+
total_fee: 400,
|
|
46
|
+
}),
|
|
47
|
+
tool('lsp_create_order', {
|
|
48
|
+
order_id: 'ord-xyz',
|
|
49
|
+
access_token: 'tok-1',
|
|
50
|
+
order_state: 'CREATED',
|
|
51
|
+
payment: {
|
|
52
|
+
bolt11: {
|
|
53
|
+
invoice: 'lnbc500400n1lspsorder',
|
|
54
|
+
order_total_sat: 500_400,
|
|
55
|
+
fee_total_sat: 400,
|
|
56
|
+
state: 'EXPECT_PAYMENT',
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
}, /* spend */ true),
|
|
60
|
+
tool('lsp_get_order', { order_state: 'COMPLETED', channel: { channel_id: 'ch-1' } }),
|
|
61
|
+
]),
|
|
62
|
+
new InProcessToolSource('rln', [
|
|
63
|
+
tool('rln_get_node_info', { pubkey: '03c31dae' }),
|
|
64
|
+
tool('rln_pay_invoice', { status: 'SUCCESS', payment_hash: 'h' }, /* spend */ true),
|
|
65
|
+
// Stateful: first call (before snapshot) shows an existing channel;
|
|
66
|
+
// second call (after) shows the existing one PLUS the new one, so the
|
|
67
|
+
// diff identifies 'newch' as freshly opened.
|
|
68
|
+
{
|
|
69
|
+
name: 'rln_list_channels',
|
|
70
|
+
description: '',
|
|
71
|
+
parameters: { type: 'object', properties: {} },
|
|
72
|
+
handler: (() => {
|
|
73
|
+
let calls = 0;
|
|
74
|
+
return async (a: any) => {
|
|
75
|
+
captured.push({ name: 'rln_list_channels', args: a });
|
|
76
|
+
calls += 1;
|
|
77
|
+
const existing = { channel_id: 'oldch', capacity_sat: 2_000_000, inbound_sat: 1_800_000, ready: true, status: 'Opened' };
|
|
78
|
+
if (calls === 1) return { channels: [existing], count: 1 };
|
|
79
|
+
return {
|
|
80
|
+
channels: [
|
|
81
|
+
existing,
|
|
82
|
+
{ channel_id: 'newch', capacity_sat: 500_000, inbound_sat: 495_000, outbound_sat: 0, ready: false, status: 'opening' },
|
|
83
|
+
],
|
|
84
|
+
count: 2,
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
})(),
|
|
88
|
+
},
|
|
89
|
+
]),
|
|
90
|
+
]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe('extractChannelOrder — deterministic prefilter', () => {
|
|
94
|
+
it('catches single-amount "buy a 500k inbound channel"', () => {
|
|
95
|
+
const r = extractChannelOrder('buy a 500k inbound channel');
|
|
96
|
+
expect(r).toMatchObject({ lsp_balance_sat: 500_000 });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('catches "I need 200000 sats of inbound liquidity"', () => {
|
|
100
|
+
const r = extractChannelOrder('I need 200000 sats of inbound liquidity');
|
|
101
|
+
expect(r).toMatchObject({ lsp_balance_sat: 200_000 });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('catches "1M inbound for 30 days"', () => {
|
|
105
|
+
const r = extractChannelOrder('open a channel from the LSP, 1M inbound for 30 days');
|
|
106
|
+
expect(r).toMatchObject({ lsp_balance_sat: 1_000_000, channel_expiry_blocks: 30 * 144 });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('catches dual-amount with side keywords: "20000 on my side 80000 on lsp"', () => {
|
|
110
|
+
const r = extractChannelOrder('buy a channel for me 20000 on my side 80000 on lsp');
|
|
111
|
+
expect(r).toMatchObject({ client_balance_sat: 20_000, lsp_balance_sat: 80_000 });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('catches "client_balance 5000 lsp_balance 100000"', () => {
|
|
115
|
+
const r = extractChannelOrder('open channel client_balance 5000 lsp_balance 100000');
|
|
116
|
+
expect(r).toMatchObject({ client_balance_sat: 5_000, lsp_balance_sat: 100_000 });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('catches "with 10k push" + "500k inbound"', () => {
|
|
120
|
+
const r = extractChannelOrder('buy a channel with 10k push and 500k inbound');
|
|
121
|
+
expect(r).toMatchObject({ client_balance_sat: 10_000, lsp_balance_sat: 500_000 });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('catches "Nk sats my side, Mk sats lsp side" (unit between number and side)', () => {
|
|
125
|
+
const r = extractChannelOrder('buy a channel: 100k sats my side, 5M sats lsp side');
|
|
126
|
+
expect(r).toMatchObject({ client_balance_sat: 100_000, lsp_balance_sat: 5_000_000 });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('catches an asset channel: ticker + asset amount', () => {
|
|
130
|
+
const r = extractChannelOrder('buy a USDT channel: 5M sats lsp side, 100 USDT inbound');
|
|
131
|
+
expect(r).toMatchObject({
|
|
132
|
+
lsp_balance_sat: 5_000_000,
|
|
133
|
+
asset_ticker: 'USDT',
|
|
134
|
+
lsp_asset_amount: 100,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('catches dual-side asset: 100 USDT inbound + 20 USDT pushed to my side', () => {
|
|
139
|
+
const r = extractChannelOrder(
|
|
140
|
+
'buy a USDT channel: 5M sats lsp side, 100k sats my side, 100 USDT inbound, 20 USDT pushed to my side',
|
|
141
|
+
);
|
|
142
|
+
expect(r).toMatchObject({
|
|
143
|
+
lsp_balance_sat: 5_000_000,
|
|
144
|
+
client_balance_sat: 100_000,
|
|
145
|
+
asset_ticker: 'USDT',
|
|
146
|
+
lsp_asset_amount: 100,
|
|
147
|
+
client_asset_amount: 20,
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('returns null when no concrete fields extractable (intent-only)', () => {
|
|
152
|
+
// The Funnel still fires the recipe via forceModelExtract + match(),
|
|
153
|
+
// so the LLM does the actual extraction. The extractor only contributes
|
|
154
|
+
// when it can pull a real value out.
|
|
155
|
+
expect(extractChannelOrder('I want a channel order')).toBeNull();
|
|
156
|
+
expect(extractChannelOrder('buy channel from kaleid')).toBeNull();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('returns null on ambiguous dual-number phrasing (no side keywords)', () => {
|
|
160
|
+
// "buy channel 20000 80000" — could be either. Let the LLM decide via the
|
|
161
|
+
// recipe's forceModelExtract path.
|
|
162
|
+
expect(extractChannelOrder('buy channel 20000 80000')).toBeNull();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('ignores unrelated text', () => {
|
|
166
|
+
expect(extractChannelOrder('what is my balance')).toBeNull();
|
|
167
|
+
expect(extractChannelOrder('swap 1000 sats to usdt')).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('kaleidoswapChannelOrderRecipe — selection', () => {
|
|
172
|
+
it('triggers on channel-order phrasings', () => {
|
|
173
|
+
const m = kaleidoswapChannelOrderRecipe.match!;
|
|
174
|
+
expect(m('buy a 500k inbound channel')).toBe(true);
|
|
175
|
+
expect(m("I can't receive 1M sats")).toBe(true);
|
|
176
|
+
expect(m('open a channel from the LSP, 200k inbound')).toBe(true);
|
|
177
|
+
expect(m('order a lsps1 channel')).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('does NOT trigger on swap / balance / generic Lightning questions', () => {
|
|
181
|
+
const m = kaleidoswapChannelOrderRecipe.match!;
|
|
182
|
+
expect(m('what is my balance')).toBe(false);
|
|
183
|
+
expect(m('swap 1000 sats to usdt')).toBe(false);
|
|
184
|
+
expect(m('what is a Lightning channel?')).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('does NOT trigger on explanatory questions about channels', () => {
|
|
188
|
+
const m = kaleidoswapChannelOrderRecipe.match!;
|
|
189
|
+
// These should route to RAG-backed knowledge answering, not the recipe.
|
|
190
|
+
expect(m('why do I need to buy a channel before swapping?')).toBe(false);
|
|
191
|
+
expect(m('how does an inbound channel work?')).toBe(false);
|
|
192
|
+
expect(m('what is inbound liquidity?')).toBe(false);
|
|
193
|
+
expect(m('do I need a channel to receive lightning payments?')).toBe(false);
|
|
194
|
+
expect(m('can I receive without an inbound channel?')).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('kaleidoswapChannelOrderRecipe — full chain', () => {
|
|
199
|
+
it('runs get_info → estimate_fees → get_node_info → create_order → pay_invoice (one inference)', async () => {
|
|
200
|
+
const captured: { name: string; args: any }[] = [];
|
|
201
|
+
const tools = buildStubs(captured);
|
|
202
|
+
|
|
203
|
+
const res = await runRecipe(
|
|
204
|
+
kaleidoswapChannelOrderRecipe,
|
|
205
|
+
'buy a 500000-sat inbound channel',
|
|
206
|
+
{
|
|
207
|
+
provider: refusingProvider,
|
|
208
|
+
tools,
|
|
209
|
+
onConfirm: async () => ({ approved: true }),
|
|
210
|
+
slots: { lsp_balance_sat: 500_000 }, // simulate a successful prior extraction
|
|
211
|
+
},
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
expect(res.status).toBe('done');
|
|
215
|
+
expect(res.inferences).toBe(0);
|
|
216
|
+
expect(captured.map((c) => c.name)).toEqual([
|
|
217
|
+
'lsp_get_info',
|
|
218
|
+
'lsp_estimate_fees',
|
|
219
|
+
'rln_get_node_info',
|
|
220
|
+
'rln_list_channels', // before-snapshot
|
|
221
|
+
'lsp_create_order',
|
|
222
|
+
'rln_pay_invoice',
|
|
223
|
+
'rln_list_channels', // after — verification (read-only final)
|
|
224
|
+
]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('verification diff identifies the NEW channel (not pre-existing ones)', async () => {
|
|
228
|
+
const captured: { name: string; args: any }[] = [];
|
|
229
|
+
const tools = buildStubs(captured);
|
|
230
|
+
const res = await runRecipe(kaleidoswapChannelOrderRecipe, 'buy a 500000-sat inbound channel', {
|
|
231
|
+
provider: refusingProvider,
|
|
232
|
+
tools,
|
|
233
|
+
onConfirm: async () => ({ approved: true }),
|
|
234
|
+
slots: { lsp_balance_sat: 500_000 },
|
|
235
|
+
});
|
|
236
|
+
// The summary should reference the NEW channel (500k), not the old 2M one.
|
|
237
|
+
expect(res.text).toMatch(/New channel/);
|
|
238
|
+
expect(res.text).toMatch(/500,000-sat/);
|
|
239
|
+
expect(res.text).not.toMatch(/2,000,000/);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("threads node.pubkey into lsp_create_order's client_pubkey", async () => {
|
|
243
|
+
const captured: { name: string; args: any }[] = [];
|
|
244
|
+
const tools = buildStubs(captured);
|
|
245
|
+
await runRecipe(kaleidoswapChannelOrderRecipe, 'buy a 500000-sat inbound channel', {
|
|
246
|
+
provider: refusingProvider,
|
|
247
|
+
tools,
|
|
248
|
+
onConfirm: async () => ({ approved: true }),
|
|
249
|
+
slots: { lsp_balance_sat: 500_000 },
|
|
250
|
+
});
|
|
251
|
+
const order = captured.find((c) => c.name === 'lsp_create_order')!;
|
|
252
|
+
expect(order.args).toMatchObject({
|
|
253
|
+
client_pubkey: '03c31dae',
|
|
254
|
+
lsp_balance_sat: 500_000,
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("threads order.payment.bolt11.invoice into rln_pay_invoice", async () => {
|
|
259
|
+
const captured: { name: string; args: any }[] = [];
|
|
260
|
+
const tools = buildStubs(captured);
|
|
261
|
+
await runRecipe(kaleidoswapChannelOrderRecipe, 'buy a 500000-sat inbound channel', {
|
|
262
|
+
provider: refusingProvider,
|
|
263
|
+
tools,
|
|
264
|
+
onConfirm: async () => ({ approved: true }),
|
|
265
|
+
slots: { lsp_balance_sat: 500_000 },
|
|
266
|
+
});
|
|
267
|
+
const pay = captured.find((c) => c.name === 'rln_pay_invoice')!;
|
|
268
|
+
expect(pay.args).toEqual({ invoice: 'lnbc500400n1lspsorder' });
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe('kaleidoswapChannelOrderRecipe — missing info', () => {
|
|
273
|
+
it('returns status:needs-info (not error) when lsp_balance_sat is missing', async () => {
|
|
274
|
+
// LLM emits no slots → confident() fails → recipe asks the user instead
|
|
275
|
+
// of running the chain with bad data.
|
|
276
|
+
const emptyExtractProvider: LLMProvider = {
|
|
277
|
+
name: 'empty',
|
|
278
|
+
runTurn: async () => ({ text: '', rawContent: '', toolCalls: [] }),
|
|
279
|
+
};
|
|
280
|
+
const captured: { name: string; args: any }[] = [];
|
|
281
|
+
const tools = buildStubs(captured);
|
|
282
|
+
|
|
283
|
+
const res = await runRecipe(kaleidoswapChannelOrderRecipe, 'buy a channel from kaleid', {
|
|
284
|
+
provider: emptyExtractProvider,
|
|
285
|
+
tools,
|
|
286
|
+
onConfirm: async () => ({ approved: true }),
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
expect(res.status).toBe('needs-info');
|
|
290
|
+
expect(res.text).toMatch(/lsp_balance_sat|specify/i);
|
|
291
|
+
// No tools should have been called.
|
|
292
|
+
expect(captured.length).toBe(0);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe('kaleidoswapChannelOrderRecipe — single confirmation', () => {
|
|
297
|
+
it('fires ONE gate (before create_order), showing the fee summary', async () => {
|
|
298
|
+
const captured: { name: string; args: any }[] = [];
|
|
299
|
+
const tools = buildStubs(captured);
|
|
300
|
+
const gates: string[] = [];
|
|
301
|
+
|
|
302
|
+
const res = await runRecipe(kaleidoswapChannelOrderRecipe, 'buy a 500000-sat inbound channel', {
|
|
303
|
+
provider: refusingProvider,
|
|
304
|
+
tools,
|
|
305
|
+
onConfirm: async (call) => {
|
|
306
|
+
gates.push(call.name);
|
|
307
|
+
return { approved: true };
|
|
308
|
+
},
|
|
309
|
+
slots: { lsp_balance_sat: 500_000 },
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
expect(res.status).toBe('done');
|
|
313
|
+
// Exactly one gate fired, at the first spend step (create_order). Pay step
|
|
314
|
+
// ran without a second prompt — the single recipe-confirm covered it.
|
|
315
|
+
expect(gates).toEqual(['lsp_create_order']);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('cancels the entire chain on decline (no order, no payment)', async () => {
|
|
319
|
+
const captured: { name: string; args: any }[] = [];
|
|
320
|
+
const tools = buildStubs(captured);
|
|
321
|
+
|
|
322
|
+
const res = await runRecipe(kaleidoswapChannelOrderRecipe, 'buy a 500000-sat inbound channel', {
|
|
323
|
+
provider: refusingProvider,
|
|
324
|
+
tools,
|
|
325
|
+
onConfirm: async () => ({ approved: false, reason: 'too expensive' }),
|
|
326
|
+
slots: { lsp_balance_sat: 500_000 },
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
expect(res.status).not.toBe('done');
|
|
330
|
+
expect(captured.some((c) => c.name === 'lsp_create_order')).toBe(false);
|
|
331
|
+
expect(captured.some((c) => c.name === 'rln_pay_invoice')).toBe(false);
|
|
332
|
+
});
|
|
333
|
+
});
|