@kaleidorg/mind 0.6.0 → 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.
Files changed (108) 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 +37 -16
  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.ts +59 -8
  88. package/src/index.ts +51 -1
  89. package/src/kaleidoswap/contract.ts +1 -1
  90. package/src/knowledge/bitcoin-copilot.ts +94 -0
  91. package/src/providers/types.ts +18 -0
  92. package/src/qvac/provider.ts +25 -1
  93. package/src/qvac/stream.test.ts +11 -0
  94. package/src/qvac/stream.ts +16 -0
  95. package/src/recipe/flashnet-swap.test.ts +114 -0
  96. package/src/recipe/flashnet-swap.ts +266 -0
  97. package/src/recipe/kaleidoswap-atomic.test.ts +21 -0
  98. package/src/recipe/kaleidoswap-atomic.ts +34 -16
  99. package/src/recipe/kaleidoswap-channel-order.test.ts +38 -0
  100. package/src/recipe/kaleidoswap-channel-order.ts +27 -9
  101. package/src/recipe/kaleidoswap-price.ts +7 -1
  102. package/src/recipe/recipe.test.ts +5 -0
  103. package/src/recipe/runner.ts +5 -3
  104. package/src/recipe/swap.ts +16 -1
  105. package/src/wallet/confirm.test.ts +8 -0
  106. package/src/wallet/confirm.ts +1 -0
  107. package/src/wallet/contract.test.ts +10 -0
  108. package/src/wallet/contract.ts +26 -4
