@kaleidorg/mind 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/dist/bitrefill/contract.d.ts +60 -0
  2. package/dist/bitrefill/contract.d.ts.map +1 -0
  3. package/dist/bitrefill/contract.js +119 -0
  4. package/dist/bitrefill/contract.js.map +1 -0
  5. package/dist/context/compress.d.ts +65 -0
  6. package/dist/context/compress.d.ts.map +1 -0
  7. package/dist/context/compress.js +181 -0
  8. package/dist/context/compress.js.map +1 -0
  9. package/dist/engine.d.ts +20 -0
  10. package/dist/engine.d.ts.map +1 -1
  11. package/dist/engine.js +23 -4
  12. package/dist/engine.js.map +1 -1
  13. package/dist/evidence.d.ts +62 -0
  14. package/dist/evidence.d.ts.map +1 -0
  15. package/dist/evidence.js +47 -0
  16. package/dist/evidence.js.map +1 -0
  17. package/dist/flashnet/contract.d.ts +56 -0
  18. package/dist/flashnet/contract.d.ts.map +1 -0
  19. package/dist/flashnet/contract.js +100 -0
  20. package/dist/flashnet/contract.js.map +1 -0
  21. package/dist/funnel.d.ts +11 -0
  22. package/dist/funnel.d.ts.map +1 -1
  23. package/dist/funnel.js +50 -7
  24. package/dist/funnel.js.map +1 -1
  25. package/dist/index.d.ts +10 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +7 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/kaleidoswap/contract.js +1 -1
  30. package/dist/kaleidoswap/contract.js.map +1 -1
  31. package/dist/knowledge/bitcoin-copilot.d.ts.map +1 -1
  32. package/dist/knowledge/bitcoin-copilot.js +83 -0
  33. package/dist/knowledge/bitcoin-copilot.js.map +1 -1
  34. package/dist/providers/types.d.ts +17 -0
  35. package/dist/providers/types.d.ts.map +1 -1
  36. package/dist/qvac/provider.d.ts.map +1 -1
  37. package/dist/qvac/provider.js +23 -0
  38. package/dist/qvac/provider.js.map +1 -1
  39. package/dist/qvac/stream.d.ts +6 -0
  40. package/dist/qvac/stream.d.ts.map +1 -1
  41. package/dist/qvac/stream.js +12 -0
  42. package/dist/qvac/stream.js.map +1 -1
  43. package/dist/recipe/flashnet-swap.d.ts +35 -0
  44. package/dist/recipe/flashnet-swap.d.ts.map +1 -0
  45. package/dist/recipe/flashnet-swap.js +239 -0
  46. package/dist/recipe/flashnet-swap.js.map +1 -0
  47. package/dist/recipe/kaleidoswap-atomic.d.ts.map +1 -1
  48. package/dist/recipe/kaleidoswap-atomic.js +66 -32
  49. package/dist/recipe/kaleidoswap-atomic.js.map +1 -1
  50. package/dist/recipe/kaleidoswap-channel-order.d.ts.map +1 -1
  51. package/dist/recipe/kaleidoswap-channel-order.js +31 -10
  52. package/dist/recipe/kaleidoswap-channel-order.js.map +1 -1
  53. package/dist/recipe/kaleidoswap-price.d.ts.map +1 -1
  54. package/dist/recipe/kaleidoswap-price.js +7 -1
  55. package/dist/recipe/kaleidoswap-price.js.map +1 -1
  56. package/dist/recipe/runner.d.ts.map +1 -1
  57. package/dist/recipe/runner.js +5 -3
  58. package/dist/recipe/runner.js.map +1 -1
  59. package/dist/recipe/swap.d.ts.map +1 -1
  60. package/dist/recipe/swap.js +14 -1
  61. package/dist/recipe/swap.js.map +1 -1
  62. package/dist/wallet/confirm.d.ts.map +1 -1
  63. package/dist/wallet/confirm.js +1 -0
  64. package/dist/wallet/confirm.js.map +1 -1
  65. package/dist/wallet/contract.d.ts.map +1 -1
  66. package/dist/wallet/contract.js +20 -4
  67. package/dist/wallet/contract.js.map +1 -1
  68. package/package.json +4 -4
  69. package/skills/bitrefill/SKILL.md +152 -52
  70. package/skills/flashnet-swaps/SKILL.md +158 -0
  71. package/skills/kaleido-lsps/SKILL.md +25 -8
  72. package/skills/kaleido-trading/SKILL.md +36 -12
  73. package/skills/merchant-finder/SKILL.md +1 -1
  74. package/skills/rgb-lightning-node/SKILL.md +35 -8
  75. package/skills/spark-wallet/SKILL.md +235 -0
  76. package/skills/wallet-assistant/SKILL.md +2 -2
  77. package/src/bitrefill/contract.test.ts +89 -0
  78. package/src/bitrefill/contract.ts +190 -0
  79. package/src/context/compress.test.ts +120 -0
  80. package/src/context/compress.ts +230 -0
  81. package/src/engine.test.ts +34 -0
  82. package/src/engine.ts +35 -4
  83. package/src/evidence.test.ts +80 -0
  84. package/src/evidence.ts +114 -0
  85. package/src/flashnet/contract.test.ts +101 -0
  86. package/src/flashnet/contract.ts +164 -0
  87. package/src/funnel.mind.test.ts +3 -5
  88. package/src/funnel.ts +59 -8
  89. package/src/index.ts +51 -1
  90. package/src/kaleidoswap/contract.ts +1 -1
  91. package/src/knowledge/bitcoin-copilot.ts +94 -0
  92. package/src/providers/types.ts +18 -0
  93. package/src/qvac/provider.ts +25 -1
  94. package/src/qvac/stream.test.ts +11 -0
  95. package/src/qvac/stream.ts +16 -0
  96. package/src/recipe/flashnet-swap.test.ts +114 -0
  97. package/src/recipe/flashnet-swap.ts +266 -0
  98. package/src/recipe/kaleidoswap-atomic.test.ts +52 -6
  99. package/src/recipe/kaleidoswap-atomic.ts +71 -34
  100. package/src/recipe/kaleidoswap-channel-order.test.ts +38 -0
  101. package/src/recipe/kaleidoswap-channel-order.ts +27 -9
  102. package/src/recipe/kaleidoswap-price.ts +7 -1
  103. package/src/recipe/recipe.test.ts +5 -0
  104. package/src/recipe/runner.ts +5 -3
  105. package/src/recipe/swap.ts +16 -1
  106. package/src/wallet/confirm.test.ts +8 -0
  107. package/src/wallet/confirm.ts +1 -0
  108. package/src/wallet/contract.test.ts +10 -0
  109. package/src/wallet/contract.ts +26 -4
