@kaleidorg/mind 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/funnel.d.ts +19 -0
- package/dist/funnel.d.ts.map +1 -1
- package/dist/funnel.js +48 -10
- package/dist/funnel.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/kaleidoswap/contract.d.ts +3 -3
- package/dist/kaleidoswap/contract.d.ts.map +1 -1
- package/dist/kaleidoswap/contract.js +16 -4
- package/dist/kaleidoswap/contract.js.map +1 -1
- package/dist/knowledge/bitcoin-copilot.d.ts.map +1 -1
- package/dist/knowledge/bitcoin-copilot.js +102 -0
- package/dist/knowledge/bitcoin-copilot.js.map +1 -1
- package/dist/knowledge/btc-map.d.ts +14 -17
- package/dist/knowledge/btc-map.d.ts.map +1 -1
- package/dist/knowledge/btc-map.js +66 -266
- package/dist/knowledge/btc-map.js.map +1 -1
- package/dist/lsps1/contract.d.ts.map +1 -1
- package/dist/lsps1/contract.js +28 -10
- package/dist/lsps1/contract.js.map +1 -1
- package/dist/qvac/parse.d.ts +15 -0
- package/dist/qvac/parse.d.ts.map +1 -1
- package/dist/qvac/parse.js +68 -5
- package/dist/qvac/parse.js.map +1 -1
- package/dist/qvac/text.d.ts.map +1 -1
- package/dist/qvac/text.js +4 -0
- package/dist/qvac/text.js.map +1 -1
- package/dist/recipe/buy-asset-channel.d.ts +26 -0
- package/dist/recipe/buy-asset-channel.d.ts.map +1 -0
- package/dist/recipe/buy-asset-channel.js +112 -0
- package/dist/recipe/buy-asset-channel.js.map +1 -0
- package/dist/recipe/kaleidoswap-atomic.d.ts +26 -18
- package/dist/recipe/kaleidoswap-atomic.d.ts.map +1 -1
- package/dist/recipe/kaleidoswap-atomic.js +101 -63
- package/dist/recipe/kaleidoswap-atomic.js.map +1 -1
- package/dist/recipe/kaleidoswap-channel-order.d.ts +35 -0
- package/dist/recipe/kaleidoswap-channel-order.d.ts.map +1 -0
- package/dist/recipe/kaleidoswap-channel-order.js +493 -0
- package/dist/recipe/kaleidoswap-channel-order.js.map +1 -0
- package/dist/recipe/kaleidoswap-price.d.ts +21 -0
- package/dist/recipe/kaleidoswap-price.d.ts.map +1 -0
- package/dist/recipe/kaleidoswap-price.js +57 -0
- package/dist/recipe/kaleidoswap-price.js.map +1 -0
- package/dist/recipe/runner.d.ts +7 -1
- package/dist/recipe/runner.d.ts.map +1 -1
- package/dist/recipe/runner.js +115 -29
- package/dist/recipe/runner.js.map +1 -1
- package/dist/recipe/swap.d.ts +26 -1
- package/dist/recipe/swap.d.ts.map +1 -1
- package/dist/recipe/swap.js +108 -13
- package/dist/recipe/swap.js.map +1 -1
- package/dist/recipe/types.d.ts +25 -1
- package/dist/recipe/types.d.ts.map +1 -1
- package/dist/skills/registry.d.ts +33 -1
- package/dist/skills/registry.d.ts.map +1 -1
- package/dist/skills/registry.js +45 -1
- package/dist/skills/registry.js.map +1 -1
- package/package.json +1 -1
- package/skills/README.md +3 -0
- package/skills/kaleido-lsps/SKILL.md +101 -43
- package/skills/kaleido-trading/SKILL.md +81 -31
- package/skills/merchant-finder/SKILL.md +96 -66
- package/skills/rgb-lightning-node/SKILL.md +108 -0
- package/skills/wallet-assistant/SKILL.md +32 -21
- package/src/funnel.ts +66 -11
- package/src/index.ts +14 -2
- package/src/kaleidoswap/contract.test.ts +7 -2
- package/src/kaleidoswap/contract.ts +27 -5
- package/src/knowledge/bitcoin-copilot.ts +111 -0
- package/src/knowledge/btc-map.test.ts +53 -96
- package/src/knowledge/btc-map.ts +72 -287
- package/src/lsps1/contract.ts +32 -14
- package/src/qvac/parse.test.ts +70 -1
- package/src/qvac/parse.ts +71 -5
- package/src/qvac/text.ts +4 -0
- package/src/recipe/buy-asset-channel.test.ts +148 -0
- package/src/recipe/buy-asset-channel.ts +118 -0
- package/src/recipe/kaleidoswap-atomic.test.ts +134 -61
- package/src/recipe/kaleidoswap-atomic.ts +112 -66
- package/src/recipe/kaleidoswap-channel-order.test.ts +333 -0
- package/src/recipe/kaleidoswap-channel-order.ts +548 -0
- package/src/recipe/kaleidoswap-price.ts +68 -0
- package/src/recipe/recipe.test.ts +61 -5
- package/src/recipe/runner.ts +128 -31
- package/src/recipe/swap.ts +109 -13
- package/src/recipe/types.ts +25 -1
- package/src/skills/registry.ts +52 -1
package/src/lsps1/contract.ts
CHANGED
|
@@ -50,31 +50,49 @@ export const LSPS1_TOOLS: Lsps1ToolDef[] = [
|
|
|
50
50
|
"Get the LSP's Lightning network info: pubkey, host, port, connect URI. Useful to display the counterparty or pre-connect a peer. No args."),
|
|
51
51
|
|
|
52
52
|
t('lsp_estimate_fees',
|
|
53
|
-
"Estimate the
|
|
53
|
+
"Estimate the channel-order fee BEFORE committing. Returns setup_fee, capacity_fee, duration_fee, total_fee (all in sats). Re-estimate rather than reusing a stale value.",
|
|
54
54
|
{
|
|
55
|
-
lsp_balance_sat:
|
|
56
|
-
client_balance_sat:
|
|
57
|
-
channel_expiry_blocks: { type: 'number', description: '
|
|
55
|
+
lsp_balance_sat: { type: 'number', description: "Sats the LSP commits on their side (inbound capacity for the user)." },
|
|
56
|
+
client_balance_sat: { type: 'number', description: "Sats the user pre-funds into the channel (push amount). Default 0." },
|
|
57
|
+
channel_expiry_blocks: { type: 'number', description: 'Lease duration in blocks. Default 4320 (~30 days). Maker maximum is typically 20160 (~140 days).' },
|
|
58
|
+
// RGB asset channels (optional):
|
|
59
|
+
asset_id: { type: 'string', description: 'For RGB asset channels — the asset to provision liquidity for.' },
|
|
60
|
+
lsp_asset_amount: { type: 'number', description: 'Asset units the LSP commits (RGB channels only).' },
|
|
61
|
+
client_asset_amount: { type: 'number', description: 'Asset units the user pre-funds (RGB channels only). Requires rfq_id.' },
|
|
62
|
+
rfq_id: { type: 'string', description: 'Quote id from kaleidoswap_get_quote — required when client_asset_amount > 0.' },
|
|
63
|
+
token: { type: 'string', description: 'Optional discount/affiliate token.' },
|
|
58
64
|
},
|
|
59
|
-
['lsp_balance_sat']),
|
|
65
|
+
['lsp_balance_sat', 'client_balance_sat', 'channel_expiry_blocks']),
|
|
60
66
|
|
|
61
67
|
t('lsp_create_order',
|
|
62
|
-
"Create a channel order. SPEND: confirmation-gated. Returns
|
|
68
|
+
"Create a channel order. SPEND: confirmation-gated. Returns order_id + access_token + a payment.bolt11.invoice the user pays to lock the order. The channel opens only after payment.",
|
|
63
69
|
{
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
client_pubkey: { type: 'string', description: "User's Lightning node pubkey — the LSP opens the channel TO this node. Get it from rln_get_node_info." },
|
|
71
|
+
lsp_balance_sat: { type: 'number', description: "Sats the LSP commits on their side (inbound capacity for the user)." },
|
|
72
|
+
client_balance_sat: { type: 'number', description: "Sats the user pre-funds. Default 0." },
|
|
73
|
+
required_channel_confirmations: { type: 'number', description: 'Number of confs the user wants before considering the channel usable. Default 1.' },
|
|
74
|
+
funding_confirms_within_blocks: { type: 'number', description: 'Max blocks within which funding must confirm. Default 6.' },
|
|
75
|
+
channel_expiry_blocks: { type: 'number', description: 'Lease duration in blocks. Default 4320 (~30 days).' },
|
|
76
|
+
announce_channel: { type: 'boolean', description: 'Public (true) or unannounced (false). Default true.' },
|
|
77
|
+
refund_onchain_address: { type: 'string', description: 'Optional on-chain refund address if the LSP cannot open the channel.' },
|
|
78
|
+
// RGB asset channels (optional):
|
|
79
|
+
asset_id: { type: 'string', description: 'For RGB asset channels.' },
|
|
80
|
+
lsp_asset_amount: { type: 'number', description: 'Asset units the LSP commits.' },
|
|
81
|
+
client_asset_amount: { type: 'number', description: 'Asset units the user pre-funds.' },
|
|
82
|
+
rfq_id: { type: 'string', description: 'Required when client_asset_amount > 0.' },
|
|
83
|
+
token: { type: 'string', description: 'Optional discount/affiliate token.' },
|
|
84
|
+
email: { type: 'string', description: 'Optional contact for order updates.' },
|
|
68
85
|
},
|
|
69
|
-
['lsp_balance_sat'],
|
|
86
|
+
['client_pubkey', 'lsp_balance_sat'],
|
|
70
87
|
/* spend */ true),
|
|
71
88
|
|
|
72
89
|
t('lsp_get_order',
|
|
73
|
-
'Check the status of an LSPS1 order
|
|
90
|
+
'Check the status of an LSPS1 order. order_state progresses CREATED → CHANNEL_OPENING → COMPLETED (or FAILED). ALWAYS pass BOTH the order_id and the access_token from lsp_create_order (the access_token is required for order status).',
|
|
74
91
|
{
|
|
75
|
-
order_id:
|
|
92
|
+
order_id: { type: 'string', description: 'The order id from lsp_create_order.' },
|
|
93
|
+
access_token: { type: 'string', description: 'The per-order access token returned by lsp_create_order. Required for non-admin reads.' },
|
|
76
94
|
},
|
|
77
|
-
['order_id']),
|
|
95
|
+
['order_id', 'access_token']),
|
|
78
96
|
];
|
|
79
97
|
|
|
80
98
|
/** All LSPS1 tool names that move funds (confirmation-gated). */
|
package/src/qvac/parse.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { finalToTurn } from './parse.js';
|
|
2
|
+
import { finalToTurn, extractTextToolCalls } from './parse.js';
|
|
3
3
|
|
|
4
4
|
describe('finalToTurn', () => {
|
|
5
5
|
it('uses contentText for visible text and strips reasoning', () => {
|
|
@@ -49,4 +49,73 @@ describe('finalToTurn', () => {
|
|
|
49
49
|
const out = finalToTurn({});
|
|
50
50
|
expect(out).toEqual({ text: '', rawContent: '', toolCalls: [], truncated: false, stopReason: undefined });
|
|
51
51
|
});
|
|
52
|
+
|
|
53
|
+
// The QVAC SDK / small models sometimes emit tool calls as plain text instead
|
|
54
|
+
// of structured frames; finalToTurn must recover them so they still execute.
|
|
55
|
+
describe('inline tool-call recovery (SDK gave no structured toolCalls)', () => {
|
|
56
|
+
it('recovers a <tool_call> block and hides the tags from the answer', () => {
|
|
57
|
+
const out = finalToTurn({
|
|
58
|
+
contentText:
|
|
59
|
+
'<tool_call> {"name": "rln_create_rgb_invoice", "arguments": {}} </tool_call>',
|
|
60
|
+
});
|
|
61
|
+
expect(out.toolCalls).toEqual([{ name: 'rln_create_rgb_invoice', arguments: {} }]);
|
|
62
|
+
expect(out.text).toBe('');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('keeps the trailing sentence after the tag out of the answer but runs the call', () => {
|
|
66
|
+
const out = finalToTurn({
|
|
67
|
+
contentText:
|
|
68
|
+
'<tool_call> {"name": "rln_create_rgb_invoice", "arguments": {}} </tool_call> Please specify the asset ID.',
|
|
69
|
+
});
|
|
70
|
+
expect(out.toolCalls).toEqual([{ name: 'rln_create_rgb_invoice', arguments: {} }]);
|
|
71
|
+
expect(out.text).toBe('Please specify the asset ID.');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('recovers nested arguments', () => {
|
|
75
|
+
const out = finalToTurn({
|
|
76
|
+
contentText:
|
|
77
|
+
'<tool_call> {"name": "lsp_get_order", "arguments": {"order_id": "latest", "access_token": "latest"}} </tool_call>',
|
|
78
|
+
});
|
|
79
|
+
expect(out.toolCalls).toEqual([
|
|
80
|
+
{ name: 'lsp_get_order', arguments: { order_id: 'latest', access_token: 'latest' } },
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('recovers a bare leading tool-call object', () => {
|
|
85
|
+
const out = finalToTurn({ contentText: '{"name": "get_balances", "arguments": {}}' });
|
|
86
|
+
expect(out.toolCalls).toEqual([{ name: 'get_balances', arguments: {} }]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('does NOT recover when the SDK already returned structured calls', () => {
|
|
90
|
+
const out = finalToTurn({
|
|
91
|
+
contentText: '<tool_call> {"name": "ghost", "arguments": {}} </tool_call>',
|
|
92
|
+
toolCalls: [{ name: 'real_tool', arguments: { a: 1 } }],
|
|
93
|
+
});
|
|
94
|
+
expect(out.toolCalls).toEqual([{ id: undefined, name: 'real_tool', arguments: { a: 1 } }]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('ignores JSON the model is merely talking about (not a call)', () => {
|
|
98
|
+
const out = finalToTurn({
|
|
99
|
+
contentText: 'A tool call looks like {"name": "x", "arguments": {}} in JSON.',
|
|
100
|
+
});
|
|
101
|
+
expect(out.toolCalls).toEqual([]);
|
|
102
|
+
expect(out.text).toContain('A tool call looks like');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('extractTextToolCalls', () => {
|
|
107
|
+
it('extracts multiple tagged calls', () => {
|
|
108
|
+
const calls = extractTextToolCalls(
|
|
109
|
+
'<tool_call>{"name":"a","arguments":{}}</tool_call> and <tool_call>{"name":"b","arguments":{"x":1}}</tool_call>',
|
|
110
|
+
);
|
|
111
|
+
expect(calls).toEqual([
|
|
112
|
+
{ name: 'a', arguments: {} },
|
|
113
|
+
{ name: 'b', arguments: { x: 1 } },
|
|
114
|
+
]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('returns [] for plain prose', () => {
|
|
118
|
+
expect(extractTextToolCalls('just a normal answer')).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
52
121
|
});
|
package/src/qvac/parse.ts
CHANGED
|
@@ -35,22 +35,88 @@ export interface ParsedTurn {
|
|
|
35
35
|
stopReason?: string;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** Parse the first balanced `{…}` from a string as a `{name, arguments}` call. */
|
|
39
|
+
function parseCallObject(
|
|
40
|
+
s: string,
|
|
41
|
+
): { name: string; arguments: Record<string, unknown> } | null {
|
|
42
|
+
const start = s.indexOf('{');
|
|
43
|
+
if (start < 0) return null;
|
|
44
|
+
let depth = 0;
|
|
45
|
+
for (let i = start; i < s.length; i++) {
|
|
46
|
+
const ch = s[i];
|
|
47
|
+
if (ch === '{') depth++;
|
|
48
|
+
else if (ch === '}' && --depth === 0) {
|
|
49
|
+
try {
|
|
50
|
+
const obj = JSON.parse(s.slice(start, i + 1)) as {
|
|
51
|
+
name?: unknown;
|
|
52
|
+
arguments?: unknown;
|
|
53
|
+
};
|
|
54
|
+
if (obj && typeof obj.name === 'string') {
|
|
55
|
+
const args =
|
|
56
|
+
obj.arguments && typeof obj.arguments === 'object'
|
|
57
|
+
? (obj.arguments as Record<string, unknown>)
|
|
58
|
+
: {};
|
|
59
|
+
return { name: obj.name, arguments: args };
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
/* malformed JSON — give up on this fragment */
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Recover tool calls a model emitted as PLAIN TEXT instead of structured frames
|
|
72
|
+
* — `<tool_call>{"name":…,"arguments":…}</tool_call>` (Qwen/Hermes) or a bare
|
|
73
|
+
* leading `{"name":…,"arguments":…}`. Small local models (and SDK builds that
|
|
74
|
+
* don't apply the tool grammar) do this; without recovery the call leaks into
|
|
75
|
+
* the visible answer and never runs.
|
|
76
|
+
*/
|
|
77
|
+
export function extractTextToolCalls(
|
|
78
|
+
text: string,
|
|
79
|
+
): Array<{ name: string; arguments: Record<string, unknown> }> {
|
|
80
|
+
const calls: Array<{ name: string; arguments: Record<string, unknown> }> = [];
|
|
81
|
+
for (const m of text.matchAll(/<tool_call\b[^>]*>([\s\S]*?)<\/tool_call>/gi)) {
|
|
82
|
+
const c = parseCallObject(m[1] ?? '');
|
|
83
|
+
if (c) calls.push(c);
|
|
84
|
+
}
|
|
85
|
+
if (calls.length) return calls;
|
|
86
|
+
// No tags — accept a bare tool-call object only at the very start of the
|
|
87
|
+
// text (so we don't misread JSON the model is merely talking about).
|
|
88
|
+
if (/^\s*\{?\s*"name"\s*:/i.test(text)) {
|
|
89
|
+
const c = parseCallObject(text);
|
|
90
|
+
if (c) calls.push(c);
|
|
91
|
+
}
|
|
92
|
+
return calls;
|
|
93
|
+
}
|
|
94
|
+
|
|
38
95
|
/**
|
|
39
96
|
* Map a completion `final` (plus the streamed fallback text) into a ParsedTurn.
|
|
40
97
|
* `rawContent` prefers the SDK's framed `raw.fullText` so the Engine can anchor
|
|
41
98
|
* the next turn; falls back to the visible text when a provider has no raw form.
|
|
99
|
+
*
|
|
100
|
+
* When the SDK reports no structured tool calls, we re-scan the raw text for
|
|
101
|
+
* tool calls the model emitted inline (see `extractTextToolCalls`) so they still
|
|
102
|
+
* execute instead of leaking into the chat.
|
|
42
103
|
*/
|
|
43
104
|
export function finalToTurn(final: QvacFinalLike, streamed = ''): ParsedTurn {
|
|
44
105
|
const rawText = final.contentText || streamed;
|
|
45
106
|
const text = cleanAssistantVisibleText(rawText);
|
|
107
|
+
let toolCalls = (final.toolCalls ?? []).map((c) => ({
|
|
108
|
+
id: c.id,
|
|
109
|
+
name: c.name,
|
|
110
|
+
arguments: c.arguments ?? {},
|
|
111
|
+
}));
|
|
112
|
+
if (toolCalls.length === 0) {
|
|
113
|
+
const recovered = extractTextToolCalls(final.raw?.fullText ?? rawText);
|
|
114
|
+
if (recovered.length) toolCalls = recovered.map((c) => ({ id: undefined, ...c }));
|
|
115
|
+
}
|
|
46
116
|
return {
|
|
47
117
|
text,
|
|
48
118
|
rawContent: final.raw?.fullText ?? rawText,
|
|
49
|
-
toolCalls
|
|
50
|
-
id: c.id,
|
|
51
|
-
name: c.name,
|
|
52
|
-
arguments: c.arguments ?? {},
|
|
53
|
-
})),
|
|
119
|
+
toolCalls,
|
|
54
120
|
truncated: final.stopReason === 'length',
|
|
55
121
|
stopReason: final.stopReason,
|
|
56
122
|
};
|
package/src/qvac/text.ts
CHANGED
|
@@ -15,6 +15,10 @@ export function cleanAssistantVisibleText(text: string): string {
|
|
|
15
15
|
// Qwen-style reasoning sometimes arrives in contentText. Never show/speak it.
|
|
16
16
|
.replace(/<think\b[\s\S]*?<\/think>/gi, ' ')
|
|
17
17
|
.replace(/<think\b[\s\S]*$/gi, ' ')
|
|
18
|
+
// Tool calls some models emit as text (<tool_call>{…}</tool_call>) are
|
|
19
|
+
// extracted + executed by the Engine (see parse.ts); never show the tags.
|
|
20
|
+
.replace(/<tool_call\b[^>]*>[\s\S]*?<\/tool_call>/gi, ' ')
|
|
21
|
+
.replace(/<tool_call\b[^>]*>[\s\S]*$/gi, ' ')
|
|
18
22
|
.replace(/\s+/g, ' ')
|
|
19
23
|
.trim();
|
|
20
24
|
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { ToolRegistry } from '../tools/registry.js';
|
|
3
|
+
import { InProcessToolSource } from '../tools/in-process.js';
|
|
4
|
+
import type { LLMProvider } from '../providers/types.js';
|
|
5
|
+
import { runRecipe, RecipeRegistry } from './runner.js';
|
|
6
|
+
import { buyAssetChannelRecipe, extractBuyAsset } from './buy-asset-channel.js';
|
|
7
|
+
import { swapRecipe } from './swap.js';
|
|
8
|
+
import { assetSendRecipe } from './asset-send.js';
|
|
9
|
+
|
|
10
|
+
const approve: LLMProvider = { name: 'x', runTurn: async () => ({ text: '', rawContent: '', toolCalls: [] }) };
|
|
11
|
+
|
|
12
|
+
/** Stub the two asset-channel tools the recipe drives. */
|
|
13
|
+
function stubTools(spy?: { create?: (a: any) => void }) {
|
|
14
|
+
return new ToolRegistry([
|
|
15
|
+
new InProcessToolSource('ks', [
|
|
16
|
+
{
|
|
17
|
+
name: 'kaleidoswap_lsp_quote_asset_channel',
|
|
18
|
+
description: '',
|
|
19
|
+
parameters: { type: 'object', properties: {} },
|
|
20
|
+
handler: async (a) => ({
|
|
21
|
+
rfq_id: 'rfq1',
|
|
22
|
+
asset_amount: a.asset_amount,
|
|
23
|
+
btc_amount_sat: 13807,
|
|
24
|
+
channel_fee_sat: 16139,
|
|
25
|
+
total_sat: 29946,
|
|
26
|
+
expires_at: 1234567890,
|
|
27
|
+
}),
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'kaleidoswap_lsp_create_asset_channel',
|
|
31
|
+
description: '',
|
|
32
|
+
parameters: { type: 'object', properties: {} },
|
|
33
|
+
requiresConfirmation: true,
|
|
34
|
+
handler: async (a) => {
|
|
35
|
+
spy?.create?.(a);
|
|
36
|
+
return { order_id: 'ord1', total_sat: 29946, payment: { onchain_address: 'bcrt1qexample' } };
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
]),
|
|
40
|
+
]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('extractBuyAsset (deterministic Tier-0)', () => {
|
|
44
|
+
it('parses "buy 100 usdt"', () => {
|
|
45
|
+
expect(extractBuyAsset('buy 100 usdt')).toEqual({ asset: 'USDT', asset_amount: 100 });
|
|
46
|
+
});
|
|
47
|
+
it('parses "get me 50 xaut"', () => {
|
|
48
|
+
expect(extractBuyAsset('get me 50 xaut')).toEqual({ asset: 'XAUT', asset_amount: 50 });
|
|
49
|
+
});
|
|
50
|
+
it('parses "i want 200 usdt" and "purchase 10 xaut"', () => {
|
|
51
|
+
expect(extractBuyAsset('i want 200 usdt')).toEqual({ asset: 'USDT', asset_amount: 200 });
|
|
52
|
+
expect(extractBuyAsset('purchase 10 xaut')).toEqual({ asset: 'XAUT', asset_amount: 10 });
|
|
53
|
+
});
|
|
54
|
+
it('handles comma grouping in the amount', () => {
|
|
55
|
+
expect(extractBuyAsset('buy 1,000 usdt')).toEqual({ asset: 'USDT', asset_amount: 1000 });
|
|
56
|
+
});
|
|
57
|
+
it('null for a swap (a named source asset ⇒ swap owns it)', () => {
|
|
58
|
+
expect(extractBuyAsset('buy 0.001 btc with usdt')).toBeNull();
|
|
59
|
+
expect(extractBuyAsset('swap 10 usdt for btc')).toBeNull();
|
|
60
|
+
expect(extractBuyAsset('buy 100 usdt with my bitcoin')).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
it('null for a send (asset-send owns it)', () => {
|
|
63
|
+
expect(extractBuyAsset('send 10 usdt to bob')).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
it('null for BTC (BTC is not bought via an asset channel)', () => {
|
|
66
|
+
expect(extractBuyAsset('buy 100000 sats')).toBeNull();
|
|
67
|
+
expect(extractBuyAsset('get 0.01 btc')).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('runRecipe — buy asset channel', () => {
|
|
72
|
+
it('quote → confirm → create order, deterministic (0 inferences)', async () => {
|
|
73
|
+
const created: any[] = [];
|
|
74
|
+
const tools = stubTools({ create: (a) => created.push(a) });
|
|
75
|
+
const onConfirm = vi.fn(async () => ({ approved: true }));
|
|
76
|
+
const res = await runRecipe(buyAssetChannelRecipe, 'buy 100 usdt', { provider: approve, tools, onConfirm });
|
|
77
|
+
|
|
78
|
+
expect(res.status).toBe('done');
|
|
79
|
+
expect(res.inferences).toBe(0);
|
|
80
|
+
expect(onConfirm).toHaveBeenCalledOnce();
|
|
81
|
+
expect(res.results.quote).toMatchObject({ rfq_id: 'rfq1' });
|
|
82
|
+
expect(created[0]).toMatchObject({ asset: 'USDT', asset_amount: 100, rfq_id: 'rfq1' });
|
|
83
|
+
// The quote's cost rides along for the confirm card.
|
|
84
|
+
expect(created[0]).toMatchObject({ total_sat: 29946, btc_amount_sat: 13807, channel_fee_sat: 16139 });
|
|
85
|
+
expect(res.text).toContain('100 USDT');
|
|
86
|
+
expect(res.text).toContain('29,946');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('denied confirmation → cancelled, no order placed', async () => {
|
|
90
|
+
const created: any[] = [];
|
|
91
|
+
const tools = stubTools({ create: (a) => created.push(a) });
|
|
92
|
+
const res = await runRecipe(buyAssetChannelRecipe, 'buy 100 usdt', {
|
|
93
|
+
provider: approve,
|
|
94
|
+
tools,
|
|
95
|
+
onConfirm: async () => ({ approved: false }),
|
|
96
|
+
});
|
|
97
|
+
expect(res.status).toBe('cancelled');
|
|
98
|
+
expect(created).toHaveLength(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('fails closed when no confirm handler is wired (spend never runs)', async () => {
|
|
102
|
+
const created: any[] = [];
|
|
103
|
+
const tools = stubTools({ create: (a) => created.push(a) });
|
|
104
|
+
const res = await runRecipe(buyAssetChannelRecipe, 'buy 100 usdt', { provider: approve, tools });
|
|
105
|
+
expect(res.status).toBe('cancelled');
|
|
106
|
+
expect(created).toHaveLength(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('falls back to ONE LLM extraction when the regex misses', async () => {
|
|
110
|
+
const created: any[] = [];
|
|
111
|
+
const tools = stubTools({ create: (a) => created.push(a) });
|
|
112
|
+
const llmOnly = { ...buyAssetChannelRecipe, extract: undefined };
|
|
113
|
+
const provider: LLMProvider = {
|
|
114
|
+
name: 'mock',
|
|
115
|
+
runTurn: vi.fn(async () => ({
|
|
116
|
+
text: '',
|
|
117
|
+
rawContent: '',
|
|
118
|
+
toolCalls: [{ id: '1', name: 'extract_request', arguments: { asset: 'USDT', asset_amount: 100 } }],
|
|
119
|
+
})),
|
|
120
|
+
};
|
|
121
|
+
const res = await runRecipe(llmOnly, 'could you set me up with a hundred tether', {
|
|
122
|
+
provider,
|
|
123
|
+
tools,
|
|
124
|
+
onConfirm: async () => ({ approved: true }),
|
|
125
|
+
});
|
|
126
|
+
expect(res.inferences).toBe(1);
|
|
127
|
+
expect(provider.runTurn).toHaveBeenCalledOnce();
|
|
128
|
+
expect(created[0]).toMatchObject({ asset: 'USDT', asset_amount: 100, rfq_id: 'rfq1' });
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('recipe selection / precedence', () => {
|
|
133
|
+
it('selects buy-asset-channel before swap for "buy 100 usdt"', () => {
|
|
134
|
+
const reg = new RecipeRegistry([buyAssetChannelRecipe, swapRecipe]);
|
|
135
|
+
expect(reg.select('buy 100 usdt')?.name).toBe('buy-asset-channel');
|
|
136
|
+
expect(reg.select('get me 50 xaut')?.name).toBe('buy-asset-channel');
|
|
137
|
+
});
|
|
138
|
+
it('does not hijack a swap or an asset send', () => {
|
|
139
|
+
const reg = new RecipeRegistry([buyAssetChannelRecipe, swapRecipe, assetSendRecipe]);
|
|
140
|
+
expect(reg.select('swap 10 usdt for btc')?.name).not.toBe('buy-asset-channel');
|
|
141
|
+
expect(reg.select('send 10 usdt to bob')?.name).not.toBe('buy-asset-channel');
|
|
142
|
+
});
|
|
143
|
+
it('confident only with both asset and a positive amount', () => {
|
|
144
|
+
expect(buyAssetChannelRecipe.confident!({ asset: 'USDT', asset_amount: 100 })).toBe(true);
|
|
145
|
+
expect(buyAssetChannelRecipe.confident!({ asset: 'USDT' })).toBe(false);
|
|
146
|
+
expect(buyAssetChannelRecipe.confident!({ asset: 'USDT', asset_amount: 0 })).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in "buy an asset channel" recipe — the onboarding buy.
|
|
3
|
+
*
|
|
4
|
+
* The user has on-chain BTC but no Lightning channel yet, and wants to HOLD an
|
|
5
|
+
* RGB asset (USDT, XAUT). They can't swap (no channel to swap inside), so they
|
|
6
|
+
* buy a NEW channel from the maker LSP pre-loaded with the asset. One quote,
|
|
7
|
+
* one spend:
|
|
8
|
+
*
|
|
9
|
+
* "buy 100 usdt" / "get me 50 xaut" / "i want 200 usdt"
|
|
10
|
+
* ↓ 1 model inference (slot extraction; 0 when the regex hits)
|
|
11
|
+
* kaleidoswap_lsp_quote_asset_channel ← maker prices the asset + channel
|
|
12
|
+
* kaleidoswap_lsp_create_asset_channel 🔒 ← (final) order it; pay to open
|
|
13
|
+
*
|
|
14
|
+
* Distinct from `swapRecipe`: a swap names a source asset ("swap 10 usdt FOR
|
|
15
|
+
* btc", "buy btc WITH usdt") and needs an existing channel. This is the
|
|
16
|
+
* no-source, no-channel onboarding path — "buy <amount> <asset>" with nothing
|
|
17
|
+
* to spend it from — so it must be SELECTED BEFORE swap for that phrasing.
|
|
18
|
+
*
|
|
19
|
+
* Opt-in: register via `Funnel.recipes` (like `kaleidoswapAtomicRecipe`). The
|
|
20
|
+
* host binds `kaleidoswap_lsp_*` to its transport (maker REST / MCP / WDK).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { Recipe } from './types.js';
|
|
24
|
+
|
|
25
|
+
/** RGB assets the maker sells as an asset channel. BTC is never "bought" this way. */
|
|
26
|
+
const RGB_ASSET = /\b(usdt|tether|xaut|gold)\b/i;
|
|
27
|
+
/** A named funding source ⇒ this is a swap, not an onboarding buy. */
|
|
28
|
+
const HAS_SOURCE = /\b(?:with|using|from)\b|\bfor\s+(?:btc|bitcoin|sats?|usdt|xaut|tether|gold)\b/i;
|
|
29
|
+
/** Verbs other intents own (swap / sell / send) — never an onboarding buy. */
|
|
30
|
+
const NOT_BUY = /\b(swap|exchange|convert|trade|sell|send)\b/i;
|
|
31
|
+
/** Acquire verbs that DO mean an onboarding buy. */
|
|
32
|
+
const BUY_VERB = /\b(buy|get|acquire|want|purchase|onboard|need)\b/i;
|
|
33
|
+
|
|
34
|
+
function normAsset(a?: string): string | undefined {
|
|
35
|
+
if (!a) return undefined;
|
|
36
|
+
const x = a.toLowerCase();
|
|
37
|
+
if (/usdt|tether/.test(x)) return 'USDT';
|
|
38
|
+
if (/xaut|gold/.test(x)) return 'XAUT';
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const num = (s?: string): number | undefined => {
|
|
43
|
+
if (!s) return undefined;
|
|
44
|
+
const n = Number(s.replace(/,/g, ''));
|
|
45
|
+
return Number.isFinite(n) ? n : undefined;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Thousands separators, locale-independent (deterministic for tests). */
|
|
49
|
+
const commas = (n: number): string => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
50
|
+
|
|
51
|
+
/** "buy 100 usdt" / "get me 50 xaut" / "i want 200 usdt" / "purchase 10 xaut". */
|
|
52
|
+
export function extractBuyAsset(text: string): Record<string, unknown> | null {
|
|
53
|
+
const t = text.trim();
|
|
54
|
+
if (NOT_BUY.test(t) || HAS_SOURCE.test(t)) return null;
|
|
55
|
+
if (!RGB_ASSET.test(t)) return null;
|
|
56
|
+
// buy/get/want/acquire/purchase [me] <amount> <asset>
|
|
57
|
+
const m = t.match(/\b(?:buy|get|acquire|want|purchase|onboard|need)\b(?:\s+me)?\s+([\d.,]+)\s*([a-z]+)/i);
|
|
58
|
+
if (!m) return null;
|
|
59
|
+
const asset = normAsset(m[2]);
|
|
60
|
+
const amount = num(m[1]);
|
|
61
|
+
if (!asset || amount === undefined) return null;
|
|
62
|
+
return { asset, asset_amount: amount };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const buyAssetChannelRecipe: Recipe = {
|
|
66
|
+
name: 'buy-asset-channel',
|
|
67
|
+
description:
|
|
68
|
+
'Onboarding buy: purchase a new Lightning channel pre-loaded with an RGB asset (USDT, XAUT) from the maker LSP — for a user with on-chain BTC but no channel yet. Quote, then order (with confirmation).',
|
|
69
|
+
// "buy/get/want N <rgb-asset>" with NO named source asset and NO swap/send verb.
|
|
70
|
+
match: (t) => !NOT_BUY.test(t) && !HAS_SOURCE.test(t) && RGB_ASSET.test(t) && BUY_VERB.test(t),
|
|
71
|
+
triggers: ['buy', 'get', 'purchase', 'acquire'],
|
|
72
|
+
slots: [
|
|
73
|
+
{ name: 'asset', type: 'string', description: 'RGB asset to acquire (USDT or XAUT)', required: true },
|
|
74
|
+
{ name: 'asset_amount', type: 'number', description: 'Amount of the asset to load into the channel (display units, e.g. 100)', required: true },
|
|
75
|
+
],
|
|
76
|
+
extract: extractBuyAsset,
|
|
77
|
+
confident: (s) => !!s.asset && s.asset_amount !== undefined && Number(s.asset_amount) > 0,
|
|
78
|
+
steps: [
|
|
79
|
+
// 1. Maker prices the asset + the channel.
|
|
80
|
+
// Returns { rfq_id, btc_amount_sat, channel_fee_sat, total_sat, expires_at }.
|
|
81
|
+
{
|
|
82
|
+
tool: 'kaleidoswap_lsp_quote_asset_channel',
|
|
83
|
+
as: 'quote',
|
|
84
|
+
args: (ctx) => ({ asset: ctx.slots.asset, asset_amount: ctx.slots.asset_amount }),
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
// 2. Order the channel with the fresh rfq_id. Spend → confirmation-gated.
|
|
88
|
+
// The quote's cost fields ride along so the host's confirm card can show
|
|
89
|
+
// the price before approval; the create tool treats them as display-only.
|
|
90
|
+
final: {
|
|
91
|
+
tool: 'kaleidoswap_lsp_create_asset_channel',
|
|
92
|
+
args: (ctx) => {
|
|
93
|
+
const q = (ctx.results.quote ?? {}) as {
|
|
94
|
+
rfq_id?: string;
|
|
95
|
+
total_sat?: number;
|
|
96
|
+
btc_amount_sat?: number;
|
|
97
|
+
channel_fee_sat?: number;
|
|
98
|
+
expires_at?: number;
|
|
99
|
+
};
|
|
100
|
+
return {
|
|
101
|
+
asset: ctx.slots.asset,
|
|
102
|
+
asset_amount: ctx.slots.asset_amount,
|
|
103
|
+
rfq_id: q.rfq_id,
|
|
104
|
+
total_sat: q.total_sat,
|
|
105
|
+
btc_amount_sat: q.btc_amount_sat,
|
|
106
|
+
channel_fee_sat: q.channel_fee_sat,
|
|
107
|
+
expires_at: q.expires_at,
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
summary: (ctx, finalResult) => {
|
|
112
|
+
const q = ctx.results.quote as { total_sat?: number } | undefined;
|
|
113
|
+
const o = finalResult as { order_id?: string } | undefined;
|
|
114
|
+
const cost = typeof q?.total_sat === 'number' ? ` for ${commas(q.total_sat)} sats` : '';
|
|
115
|
+
const id = o?.order_id ? ` (order ${o.order_id})` : '';
|
|
116
|
+
return `Ordered a Lightning channel with ${ctx.slots.asset_amount} ${ctx.slots.asset}${cost}${id}. Pay the returned invoice/address to open it.`;
|
|
117
|
+
},
|
|
118
|
+
};
|