@@ -0,0 +1,114 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { flashnetSwapRecipe } from './flashnet-swap.js';
3
+
4
+ describe('flashnetSwapRecipe — selection', () => {
5
+ it('claims swap/buy/sell phrasings with a Flashnet/Spark cue', () => {
6
+ expect(flashnetSwapRecipe.match!('swap 10000 sats with usdb')).toBe(true);
7
+ expect(flashnetSwapRecipe.match!('swap 5000 sats to usdb on flashnet')).toBe(true);
8
+ expect(flashnetSwapRecipe.match!('buy usdb with 1000 sats')).toBe(true);
9
+ expect(flashnetSwapRecipe.match!('exchange btc for usdb on spark')).toBe(true);
10
+ expect(flashnetSwapRecipe.match!('sell 5 usdb')).toBe(true);
11
+ });
12
+
13
+ it('does NOT claim RGB swaps (USDT / XAUT belong to kaleidoswap-atomic)', () => {
14
+ expect(flashnetSwapRecipe.match!('swap 100k sats to usdt')).toBe(false);
15
+ expect(flashnetSwapRecipe.match!('convert btc to xaut')).toBe(false);
16
+ expect(flashnetSwapRecipe.match!('sell 10 usdt for sats')).toBe(false);
17
+ });
18
+
19
+ it('does NOT claim a bare swap without a Flashnet/Spark cue', () => {
20
+ expect(flashnetSwapRecipe.match!('swap 100000 sats')).toBe(false);
21
+ expect(flashnetSwapRecipe.match!('exchange some bitcoin')).toBe(false);
22
+ });
23
+
24
+ it('does NOT claim commerce / receive / channel phrasings', () => {
25
+ expect(flashnetSwapRecipe.match!('buy a gift card with btc')).toBe(false);
26
+ expect(flashnetSwapRecipe.match!('create an invoice for 1000 sats')).toBe(false);
27
+ expect(flashnetSwapRecipe.match!('buy a usdb channel on flashnet')).toBe(false);
28
+ });
29
+
30
+ it('does NOT claim educational questions', () => {
31
+ expect(flashnetSwapRecipe.match!('how does a flashnet swap work?')).toBe(false);
32
+ expect(flashnetSwapRecipe.match!('what is usdb?')).toBe(false);
33
+ expect(flashnetSwapRecipe.match!('explain flashnet')).toBe(false);
34
+ });
35
+ });
36
+
37
+ describe('flashnetSwapRecipe — shape', () => {
38
+ it('extracts the 4 swap slots', () => {
39
+ expect(flashnetSwapRecipe.slots.map((s) => s.name).sort()).toEqual([
40
+ 'amount', 'amount_side', 'from_asset', 'to_asset',
41
+ ]);
42
+ const required = flashnetSwapRecipe.slots.filter((s) => s.required).map((s) => s.name).sort();
43
+ expect(required).toEqual(['from_asset', 'to_asset']);
44
+ });
45
+
46
+ it('forces the model to do slot extraction (not the deterministic regex)', () => {
47
+ expect(flashnetSwapRecipe.forceModelExtract).toBe(true);
48
+ });
49
+
50
+ it('runs the canonical 2-step + final chain (list_pools → simulate → execute)', () => {
51
+ expect(flashnetSwapRecipe.steps.map((s) => s.tool)).toEqual([
52
+ 'flashnet_list_pools',
53
+ 'flashnet_simulate_swap',
54
+ ]);
55
+ expect(flashnetSwapRecipe.final.tool).toBe('flashnet_execute_swap');
56
+ });
57
+
58
+ it('confident only when both assets + amount are extracted', () => {
59
+ expect(flashnetSwapRecipe.confident!({})).toBe(false);
60
+ expect(flashnetSwapRecipe.confident!({ from_asset: 'BTC' })).toBe(false);
61
+ expect(flashnetSwapRecipe.confident!({ from_asset: 'BTC', to_asset: 'USDB' })).toBeFalsy();
62
+ expect(flashnetSwapRecipe.confident!({ from_asset: 'BTC', to_asset: 'USDB', amount: 1000 })).toBe(true);
63
+ });
64
+
65
+ it('has a recipe-level confirm gate (single approval covers the chain)', () => {
66
+ expect(typeof flashnetSwapRecipe.confirm).toBe('function');
67
+ });
68
+ });
69
+
70
+ describe('flashnetSwapRecipe — direction is LITERAL', () => {
71
+ it('simulate step uses from_asset as asset_in (the spent leg) by construction', () => {
72
+ const simStep = flashnetSwapRecipe.steps.find((s) => s.tool === 'flashnet_simulate_swap')!;
73
+ // Build a minimal ctx as runRecipe would: pools result with a BTC/USDB
74
+ // pool, slots saying from=BTC, to=USDB, amount=10000. The args function
75
+ // must put BTC on asset_in and amount=10000 on amount_in — the previous
76
+ // model-driven inversion bug becomes impossible.
77
+ const ctx: any = {
78
+ slots: { from_asset: 'BTC', to_asset: 'USDB', amount: 10000 },
79
+ results: {
80
+ pools: {
81
+ pools: [{
82
+ pool_id: 'pool-xyz',
83
+ asset_a_address: '0e6354aaaa', asset_a_symbol: 'USDB',
84
+ asset_b_address: '020202bbbb', asset_b_symbol: 'BTC',
85
+ }],
86
+ },
87
+ },
88
+ };
89
+ const args = simStep.args(ctx) as any;
90
+ expect(args.pool_id).toBe('pool-xyz');
91
+ // The pool already stores BTC's address (the SDK constant `020202…`);
92
+ // the recipe passes it through directly so the adapter doesn't have to
93
+ // re-resolve the "BTC" ticker.
94
+ expect(args.asset_in_address).toBe('020202bbbb'); // BTC, from the pool
95
+ expect(args.asset_out_address).toBe('0e6354aaaa'); // USDB by symbol
96
+ expect(args.amount_in).toBe('10000');
97
+ });
98
+
99
+ it('execute step computes min_amount_out from the simulated value (not the raw value)', () => {
100
+ const ctx: any = {
101
+ slots: { from_asset: 'BTC', to_asset: 'USDB', amount: 10000 },
102
+ results: {
103
+ pools: { pools: [{ pool_id: 'p', asset_a_address: 'a', asset_a_symbol: 'USDB', asset_b_address: 'b', asset_b_symbol: 'BTC' }] },
104
+ sim: { amount_out: '1472', execution_price: '0.1472' },
105
+ },
106
+ };
107
+ const args = flashnetSwapRecipe.final.args(ctx) as any;
108
+ // Default 50 bps = 0.5% slippage tolerance → floor(1472 * 0.995) = 1464.
109
+ expect(args.min_amount_out).toBe('1464');
110
+ expect(args.max_slippage_bps).toBe(50);
111
+ // BTC (from_asset) → pool's BTC-side address `b`.
112
+ expect(args.asset_in_address).toBe('b');
113
+ });
114
+ });
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Built-in "swap on Flashnet" recipe — Spark-native AMM, deterministic chain.
3
+ *
4
+ * A Flashnet swap on a small model was fragile when left to the agentic loop:
5
+ * the model had to discover the pool, get the asset addresses right, pick the
6
+ * correct simulate DIRECTION (asset_in = what the user spends), compute
7
+ * `min_amount_out` from slippage tolerance, and then thread all of that into
8
+ * execute. With Qwen3-1.7B that often produced an inverted simulate (token →
9
+ * BTC instead of BTC → token), and on the follow-up "yes" the skill context
10
+ * was lost and the model called `flashnet_simulate_swap({})` with no args.
11
+ *
12
+ * "swap 10000 sats with usdb"
13
+ * ↓ 1 model inference (slot extraction: from/to/amount/amount_side)
14
+ * flashnet_list_pools ← discover the right pool deterministically
15
+ * ↓
16
+ * flashnet_simulate_swap ← quote (read-only)
17
+ * ↓ [ONE confirmation gate — shows the real quote in plain English]
18
+ * flashnet_execute_swap ← settle (the spend)
19
+ *
20
+ * The whole chain runs with 1 LLM inference total (slot extraction), same
21
+ * pattern as `kaleidoswapAtomicRecipe`. The host's `flashnet_list_pools` is
22
+ * side-agnostic and labels symbols, so picking the right pool from the
23
+ * extracted asset pair is cheap and reliable.
24
+ *
25
+ * Slippage: default `max_slippage_bps = 50` (0.5%). The runner computes
26
+ * `min_amount_out = floor(amount_out × (1 − bps/10000))` from the simulate
27
+ * result before execute — never trusts the simulated value as-is.
28
+ *
29
+ * Asset taxonomy guard: matches ONLY when a Flashnet cue is present
30
+ * (flashnet/usdb/spark), so RGB swaps (USDT/XAUT) still go to the
31
+ * KaleidoSwap atomic recipe. The two recipes are disjoint.
32
+ */
33
+
34
+ import type { Recipe, RecipeContext } from './types.js';
35
+ import { extractSwap } from './swap.js';
36
+
37
+ // Flashnet cue — same set the kaleidoswap-atomic recipe defers ON, so the
38
+ // two are disjoint by construction.
39
+ const FLASHNET_CUE = /\b(flashnet|usdb|spark)\b/i;
40
+ // Spark-token tickers that imply a Flashnet swap even without "flashnet" in
41
+ // the text. Keep this list small and Spark-specific — never includes RGB
42
+ // assets (USDT, XAUT, gold).
43
+ const SPARK_TOKEN = /\b(usdb)\b/i;
44
+ // Generic swap-intent verbs that pair with an asset name. "buy/sell/get" join
45
+ // the swap intent only when an ASSET is named (so "buy a gift card" doesn't
46
+ // route here — that's bitrefill territory).
47
+ const SWAP_VERB = /\b(swap|exchange|convert|trade)\b/i;
48
+ const BUY_VERB = /\b(buy|sell|get|purchase|acquire)\b/i;
49
+ const ASSET = /\b(btc|bitcoin|sats?|usdb)\b/i;
50
+ const NON_SWAP = /\b(gift\s?card|top-?up|esim|voucher|invoice|address|channel|inbound|liquidity|lsps?\b)\b/i;
51
+
52
+ const FLASHNET_INTENT = (t: string) => {
53
+ // Educational questions go to the agentic skill (which can call list_pools
54
+ // for an honest read).
55
+ 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;
56
+ // Must have a Flashnet/Spark cue OR name a Spark-native token like USDB,
57
+ // so this never grabs an RGB swap (those go to kaleidoswap-atomic).
58
+ if (!FLASHNET_CUE.test(t) && !SPARK_TOKEN.test(t)) return false;
59
+ if (NON_SWAP.test(t)) return false;
60
+ if (SWAP_VERB.test(t)) return true;
61
+ if (BUY_VERB.test(t) && ASSET.test(t)) return true;
62
+ return false;
63
+ };
64
+
65
+ // ── Shape helpers (decoupled from the SDK's exact field names; the host
66
+ // adapter normalizes responses to these). ────────────────────────────────
67
+ interface PoolRow {
68
+ pool_id: string;
69
+ asset_a_address: string;
70
+ asset_b_address: string;
71
+ asset_a_symbol?: string;
72
+ asset_b_symbol?: string;
73
+ curve_type?: string;
74
+ tvl_asset_b?: string;
75
+ fee_bps?: number;
76
+ }
77
+ interface ListPoolsResult { pools: PoolRow[]; total_count?: number }
78
+ interface SimulateResult {
79
+ amount_out?: string;
80
+ execution_price?: string;
81
+ price_impact_pct?: string;
82
+ fee_paid_asset_in?: string;
83
+ warning?: string;
84
+ }
85
+ interface ExecuteResult {
86
+ accepted?: boolean;
87
+ request_id?: string;
88
+ amount_out?: string;
89
+ execution_price?: string;
90
+ }
91
+
92
+ /**
93
+ * Resolve the asset address pair from a pool row + the user's requested
94
+ * (from_asset, to_asset) symbols. The pool stores ONE order (typically the
95
+ * non-BTC token on side A, BTC on side B), but the user can phrase the swap
96
+ * either direction.
97
+ *
98
+ * Strategy — deduce the unknown side from the known one:
99
+ * 1. Match each leg to a pool side by symbol or by the BTC-ticker family.
100
+ * 2. If one leg resolves to a side, the other leg MUST be the opposite side
101
+ * (a pool only has two assets). This is the case that prevented an
102
+ * earlier "asset_in == asset_out" bug on regtest, where the non-BTC
103
+ * side carries no symbol.
104
+ * 3. Only as a last resort fall back to the "BTC on side B, token on side
105
+ * A" default — true for almost every regtest pool we've seen.
106
+ */
107
+ function isBtcTicker(s: string): boolean {
108
+ return s === 'BTC' || s === 'SATS' || s === 'BITCOIN';
109
+ }
110
+
111
+ function resolveLegAddresses(pool: PoolRow, from: string, to: string): { fromAddr: string; toAddr: string } {
112
+ const f = (from ?? '').toUpperCase();
113
+ const t = (to ?? '').toUpperCase();
114
+ const aSym = (pool.asset_a_symbol ?? '').toUpperCase();
115
+ const bSym = (pool.asset_b_symbol ?? '').toUpperCase();
116
+
117
+ // Which side (if any) does each leg map to?
118
+ const sideOf = (sym: string): 'a' | 'b' | undefined => {
119
+ if (sym === aSym && aSym) return 'a';
120
+ if (sym === bSym && bSym) return 'b';
121
+ return undefined;
122
+ };
123
+
124
+ let fSide = sideOf(f);
125
+ let tSide = sideOf(t);
126
+
127
+ // 2. Deduce the unknown side from the known one.
128
+ if (!fSide && tSide) fSide = tSide === 'a' ? 'b' : 'a';
129
+ if (!tSide && fSide) tSide = fSide === 'a' ? 'b' : 'a';
130
+
131
+ // 3. Last-resort default — only when NEITHER side resolved. BTC on side B
132
+ // is the canonical layout for the Spark/Flashnet pools we've observed.
133
+ if (!fSide && !tSide) {
134
+ fSide = isBtcTicker(f) ? 'b' : 'a';
135
+ tSide = fSide === 'a' ? 'b' : 'a';
136
+ }
137
+
138
+ const addr = (side: 'a' | 'b'): string => (side === 'a' ? pool.asset_a_address : pool.asset_b_address);
139
+ return { fromAddr: addr(fSide!), toAddr: addr(tSide!) };
140
+ }
141
+
142
+ /** Compute min_amount_out = floor(amount_out × (1 − bps/10000)). String-safe. */
143
+ function computeMinAmountOut(amountOut: string | undefined, slippageBps: number): string {
144
+ if (!amountOut) return '0';
145
+ try {
146
+ const out = BigInt(String(amountOut).replace(/[^\d]/g, ''));
147
+ const num = BigInt(10_000 - Math.max(0, Math.min(5_000, slippageBps)));
148
+ const min = (out * num) / 10_000n;
149
+ return min.toString();
150
+ } catch {
151
+ return '0';
152
+ }
153
+ }
154
+
155
+ const DEFAULT_SLIPPAGE_BPS = 50;
156
+
157
+ export const flashnetSwapRecipe: Recipe = {
158
+ name: 'flashnet-swap',
159
+ description:
160
+ "Swap on Flashnet (Spark-native AMM): list pool → simulate → confirm once → execute. The user's Spark wallet IS the swap account.",
161
+ match: (t) => FLASHNET_INTENT(t),
162
+ triggers: ['flashnet', 'usdb', 'swap', 'exchange', 'convert', 'trade'],
163
+ slots: [
164
+ { name: 'from_asset', type: 'string', description: 'Asset the user SPENDS (BTC / USDB). "swap 10000 sats with usdb" → from_asset=BTC. "sell 1 usdb" → from_asset=USDB.', required: true },
165
+ { name: 'to_asset', type: 'string', description: 'Asset the user GETS (BTC / USDB). "swap 10000 sats with usdb" → to_asset=USDB. "buy USDB with sats" → to_asset=USDB.', required: true },
166
+ { name: 'amount', type: 'number', description: 'The numeric amount the user named. e.g. "swap 10000 sats with usdb" → amount=10000. Always in the asset on `amount_side`.' },
167
+ { name: 'amount_side', type: 'string', description: "Which leg the amount is denominated in: 'from' (the spent asset) or 'to' (the received asset). Default 'from'. 'buy 10 usdb with sats' → 'to'." },
168
+ ],
169
+ // extractSwap is the same regex extractor the KaleidoSwap atomic recipe
170
+ // uses; it returns {from_asset, to_asset, amount, amount_side?}. With
171
+ // forceModelExtract=true the runner ignores the det result and always asks
172
+ // the model — but the det extraction still feeds the Funnel's pre-filter so
173
+ // bare "buy/sell" with a Spark asset routes here even before the model runs.
174
+ extract: extractSwap,
175
+ forceModelExtract: true,
176
+ confident: (s) => !!s.from_asset && !!s.to_asset && !!s.amount,
177
+ steps: [
178
+ // 1. Discover the pool. Side-agnostic on the host side; one row is enough.
179
+ {
180
+ tool: 'flashnet_list_pools',
181
+ as: 'pools',
182
+ args: (ctx) => ({
183
+ asset_a: String(ctx.slots.from_asset ?? '').toUpperCase(),
184
+ asset_b: String(ctx.slots.to_asset ?? '').toUpperCase(),
185
+ sort: 'TVL_DESC',
186
+ limit: 5,
187
+ }),
188
+ },
189
+ // 2. Quote the swap. Direction is LITERAL: asset_in = what the user
190
+ // spends (`from_asset`), asset_out = what they get (`to_asset`). The
191
+ // runner — not the model — assembles this, so the inverted-direction
192
+ // bug is impossible by construction.
193
+ {
194
+ tool: 'flashnet_simulate_swap',
195
+ as: 'sim',
196
+ args: (ctx) => {
197
+ const r = ctx.results.pools as ListPoolsResult | undefined;
198
+ const pool = r?.pools?.[0];
199
+ if (!pool) throw new Error(`No Flashnet pool found for ${ctx.slots.from_asset} ↔ ${ctx.slots.to_asset}.`);
200
+ const { fromAddr, toAddr } = resolveLegAddresses(pool, String(ctx.slots.from_asset ?? ''), String(ctx.slots.to_asset ?? ''));
201
+ return {
202
+ pool_id: pool.pool_id,
203
+ asset_in_address: fromAddr,
204
+ asset_out_address: toAddr,
205
+ amount_in: String(ctx.slots.amount ?? ''),
206
+ };
207
+ },
208
+ },
209
+ ],
210
+ // 3. Settle. Confirmation-gated at the recipe level (see runner.ts).
211
+ // min_amount_out is computed here — never the raw simulated value — so a
212
+ // moving pool can't fill at a worse price than `slippage_bps` allows.
213
+ final: {
214
+ tool: 'flashnet_execute_swap',
215
+ as: 'exec',
216
+ args: (ctx) => {
217
+ const r = ctx.results.pools as ListPoolsResult | undefined;
218
+ const pool = r?.pools?.[0];
219
+ const sim = ctx.results.sim as SimulateResult | undefined;
220
+ if (!pool || !sim) throw new Error('Flashnet swap: missing pool or simulation result.');
221
+ const { fromAddr, toAddr } = resolveLegAddresses(pool, String(ctx.slots.from_asset ?? ''), String(ctx.slots.to_asset ?? ''));
222
+ return {
223
+ pool_id: pool.pool_id,
224
+ asset_in_address: fromAddr,
225
+ asset_out_address: toAddr,
226
+ amount_in: String(ctx.slots.amount ?? ''),
227
+ min_amount_out: computeMinAmountOut(sim.amount_out, DEFAULT_SLIPPAGE_BPS),
228
+ max_slippage_bps: DEFAULT_SLIPPAGE_BPS,
229
+ };
230
+ },
231
+ },
232
+ // ONE confirmation, fired after simulate / before execute, with real numbers.
233
+ confirm: (ctx: RecipeContext) => {
234
+ const sim = ctx.results.sim as SimulateResult | undefined;
235
+ const from = fmtAmount(ctx.slots.amount, String(ctx.slots.from_asset ?? ''));
236
+ const to = fmtAmount(sim?.amount_out, String(ctx.slots.to_asset ?? ''));
237
+ const impact = sim?.price_impact_pct ? ` · ${sim.price_impact_pct} price impact` : '';
238
+ const warn = sim?.warning ? ` · ${sim.warning}` : '';
239
+ return `Swap ${from} → ~${to} on Flashnet (slippage cap ${DEFAULT_SLIPPAGE_BPS / 100}%)${impact}${warn}. Proceed?`;
240
+ },
241
+ summary: (ctx) => {
242
+ const sim = ctx.results.sim as SimulateResult | undefined;
243
+ const exec = ctx.results.exec as ExecuteResult | undefined;
244
+ const from = fmtAmount(ctx.slots.amount, String(ctx.slots.from_asset ?? ''));
245
+ const got = fmtAmount(exec?.amount_out ?? sim?.amount_out, String(ctx.slots.to_asset ?? ''));
246
+ if (exec?.accepted === false) {
247
+ return `Flashnet swap rejected: ${(exec as any)?.error ?? 'unknown error'}.`;
248
+ }
249
+ return `Flashnet swap submitted: ${from} → ${got}. request_id=${exec?.request_id ?? '?'}.`;
250
+ },
251
+ };
252
+
253
+ /**
254
+ * Format an amount + asset for user display. BTC is rendered as "X,XXX sats"
255
+ * (BTC is the asset, sats is the unit; the on-the-wire amount is already in
256
+ * sats so no conversion is needed). Anything else is rendered as
257
+ * "N TICKER" with thousand-separators.
258
+ */
259
+ function fmtAmount(amount: unknown, asset: string): string {
260
+ const t = (asset ?? '').toUpperCase();
261
+ const n = Number(amount);
262
+ if (!Number.isFinite(n)) return `${amount ?? '?'} ${t}`;
263
+ const sep = n.toLocaleString('en-US');
264
+ if (t === 'BTC' || t === 'SATS' || t === 'BITCOIN') return `${sep} sats`;
265
+ return `${sep} ${t}`;
266
+ }
@@ -66,6 +66,27 @@ describe('kaleidoswapAtomicRecipe — selection', () => {
66
66
  it('does not trigger on a balance question', () => {
67
67
  expect(kaleidoswapAtomicRecipe.match!('what is my balance')).toBe(false);
68
68
  });
69
+ it('DEFERS to Flashnet when a Flashnet/Spark cue is present (venue split)', () => {
70
+ // These belong to the agentic flashnet-swaps skill, not the KaleidoSwap
71
+ // maker recipe — so the recipe must NOT claim them.
72
+ expect(kaleidoswapAtomicRecipe.match!('swap 10000 sats with asset of your choice in flashnet')).toBe(false);
73
+ expect(kaleidoswapAtomicRecipe.match!('swap 5000 sats to usdb')).toBe(false);
74
+ expect(kaleidoswapAtomicRecipe.match!('swap btc to usdb on spark')).toBe(false);
75
+ });
76
+ it('does NOT claim a bare swap with no venue/asset cue (falls to agentic)', () => {
77
+ // Ambiguous — a swap always needs a target asset; let the skill tier ask
78
+ // or pick the connected venue rather than grabbing it for the maker.
79
+ expect(kaleidoswapAtomicRecipe.match!('swap 100000 sats')).toBe(false);
80
+ expect(kaleidoswapAtomicRecipe.match!('exchange some bitcoin')).toBe(false);
81
+ });
82
+ it('still claims swaps that name an RGB/maker asset', () => {
83
+ expect(kaleidoswapAtomicRecipe.match!('swap 100000 sats to usdt')).toBe(true);
84
+ expect(kaleidoswapAtomicRecipe.match!('convert btc to xaut')).toBe(true);
85
+ });
86
+ it('does not turn portfolio review or explicit no-trade language into a swap', () => {
87
+ expect(kaleidoswapAtomicRecipe.match!('review my portfolio allocation but do not trade')).toBe(false);
88
+ expect(kaleidoswapAtomicRecipe.match!('analyze my holdings without trading')).toBe(false);
89
+ });
69
90
  });