@@ -17,7 +17,7 @@
17
17
  * delegated to a desktop peer.
18
18
  */
19
19
  import type * as QvacSdk from '@qvac/sdk';
20
- import type { LLMProvider, TurnInput, TurnOutput } from '../providers/types.js';
20
+ import type { InferenceMetrics, LLMProvider, TurnInput, TurnOutput } from '../providers/types.js';
21
21
  import type { QvacTurnStats } from './parse.js';
22
22
  import { consumeRun } from './stream.js';
23
23
 
@@ -138,12 +138,36 @@ export function createQvacProvider(options: QvacProviderOptions): LLMProvider {
138
138
  // instead of an empty bubble so the agentic loop ends cleanly.
139
139
  const text =
140
140
  result.text || (result.thinkingBudgetExceeded ? THINKING_BUDGET_FALLBACK : result.text);
141
+ const totalTokens = result.stats?.totalTokens;
142
+ const promptTokens = result.stats?.promptTokens;
143
+ const inference: InferenceMetrics = {
144
+ requestId: result.requestId,
145
+ durationMs: result.timing.durationMs,
146
+ status:
147
+ result.stopReason === 'cancelled'
148
+ ? 'cancelled'
149
+ : result.truncated
150
+ ? 'truncated'
151
+ : 'completed',
152
+ ...(result.stats?.backendDevice ? { backendDevice: result.stats.backendDevice } : {}),
153
+ ...(typeof promptTokens === 'number' ? { promptTokens } : {}),
154
+ ...(typeof totalTokens === 'number' ? { totalTokens } : {}),
155
+ ...(typeof totalTokens === 'number' && typeof promptTokens === 'number'
156
+ ? { completionTokens: Math.max(0, totalTokens - promptTokens) }
157
+ : {}),
158
+ ...(typeof result.timing.ttftMs === 'number' ? { ttftMs: result.timing.ttftMs } : {}),
159
+ ...(typeof result.stats?.tokensPerSecond === 'number'
160
+ ? { tokensPerSecond: result.stats.tokensPerSecond }
161
+ : {}),
162
+ ...(result.stopReason ? { stopReason: result.stopReason } : {}),
163
+ };
141
164
 
142
165
  return {
143
166
  text,
144
167
  rawContent: result.rawContent,
145
168
  toolCalls: result.toolCalls,
146
169
  requestId: result.requestId,
170
+ inference,
147
171
  };
148
172
  },
