@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.
- package/dist/bitrefill/contract.d.ts +60 -0
- package/dist/bitrefill/contract.d.ts.map +1 -0
- package/dist/bitrefill/contract.js +119 -0
- package/dist/bitrefill/contract.js.map +1 -0
- package/dist/context/compress.d.ts +65 -0
- package/dist/context/compress.d.ts.map +1 -0
- package/dist/context/compress.js +181 -0
- package/dist/context/compress.js.map +1 -0
- package/dist/engine.d.ts +20 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +23 -4
- package/dist/engine.js.map +1 -1
- package/dist/evidence.d.ts +62 -0
- package/dist/evidence.d.ts.map +1 -0
- package/dist/evidence.js +47 -0
- package/dist/evidence.js.map +1 -0
- package/dist/flashnet/contract.d.ts +56 -0
- package/dist/flashnet/contract.d.ts.map +1 -0
- package/dist/flashnet/contract.js +100 -0
- package/dist/flashnet/contract.js.map +1 -0
- package/dist/funnel.d.ts +11 -0
- package/dist/funnel.d.ts.map +1 -1
- package/dist/funnel.js +50 -7
- package/dist/funnel.js.map +1 -1
- package/dist/index.d.ts +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/kaleidoswap/contract.js +1 -1
- package/dist/kaleidoswap/contract.js.map +1 -1
- package/dist/knowledge/bitcoin-copilot.d.ts.map +1 -1
- package/dist/knowledge/bitcoin-copilot.js +83 -0
- package/dist/knowledge/bitcoin-copilot.js.map +1 -1
- package/dist/providers/types.d.ts +17 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/qvac/provider.d.ts.map +1 -1
- package/dist/qvac/provider.js +23 -0
- package/dist/qvac/provider.js.map +1 -1
- package/dist/qvac/stream.d.ts +6 -0
- package/dist/qvac/stream.d.ts.map +1 -1
- package/dist/qvac/stream.js +12 -0
- package/dist/qvac/stream.js.map +1 -1
- package/dist/recipe/flashnet-swap.d.ts +35 -0
- package/dist/recipe/flashnet-swap.d.ts.map +1 -0
- package/dist/recipe/flashnet-swap.js +239 -0
- package/dist/recipe/flashnet-swap.js.map +1 -0
- package/dist/recipe/kaleidoswap-atomic.d.ts.map +1 -1
- package/dist/recipe/kaleidoswap-atomic.js +66 -32
- package/dist/recipe/kaleidoswap-atomic.js.map +1 -1
- package/dist/recipe/kaleidoswap-channel-order.d.ts.map +1 -1
- package/dist/recipe/kaleidoswap-channel-order.js +31 -10
- package/dist/recipe/kaleidoswap-channel-order.js.map +1 -1
- package/dist/recipe/kaleidoswap-price.d.ts.map +1 -1
- package/dist/recipe/kaleidoswap-price.js +7 -1
- package/dist/recipe/kaleidoswap-price.js.map +1 -1
- package/dist/recipe/runner.d.ts.map +1 -1
- package/dist/recipe/runner.js +5 -3
- package/dist/recipe/runner.js.map +1 -1
- package/dist/recipe/swap.d.ts.map +1 -1
- package/dist/recipe/swap.js +14 -1
- package/dist/recipe/swap.js.map +1 -1
- package/dist/wallet/confirm.d.ts.map +1 -1
- package/dist/wallet/confirm.js +1 -0
- package/dist/wallet/confirm.js.map +1 -1
- package/dist/wallet/contract.d.ts.map +1 -1
- package/dist/wallet/contract.js +20 -4
- package/dist/wallet/contract.js.map +1 -1
- package/package.json +4 -4
- package/skills/bitrefill/SKILL.md +152 -52
- package/skills/flashnet-swaps/SKILL.md +158 -0
- package/skills/kaleido-lsps/SKILL.md +25 -8
- package/skills/kaleido-trading/SKILL.md +36 -12
- package/skills/merchant-finder/SKILL.md +1 -1
- package/skills/rgb-lightning-node/SKILL.md +35 -8
- package/skills/spark-wallet/SKILL.md +235 -0
- package/skills/wallet-assistant/SKILL.md +2 -2
- package/src/bitrefill/contract.test.ts +89 -0
- package/src/bitrefill/contract.ts +190 -0
- package/src/context/compress.test.ts +120 -0
- package/src/context/compress.ts +230 -0
- package/src/engine.test.ts +34 -0
- package/src/engine.ts +35 -4
- package/src/evidence.test.ts +80 -0
- package/src/evidence.ts +114 -0
- package/src/flashnet/contract.test.ts +101 -0
- package/src/flashnet/contract.ts +164 -0
- package/src/funnel.mind.test.ts +3 -5
- package/src/funnel.ts +59 -8
- package/src/index.ts +51 -1
- package/src/kaleidoswap/contract.ts +1 -1
- package/src/knowledge/bitcoin-copilot.ts +94 -0
- package/src/providers/types.ts +18 -0
- package/src/qvac/provider.ts +25 -1
- package/src/qvac/stream.test.ts +11 -0
- package/src/qvac/stream.ts +16 -0
- package/src/recipe/flashnet-swap.test.ts +114 -0
- package/src/recipe/flashnet-swap.ts +266 -0
- package/src/recipe/kaleidoswap-atomic.test.ts +52 -6
- package/src/recipe/kaleidoswap-atomic.ts +71 -34
- package/src/recipe/kaleidoswap-channel-order.test.ts +38 -0
- package/src/recipe/kaleidoswap-channel-order.ts +27 -9
- package/src/recipe/kaleidoswap-price.ts +7 -1
- package/src/recipe/recipe.test.ts +5 -0
- package/src/recipe/runner.ts +5 -3
- package/src/recipe/swap.ts +16 -1
- package/src/wallet/confirm.test.ts +8 -0
- package/src/wallet/confirm.ts +1 -0
- package/src/wallet/contract.test.ts +10 -0
- package/src/wallet/contract.ts +26 -4
package/src/qvac/provider.ts
CHANGED
|
@@ -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
|
|
package/src/qvac/stream.test.ts
CHANGED
|
@@ -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
|
});
|
package/src/qvac/stream.ts
CHANGED
|
@@ -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',
|
|
37
|
-
to_asset: { asset_id: 'BTC', ticker: 'BTC',
|
|
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
|
-
|
|
149
|
-
|
|
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
|
|