70
91
 
71
92
  describe('kaleidoswapAtomicRecipe — forceModelExtract (less deterministic slot parsing)', () => {
@@ -34,23 +34,38 @@
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
 
@@ -62,7 +77,7 @@ interface QuoteResult {
62
77
  to_amount_display?: string;
63
78
  fee_display?: string;
64
79
  }
65
- interface InitResult { swapstring?: string; payment_hash?: string }
80
+ interface InitResult { swapstring?: string; payment_hash?: string; atomic_id?: string }
66
81
  interface NodeInfo { pubkey?: string }
67
82
 
68
83
  export const kaleidoswapAtomicRecipe: Recipe = {
@@ -72,10 +87,10 @@ export const kaleidoswapAtomicRecipe: Recipe = {
72
87
  match: (t) => SWAP_INTENT(t),
73
88
  triggers: ['swap', 'exchange', 'convert', 'trade', 'buy', 'sell'],
74
89
  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)" },
90
+ { name: 'from_asset', type: 'string', description: 'Asset to spend (BTC / USDT / XAUT). Example: "swap 10 usdt to btc" → from_asset=USDT', required: true },
91
+ { name: 'to_asset', type: 'string', description: 'Asset to receive (BTC / USDT / XAUT). Example: "buy 1 usdt" → to_asset=USDT', required: true },
92
+ { 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' },
93
+ { 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
94
  ],
80
95
  // Keep the fast `extract` for the Funnel's cheap pre-filter (so "buy 1 usdt"
81
96
  // reliably enters the recipe branch instead of falling to free agentic).
@@ -159,6 +174,9 @@ export const kaleidoswapAtomicRecipe: Recipe = {
159
174
  const q = ctx.results.quote as QuoteResult | undefined;
160
175
  const from = q?.from_amount_display ?? `${ctx.slots.amount} ${ctx.slots.from_asset}`;
161
176
  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.`;
177
+ const init = ctx.results.init as InitResult | undefined;
178
+ const id = init?.atomic_id || init?.payment_hash || '?';
179
+ return `remember: atomic swap atomic_id=${id} (for later kaleidoswap_atomic_status checks).
180
+ 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
181
  },
164
182
  };
@@ -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');