149
173
 
@@ -101,4 +101,15 @@ describe('consumeRun', () => {
101
101
  await consumeRun(run, { onToken: (t) => tokens.push(t) });
102
102
  expect(tokens).toEqual(['hi']);
103
103
  });
104
+
105
+ it('measures first-token and total completion timing', async () => {
106
+ const ticks = [100, 145, 190];
107
+ const out = await consumeRun(
108
+ fakeRun([{ type: 'thinkingDelta', text: 'plan' }, { type: 'contentDelta', text: 'answer' }], {
109
+ contentText: 'answer',
110
+ }),
111
+ { now: () => ticks.shift() ?? 190 },
112
+ );
113
+ expect(out.timing).toEqual({ ttftMs: 45, durationMs: 90 });
114
+ });
104
115
  });
@@ -41,12 +41,18 @@ export interface StreamHandlers {
41
41
  * stops forwarding deltas after this.
42
42
  */
43
43
  onThinkingBudgetExceeded?: () => void;
44
+ /** Injectable monotonic-ish wall clock for deterministic timing tests. */
45
+ now?: () => number;
44
46
  }
45
47
 
46
48
  export interface ConsumedTurn extends ParsedTurn {
47
49
  requestId: string;
48
50
  /** True when the run was stopped because `<think>` hit `maxThinkingTokens`. */
49
51
  thinkingBudgetExceeded?: boolean;
52
+ timing: {
53
+ ttftMs?: number;
54
+ durationMs: number;
55
+ };
50
56
  }
51
57
 
52
58
  /** Rough token estimate (~4 chars/token) — same heuristic the context budget uses. */
@@ -63,14 +69,19 @@ export async function consumeRun(
63
69
  run: CompletionRunLike,
64
70
  handlers: StreamHandlers = {},
65
71
  ): Promise<ConsumedTurn> {
72
+ const now = handlers.now ?? Date.now;
73
+ const startedAt = now();
74
+ let firstTokenAt: number | undefined;
66
75
  let streamed = '';
67
76
  let thinkingChars = 0;
68
77
  let budgetExceeded = false;
69
78
  for await (const event of run.events) {
70
79
  if (event.type === 'contentDelta' && typeof event.text === 'string') {
80
+ if (firstTokenAt === undefined && event.text.length > 0) firstTokenAt = now();
71
81
  streamed += event.text;
72
82
  handlers.onToken?.(event.text);
73
83
  } else if (event.type === 'thinkingDelta' && typeof event.text === 'string') {
84
+ if (firstTokenAt === undefined && event.text.length > 0) firstTokenAt = now();
74
85
  handlers.onThinking?.(event.text);
75
86
  if (handlers.maxThinkingTokens !== undefined && !budgetExceeded) {
76
87
  thinkingChars += event.text.length;
@@ -85,9 +96,14 @@ export async function consumeRun(
85
96
  }
86
97
  }
87
98
  const final = await run.final;
99
+ const finishedAt = now();
88
100
  return {
89
101
  ...finalToTurn(final, streamed),
90
102
  requestId: run.requestId,
91
103
  thinkingBudgetExceeded: budgetExceeded,
104
+ timing: {
105
+ ...(firstTokenAt === undefined ? {} : { ttftMs: Math.max(0, firstTokenAt - startedAt) }),
106
+ durationMs: Math.max(0, finishedAt - startedAt),
107
+ },
92
108
  };
93
109
  }
@@ -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
+ }
@@ -31,12 +31,12 @@ function buildStubs(captured: { name: string; args: any }[]) {
31
31
  });
32
32
  return new ToolRegistry([
33
33
  new InProcessToolSource('kaleidoswap', [
34
+ // Mirror the REAL kaleido-mcp `kaleidoswap_get_quote` response: each leg
35
+ // echoes asset_id + ticker + layer + amount_raw (integer) + amount_display.
34
36
  tool('kaleidoswap_get_quote', {
35
37
  rfq_id: 'rfq-1',
36
- from_asset: { asset_id: 'USDT', ticker: 'USDT', amount: 10_000_000 },
37
- to_asset: { asset_id: 'BTC', ticker: 'BTC', amount: 15_250_000 },
38
- from_amount_display: '10 USDT',
39
- to_amount_display: '15,250 sats',
38
+ from_asset: { asset_id: 'USDT', ticker: 'USDT', layer: 'RGB_LN', amount_raw: 10_000_000, amount_display: '10' },
39
+ to_asset: { asset_id: 'BTC', ticker: 'BTC', layer: 'BTC_LN', amount_raw: 15_250_000, amount_display: '15,250 sats' },
40
40
  fee_display: '154 sats',
41
41
  }),
42
42
  tool('kaleidoswap_atomic_init', { swapstring: 'SWAP/abc/def', payment_hash: 'ph-1' }, /* spend */ true),
@@ -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)', () => {
@@ -145,8 +166,33 @@ describe('kaleidoswapAtomicRecipe — full chain', () => {
145
166
  const init = captured.find((c) => c.name === 'kaleidoswap_atomic_init')!;
146
167
  expect(init.args).toEqual({
147
168
  rfq_id: 'rfq-1',
148
- from_asset: 'USDT', from_amount: 10_000_000,
149
- to_asset: 'BTC', to_amount: 15_250_000,
169
+ from_asset_id: 'USDT', from_amount_raw: 10_000_000,
170
+ to_asset_id: 'BTC', to_amount_raw: 15_250_000,
171
+ });
172
+ });
173
+
174
+ it('builds get_quote args matching the kaleido-mcp schema (sell vs buy leg)', async () => {
175
+ // The reported bug: the recipe must emit the MCP tool's field names
176
+ // (from_asset_id/to_asset_id/from_layer/to_layer) and put the amount on the
177
+ // correct leg — to_amount for "buy 1 usdt", from_amount for a sell/swap.
178
+ const sell: { name: string; args: any }[] = [];
179
+ await runRecipe(kaleidoswapAtomicRecipe, 'swap 10 usdt to btc', {
180
+ provider: refusingProvider, tools: buildStubs(sell), onConfirm: async () => ({ approved: true }),
181
+ slots: { from_asset: 'USDT', to_asset: 'BTC', amount: 10, amount_side: 'from' },
182
+ });
183
+ expect(sell.find((c) => c.name === 'kaleidoswap_get_quote')!.args).toEqual({
184
+ from_asset_id: 'USDT', to_asset_id: 'BTC',
185
+ from_layer: 'RGB_LN', to_layer: 'BTC_LN', from_amount: 10,
186
+ });
187
+
188
+ const buy: { name: string; args: any }[] = [];
189
+ await runRecipe(kaleidoswapAtomicRecipe, 'buy 1 usdt', {
190
+ provider: refusingProvider, tools: buildStubs(buy), onConfirm: async () => ({ approved: true }),
191
+ slots: { from_asset: 'BTC', to_asset: 'USDT', amount: 1, amount_side: 'to' },
192
+ });
193
+ expect(buy.find((c) => c.name === 'kaleidoswap_get_quote')!.args).toEqual({
194
+ from_asset_id: 'BTC', to_asset_id: 'USDT',
195
+ from_layer: 'BTC_LN', to_layer: 'RGB_LN', to_amount: 1,
150
196
  });
151
197
  });
152
198